mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-24 20:14:35 -04:00
refactor: reimplement realtime-communication
This commit refactors a lot of things that are not easy to separate. It replaces the binary protocol of y-protocols with json. It introduces event based message processing. It implements our own code mirror plugins for synchronisation of content and remote cursors Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
67cf1432b2
commit
3a06f84af1
110 changed files with 3920 additions and 2201 deletions
|
@ -1,77 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageType } from './messages/message-type.enum.js'
|
||||
import type { YDocMessageTransporter } from './y-doc-message-transporter.js'
|
||||
import { createEncoder, toUint8Array, writeVarUint } from 'lib0/encoding'
|
||||
|
||||
/**
|
||||
* Provides a keep alive ping for a given {@link WebSocket websocket} connection by sending a periodic message.
|
||||
*/
|
||||
export class ConnectionKeepAliveHandler {
|
||||
private pongReceived = false
|
||||
private static readonly pingTimeout = 30 * 1000
|
||||
private intervalId: NodeJS.Timer | undefined
|
||||
|
||||
/**
|
||||
* Constructs the instance and starts the interval.
|
||||
*
|
||||
* @param messageTransporter The websocket to keep alive
|
||||
*/
|
||||
constructor(private messageTransporter: YDocMessageTransporter) {
|
||||
this.messageTransporter.on('disconnected', () => this.stopTimer())
|
||||
this.messageTransporter.on('ready', () => this.startTimer())
|
||||
this.messageTransporter.on(String(MessageType.PING), () => {
|
||||
this.sendPongMessage()
|
||||
})
|
||||
this.messageTransporter.on(
|
||||
String(MessageType.PONG),
|
||||
() => (this.pongReceived = true)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the ping timer.
|
||||
*/
|
||||
public startTimer(): void {
|
||||
this.pongReceived = false
|
||||
this.intervalId = setInterval(
|
||||
() => this.check(),
|
||||
ConnectionKeepAliveHandler.pingTimeout
|
||||
)
|
||||
this.sendPingMessage()
|
||||
}
|
||||
|
||||
public stopTimer(): void {
|
||||
clearInterval(this.intervalId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a pong has been received since the last run. If not, the connection is probably dead and will be terminated.
|
||||
*/
|
||||
private check(): void {
|
||||
if (this.pongReceived) {
|
||||
this.pongReceived = false
|
||||
this.sendPingMessage()
|
||||
} else {
|
||||
this.messageTransporter.disconnect()
|
||||
console.error(
|
||||
`No pong received in the last ${ConnectionKeepAliveHandler.pingTimeout} seconds. Connection seems to be dead.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private sendPingMessage(): void {
|
||||
const encoder = createEncoder()
|
||||
writeVarUint(encoder, MessageType.PING)
|
||||
this.messageTransporter.send(toUint8Array(encoder))
|
||||
}
|
||||
|
||||
private sendPongMessage(): void {
|
||||
const encoder = createEncoder()
|
||||
writeVarUint(encoder, MessageType.PONG)
|
||||
this.messageTransporter.send(toUint8Array(encoder))
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent'
|
|
@ -1,28 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export { MessageType } from './messages/message-type.enum.js'
|
||||
export { ConnectionKeepAliveHandler } from './connection-keep-alive-handler.js'
|
||||
export { YDocMessageTransporter } from './y-doc-message-transporter.js'
|
||||
export {
|
||||
applyAwarenessUpdateMessage,
|
||||
encodeAwarenessUpdateMessage
|
||||
} from './messages/awareness-update-message.js'
|
||||
export {
|
||||
applyDocumentUpdateMessage,
|
||||
encodeDocumentUpdateMessage
|
||||
} from './messages/document-update-message.js'
|
||||
export { encodeCompleteAwarenessStateRequestMessage } from './messages/complete-awareness-state-request-message.js'
|
||||
export { encodeCompleteDocumentStateRequestMessage } from './messages/complete-document-state-request-message.js'
|
||||
export { encodeCompleteDocumentStateAnswerMessage } from './messages/complete-document-state-answer-message.js'
|
||||
export { encodeDocumentDeletedMessage } from './messages/document-deleted-message.js'
|
||||
export { encodeMetadataUpdatedMessage } from './messages/metadata-updated-message.js'
|
||||
export { encodeServerVersionUpdatedMessage } from './messages/server-version-updated-message.js'
|
||||
|
||||
export { WebsocketTransporter } from './websocket-transporter.js'
|
||||
export * from './message-transporters/mocked-backend-message-transporter.js'
|
||||
export * from './message-transporters/message.js'
|
||||
export * from './message-transporters/message-transporter.js'
|
||||
export * from './message-transporters/realtime-user.js'
|
||||
export * from './message-transporters/websocket-transporter.js'
|
||||
|
||||
export { parseUrl } from './utils/parse-url.js'
|
||||
export {
|
||||
|
@ -30,8 +16,10 @@ export {
|
|||
WrongProtocolError
|
||||
} from './utils/errors.js'
|
||||
|
||||
export type { MessageTransporterEvents } from './y-doc-message-transporter.js'
|
||||
export * from './y-doc-sync/y-doc-sync-client-adapter.js'
|
||||
export * from './y-doc-sync/y-doc-sync-server-adapter.js'
|
||||
export * from './y-doc-sync/y-doc-sync-adapter.js'
|
||||
|
||||
export { waitForOtherPromisesToFinish } from './utils/wait-for-other-promises-to-finish.js'
|
||||
|
||||
export { MARKDOWN_CONTENT_CHANNEL_NAME } from './constants/markdown-content-channel-name.js'
|
||||
export { RealtimeDoc } from './y-doc-sync/realtime-doc'
|
||||
|
|
102
commons/src/message-transporters/message-transporter.ts
Normal file
102
commons/src/message-transporters/message-transporter.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Message, MessagePayloads, MessageType } from './message.js'
|
||||
import { EventEmitter2, Listener } from 'eventemitter2'
|
||||
|
||||
export type MessageEvents = MessageType | 'connected' | 'disconnected'
|
||||
|
||||
type MessageEventPayloadMap = {
|
||||
[E in MessageEvents]: E extends keyof MessagePayloads
|
||||
? (message: Message<E>) => void
|
||||
: () => void
|
||||
}
|
||||
|
||||
export enum ConnectionState {
|
||||
DISCONNECT,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for event based message communication.
|
||||
*/
|
||||
export abstract class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> {
|
||||
private readyMessageReceived = false
|
||||
|
||||
public abstract sendMessage<M extends MessageType>(content: Message<M>): void
|
||||
|
||||
protected receiveMessage<L extends MessageType>(message: Message<L>): void {
|
||||
if (message.type === MessageType.READY) {
|
||||
this.readyMessageReceived = true
|
||||
}
|
||||
this.emit(message.type, message)
|
||||
}
|
||||
|
||||
public sendReady(): void {
|
||||
this.sendMessage({
|
||||
type: MessageType.READY
|
||||
})
|
||||
}
|
||||
|
||||
public abstract disconnect(): void
|
||||
|
||||
public abstract getConnectionState(): ConnectionState
|
||||
|
||||
protected onConnected(): void {
|
||||
this.emit('connected')
|
||||
}
|
||||
|
||||
protected onDisconnecting(): void {
|
||||
this.readyMessageReceived = false
|
||||
this.emit('disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the message transporter is connected and can send/receive messages.
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.getConnectionState() === ConnectionState.CONNECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the message transporter has receives a {@link MessageType.READY ready message} yet.
|
||||
*/
|
||||
public isReady(): boolean {
|
||||
return this.readyMessageReceived
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given callback whenever the message transporter receives a ready message.
|
||||
* If the messenger has already received a ready message then the callback will be executed immediately.
|
||||
*
|
||||
* @param callback The callback to execute when ready
|
||||
* @return The event listener that waits for ready messages
|
||||
*/
|
||||
public doAsSoonAsReady(callback: () => void): Listener {
|
||||
if (this.readyMessageReceived) {
|
||||
callback()
|
||||
}
|
||||
return this.on(MessageType.READY, callback, {
|
||||
objectify: true
|
||||
}) as Listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given callback whenever the message transporter has established a connection.
|
||||
* If the messenger is already connected then the callback will be executed immediately.
|
||||
*
|
||||
* @param callback The callback to execute when connected
|
||||
* @return The event listener that waits for connection events
|
||||
*/
|
||||
public doAsSoonAsConnected(callback: () => void): Listener {
|
||||
if (this.isConnected()) {
|
||||
callback()
|
||||
}
|
||||
return this.on('connected', callback, {
|
||||
objectify: true
|
||||
}) as Listener
|
||||
}
|
||||
}
|
36
commons/src/message-transporters/message.ts
Normal file
36
commons/src/message-transporters/message.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { RealtimeUser, RemoteCursor } from './realtime-user.js'
|
||||
|
||||
export enum MessageType {
|
||||
NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST',
|
||||
NOTE_CONTENT_UPDATE = 'NOTE_CONTENT_UPDATE',
|
||||
PING = 'PING',
|
||||
PONG = 'PONG',
|
||||
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',
|
||||
READY = 'READY'
|
||||
}
|
||||
|
||||
export interface MessagePayloads {
|
||||
[MessageType.NOTE_CONTENT_STATE_REQUEST]: number[]
|
||||
[MessageType.NOTE_CONTENT_UPDATE]: number[]
|
||||
[MessageType.REALTIME_USER_STATE_SET]: RealtimeUser[]
|
||||
[MessageType.REALTIME_USER_SINGLE_UPDATE]: RemoteCursor
|
||||
}
|
||||
|
||||
export type Message<T extends MessageType> = T extends keyof MessagePayloads
|
||||
? {
|
||||
type: T
|
||||
payload: MessagePayloads[T]
|
||||
}
|
||||
: {
|
||||
type: T
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { RealtimeDoc } from '../y-doc-sync/realtime-doc.js'
|
||||
import { ConnectionState, MessageTransporter } from './message-transporter.js'
|
||||
import { Message, MessageType } from './message.js'
|
||||
import { Doc, encodeStateAsUpdate } from 'yjs'
|
||||
|
||||
/**
|
||||
* A mocked connection that doesn't send or receive any data and is instantly ready.
|
||||
* The only exception is the note content state request that is answered with the given initial content.
|
||||
*/
|
||||
export class MockedBackendMessageTransporter extends MessageTransporter {
|
||||
private readonly doc: Doc
|
||||
|
||||
private connected = true
|
||||
|
||||
constructor(initialContent: string) {
|
||||
super()
|
||||
this.doc = new RealtimeDoc(initialContent)
|
||||
|
||||
this.onConnected()
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (!this.connected) {
|
||||
return
|
||||
}
|
||||
this.connected = false
|
||||
this.onDisconnecting()
|
||||
}
|
||||
|
||||
sendReady() {
|
||||
this.receiveMessage({
|
||||
type: MessageType.READY
|
||||
})
|
||||
}
|
||||
|
||||
sendMessage<M extends MessageType>(content: Message<M>) {
|
||||
if (content.type === MessageType.NOTE_CONTENT_STATE_REQUEST) {
|
||||
setTimeout(() => {
|
||||
const payload = Array.from(
|
||||
encodeStateAsUpdate(this.doc, new Uint8Array(content.payload))
|
||||
)
|
||||
this.receiveMessage({ type: MessageType.NOTE_CONTENT_UPDATE, payload })
|
||||
}, 10)
|
||||
}
|
||||
}
|
||||
|
||||
getConnectionState(): ConnectionState {
|
||||
return this.connected
|
||||
? ConnectionState.CONNECTED
|
||||
: ConnectionState.DISCONNECT
|
||||
}
|
||||
}
|
18
commons/src/message-transporters/realtime-user.ts
Normal file
18
commons/src/message-transporters/realtime-user.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface RealtimeUser {
|
||||
displayName: string
|
||||
username: string | null
|
||||
active: boolean
|
||||
styleIndex: number
|
||||
cursor: RemoteCursor
|
||||
}
|
||||
|
||||
export interface RemoteCursor {
|
||||
from: number
|
||||
to?: number
|
||||
}
|
97
commons/src/message-transporters/websocket-transporter.ts
Normal file
97
commons/src/message-transporters/websocket-transporter.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConnectionState, MessageTransporter } from './message-transporter.js'
|
||||
import { Message, MessageType } from './message.js'
|
||||
import WebSocket, { CloseEvent, ErrorEvent, MessageEvent } from 'isomorphic-ws'
|
||||
|
||||
export class WebsocketTransporter extends MessageTransporter {
|
||||
private websocket: WebSocket | undefined
|
||||
|
||||
private messageCallback: undefined | ((event: MessageEvent) => void)
|
||||
private errorCallback: undefined | ((event: ErrorEvent) => void)
|
||||
private closeCallback: undefined | ((event: CloseEvent) => void)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
public setWebsocket(websocket: WebSocket) {
|
||||
if (
|
||||
websocket.readyState === WebSocket.CLOSED ||
|
||||
websocket.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
throw new Error('Websocket must be open')
|
||||
}
|
||||
this.undbindEventsFromPreviousWebsocket()
|
||||
this.websocket = websocket
|
||||
this.bindWebsocketEvents(websocket)
|
||||
|
||||
if (this.websocket.readyState === WebSocket.OPEN) {
|
||||
this.onConnected()
|
||||
} else {
|
||||
this.websocket.addEventListener('open', this.onConnected.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
private undbindEventsFromPreviousWebsocket() {
|
||||
if (this.websocket) {
|
||||
if (this.messageCallback) {
|
||||
this.websocket.removeEventListener('message', this.messageCallback)
|
||||
}
|
||||
if (this.errorCallback) {
|
||||
this.websocket.removeEventListener('error', this.errorCallback)
|
||||
}
|
||||
if (this.closeCallback) {
|
||||
this.websocket.removeEventListener('close', this.closeCallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bindWebsocketEvents(websocket: WebSocket) {
|
||||
this.messageCallback = this.processMessageEvent.bind(this)
|
||||
this.errorCallback = this.disconnect.bind(this)
|
||||
this.closeCallback = this.onDisconnecting.bind(this)
|
||||
|
||||
websocket.addEventListener('message', this.messageCallback)
|
||||
websocket.addEventListener('error', this.errorCallback)
|
||||
websocket.addEventListener('close', this.closeCallback)
|
||||
}
|
||||
|
||||
private processMessageEvent(event: MessageEvent): void {
|
||||
if (typeof event.data !== 'string') {
|
||||
return
|
||||
}
|
||||
const message = JSON.parse(event.data) as Message<MessageType>
|
||||
this.receiveMessage(message)
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.websocket?.close()
|
||||
}
|
||||
|
||||
public sendMessage(content: Message<MessageType>): void {
|
||||
if (this.websocket?.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("Can't send message over non-open socket")
|
||||
}
|
||||
|
||||
try {
|
||||
this.websocket.send(JSON.stringify(content))
|
||||
} catch (error: unknown) {
|
||||
this.disconnect()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public getConnectionState(): ConnectionState {
|
||||
if (this.websocket?.readyState === WebSocket.OPEN) {
|
||||
return ConnectionState.CONNECTED
|
||||
} else if (this.websocket?.readyState === WebSocket.CONNECTING) {
|
||||
return ConnectionState.CONNECTING
|
||||
} else {
|
||||
return ConnectionState.DISCONNECT
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
import type { Decoder } from 'lib0/decoding'
|
||||
import { readVarUint8Array } from 'lib0/decoding'
|
||||
import {
|
||||
createEncoder,
|
||||
toUint8Array,
|
||||
writeVarUint,
|
||||
writeVarUint8Array
|
||||
} from 'lib0/encoding'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
import {
|
||||
applyAwarenessUpdate,
|
||||
encodeAwarenessUpdate
|
||||
} from 'y-protocols/awareness'
|
||||
|
||||
export function applyAwarenessUpdateMessage(
|
||||
decoder: Decoder,
|
||||
awareness: Awareness,
|
||||
origin: unknown
|
||||
): void {
|
||||
applyAwarenessUpdate(awareness, readVarUint8Array(decoder), origin)
|
||||
}
|
||||
|
||||
export function encodeAwarenessUpdateMessage(
|
||||
awareness: Awareness,
|
||||
updatedClientIds: number[]
|
||||
): Uint8Array {
|
||||
const encoder = createEncoder()
|
||||
writeVarUint(encoder, MessageType.AWARENESS_UPDATE)
|
||||
writeVarUint8Array(
|
||||
encoder,
|
||||
encodeAwarenessUpdate(awareness, updatedClientIds)
|
||||
)
|
||||
return toUint8Array(encoder)
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { encodeGenericMessage } from './generic-message.js'
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
|
||||
export function encodeCompleteAwarenessStateRequestMessage(): Uint8Array {
|
||||
return encodeGenericMessage(MessageType.COMPLETE_AWARENESS_STATE_REQUEST)
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
import { decoding } from 'lib0'
|
||||
import { Decoder } from 'lib0/decoding'
|
||||
import {
|
||||
createEncoder,
|
||||
toUint8Array,
|
||||
writeVarUint,
|
||||
writeVarUint8Array
|
||||
} from 'lib0/encoding'
|
||||
import type { Doc } from 'yjs'
|
||||
import { encodeStateAsUpdate } from 'yjs'
|
||||
|
||||
export function encodeCompleteDocumentStateAnswerMessage(
|
||||
doc: Doc,
|
||||
decoder: Decoder
|
||||
): Uint8Array {
|
||||
const encoder = createEncoder()
|
||||
writeVarUint(encoder, MessageType.COMPLETE_DOCUMENT_STATE_ANSWER)
|
||||
writeVarUint8Array(
|
||||
encoder,
|
||||
encodeStateAsUpdate(doc, decoding.readVarUint8Array(decoder))
|
||||
)
|
||||
return toUint8Array(encoder)
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
import {
|
||||
createEncoder,
|
||||
toUint8Array,
|
||||
writeVarUint,
|
||||
writeVarUint8Array
|
||||
} from 'lib0/encoding'
|
||||
import type { Doc } from 'yjs'
|
||||
import { encodeStateVector } from 'yjs'
|
||||
|
||||
export function encodeCompleteDocumentStateRequestMessage(
|
||||
doc: Doc
|
||||
): Uint8Array {
|
||||
const encoder = createEncoder()
|
||||
writeVarUint(encoder, MessageType.COMPLETE_DOCUMENT_STATE_REQUEST)
|
||||
writeVarUint8Array(encoder, encodeStateVector(doc))
|
||||
return toUint8Array(encoder)
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { encodeGenericMessage } from './generic-message.js'
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
|
||||
export function encodeDocumentDeletedMessage(): Uint8Array {
|
||||
return encodeGenericMessage(MessageType.DOCUMENT_DELETED)
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
import { readVarUint8Array } from 'lib0/decoding'
|
||||
import type { Decoder } from 'lib0/decoding.js'
|
||||
import {
|
||||
createEncoder,
|
||||
toUint8Array,
|
||||
writeVarUint,
|
||||
writeVarUint8Array
|
||||
} from 'lib0/encoding'
|
||||
import type { Doc } from 'yjs'
|
||||
import { applyUpdate } from 'yjs'
|
||||
|
||||
export function applyDocumentUpdateMessage(
|
||||
decoder: Decoder,
|
||||
doc: Doc,
|
||||
origin: unknown
|
||||
): void {
|
||||
applyUpdate(doc, readVarUint8Array(decoder), origin)
|
||||
}
|
||||
|
||||
export function encodeDocumentUpdateMessage(
|
||||
documentUpdate: Uint8Array
|
||||
): Uint8Array {
|
||||
const encoder = createEncoder()
|
||||
writeVarUint(encoder, MessageType.DOCUMENT_UPDATE)
|
||||
writeVarUint8Array(encoder, documentUpdate)
|
||||
return toUint8Array(encoder)
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
import { createEncoder, toUint8Array, writeVarUint } from 'lib0/encoding'
|
||||
|
||||
/**
|
||||
* Encodes a generic message with a given message type but without content.
|
||||
*/
|
||||
export function encodeGenericMessage(messageType: MessageType): Uint8Array {
|
||||
const encoder = createEncoder()
|
||||
writeVarUint(encoder, messageType)
|
||||
return toUint8Array(encoder)
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum MessageType {
|
||||
COMPLETE_DOCUMENT_STATE_REQUEST = 0,
|
||||
COMPLETE_DOCUMENT_STATE_ANSWER = 1,
|
||||
DOCUMENT_UPDATE = 2,
|
||||
AWARENESS_UPDATE = 3,
|
||||
COMPLETE_AWARENESS_STATE_REQUEST = 4,
|
||||
PING = 5,
|
||||
PONG = 6,
|
||||
READY_REQUEST = 7,
|
||||
READY_ANSWER = 8,
|
||||
METADATA_UPDATED = 9,
|
||||
DOCUMENT_DELETED = 10,
|
||||
SERVER_VERSION_UPDATED = 11
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { encodeGenericMessage } from './generic-message.js'
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
|
||||
export function encodeMetadataUpdatedMessage(): Uint8Array {
|
||||
return encodeGenericMessage(MessageType.METADATA_UPDATED)
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { encodeGenericMessage } from './generic-message.js'
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
|
||||
export function encodeReadyAnswerMessage(): Uint8Array {
|
||||
return encodeGenericMessage(MessageType.READY_ANSWER)
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { encodeGenericMessage } from './generic-message.js'
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
|
||||
export function encodeReadyRequestMessage(): Uint8Array {
|
||||
return encodeGenericMessage(MessageType.READY_REQUEST)
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { encodeGenericMessage } from './generic-message.js'
|
||||
import { MessageType } from './message-type.enum.js'
|
||||
|
||||
export function encodeServerVersionUpdatedMessage(): Uint8Array {
|
||||
return encodeGenericMessage(MessageType.SERVER_VERSION_UPDATED)
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConnectionKeepAliveHandler } from './connection-keep-alive-handler.js'
|
||||
import { YDocMessageTransporter } from './y-doc-message-transporter.js'
|
||||
import WebSocket from 'isomorphic-ws'
|
||||
import { Awareness } from 'y-protocols/awareness'
|
||||
import { Doc } from 'yjs'
|
||||
|
||||
export class WebsocketTransporter extends YDocMessageTransporter {
|
||||
private websocket: WebSocket | undefined
|
||||
|
||||
constructor(doc: Doc, awareness: Awareness) {
|
||||
super(doc, awareness)
|
||||
new ConnectionKeepAliveHandler(this)
|
||||
}
|
||||
|
||||
public setupWebsocket(websocket: WebSocket) {
|
||||
if (
|
||||
websocket.readyState === WebSocket.CLOSED ||
|
||||
websocket.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
throw new Error(`Socket is closed`)
|
||||
}
|
||||
this.websocket = websocket
|
||||
websocket.binaryType = 'arraybuffer'
|
||||
websocket.addEventListener('message', (event) =>
|
||||
this.decodeMessage(event.data as ArrayBuffer)
|
||||
)
|
||||
websocket.addEventListener('error', () => this.disconnect())
|
||||
websocket.addEventListener('close', () => this.onClose())
|
||||
if (websocket.readyState === WebSocket.OPEN) {
|
||||
this.onOpen()
|
||||
} else {
|
||||
websocket.addEventListener('open', this.onOpen.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.websocket?.close()
|
||||
}
|
||||
|
||||
public send(content: Uint8Array): void {
|
||||
if (this.websocket?.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("Can't send message over non-open socket")
|
||||
}
|
||||
|
||||
try {
|
||||
this.websocket.send(content)
|
||||
} catch (error: unknown) {
|
||||
this.disconnect()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public isWebSocketOpen(): boolean {
|
||||
return this.websocket?.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
|
@ -1,207 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MARKDOWN_CONTENT_CHANNEL_NAME } from './constants/markdown-content-channel-name.js'
|
||||
import { encodeDocumentUpdateMessage } from './messages/document-update-message.js'
|
||||
import { MessageType } from './messages/message-type.enum.js'
|
||||
import { YDocMessageTransporter } from './y-doc-message-transporter.js'
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
import { Awareness } from 'y-protocols/awareness'
|
||||
import { Doc } from 'yjs'
|
||||
|
||||
class InMemoryMessageTransporter extends YDocMessageTransporter {
|
||||
private otherSide: InMemoryMessageTransporter | undefined
|
||||
|
||||
constructor(private name: string, doc: Doc, awareness: Awareness) {
|
||||
super(doc, awareness)
|
||||
}
|
||||
|
||||
public connect(other: InMemoryMessageTransporter): void {
|
||||
this.setOtherSide(other)
|
||||
other.setOtherSide(this)
|
||||
this.onOpen()
|
||||
other.onOpen()
|
||||
}
|
||||
|
||||
private setOtherSide(other: InMemoryMessageTransporter | undefined): void {
|
||||
this.otherSide = other
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.onClose()
|
||||
this.setOtherSide(undefined)
|
||||
this.otherSide?.onClose()
|
||||
this.otherSide?.setOtherSide(undefined)
|
||||
}
|
||||
|
||||
send(content: Uint8Array): void {
|
||||
if (this.otherSide === undefined) {
|
||||
throw new Error('Disconnected')
|
||||
}
|
||||
console.debug(`${this.name}`, 'Sending', content)
|
||||
this.otherSide?.decodeMessage(content)
|
||||
}
|
||||
|
||||
public onOpen(): void {
|
||||
super.onOpen()
|
||||
}
|
||||
}
|
||||
|
||||
describe('message transporter', () =>
|
||||
it('server client communication', () => {
|
||||
const docServer: Doc = new Doc()
|
||||
const docClient1: Doc = new Doc()
|
||||
const docClient2: Doc = new Doc()
|
||||
const dummyAwareness: Awareness = new Awareness(docServer)
|
||||
|
||||
const textServer = docServer.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
|
||||
const textClient1 = docClient1.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
|
||||
const textClient2 = docClient2.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
|
||||
textServer.insert(0, 'This is a test note')
|
||||
|
||||
textServer.observe(() =>
|
||||
console.debug('textServer', new Date(), textServer.toString())
|
||||
)
|
||||
textClient1.observe(() =>
|
||||
console.debug('textClient1', new Date(), textClient1.toString())
|
||||
)
|
||||
textClient2.observe(() =>
|
||||
console.debug('textClient2', new Date(), textClient2.toString())
|
||||
)
|
||||
|
||||
const transporterServerTo1 = new InMemoryMessageTransporter(
|
||||
's>1',
|
||||
docServer,
|
||||
dummyAwareness
|
||||
)
|
||||
const transporterServerTo2 = new InMemoryMessageTransporter(
|
||||
's>2',
|
||||
docServer,
|
||||
dummyAwareness
|
||||
)
|
||||
const transporterClient1 = new InMemoryMessageTransporter(
|
||||
'1>s',
|
||||
docClient1,
|
||||
dummyAwareness
|
||||
)
|
||||
const transporterClient2 = new InMemoryMessageTransporter(
|
||||
'2>s',
|
||||
docClient2,
|
||||
dummyAwareness
|
||||
)
|
||||
|
||||
transporterServerTo1.on(String(MessageType.DOCUMENT_UPDATE), () =>
|
||||
console.debug('Received DOCUMENT_UPDATE from client 1 to server')
|
||||
)
|
||||
transporterServerTo2.on(String(MessageType.DOCUMENT_UPDATE), () =>
|
||||
console.debug('Received DOCUMENT_UPDATE from client 2 to server')
|
||||
)
|
||||
transporterClient1.on(String(MessageType.DOCUMENT_UPDATE), () =>
|
||||
console.debug('Received DOCUMENT_UPDATE from server to client 1')
|
||||
)
|
||||
transporterClient2.on(String(MessageType.DOCUMENT_UPDATE), () =>
|
||||
console.debug('Received DOCUMENT_UPDATE from server to client 2')
|
||||
)
|
||||
|
||||
transporterServerTo1.on(
|
||||
String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
|
||||
() =>
|
||||
console.debug(
|
||||
'Received COMPLETE_DOCUMENT_STATE_ANSWER from client 1 to server'
|
||||
)
|
||||
)
|
||||
transporterServerTo2.on(
|
||||
String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
|
||||
() =>
|
||||
console.debug(
|
||||
'Received COMPLETE_DOCUMENT_STATE_ANSWER from client 2 to server'
|
||||
)
|
||||
)
|
||||
transporterClient1.on(
|
||||
String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
|
||||
() =>
|
||||
console.debug(
|
||||
'Received COMPLETE_DOCUMENT_STATE_ANSWER from server to client 1'
|
||||
)
|
||||
)
|
||||
transporterClient2.on(
|
||||
String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
|
||||
() =>
|
||||
console.debug(
|
||||
'Received COMPLETE_DOCUMENT_STATE_ANSWER from server to client 2'
|
||||
)
|
||||
)
|
||||
|
||||
transporterServerTo1.on(
|
||||
String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
|
||||
() =>
|
||||
console.debug(
|
||||
'Received COMPLETE_DOCUMENT_STATE_REQUEST from client 1 to server'
|
||||
)
|
||||
)
|
||||
transporterServerTo2.on(
|
||||
String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
|
||||
() =>
|
||||
console.debug(
|
||||
'Received COMPLETE_DOCUMENT_STATE_REQUEST from client 2 to server'
|
||||
)
|
||||
)
|
||||
transporterClient1.on(
|
||||
String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
|
||||
() =>
|
||||
console.debug(
|
||||
'Received COMPLETE_DOCUMENT_STATE_REQUEST from server to client 1'
|
||||
)
|
||||
)
|
||||
transporterClient2.on(
|
||||
String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
|
||||
() =>
|
||||
console.debug(
|
||||
'Received COMPLETE_DOCUMENT_STATE_REQUEST from server to client 2'
|
||||
)
|
||||
)
|
||||
transporterClient1.on('ready', () => console.debug('Client 1 is ready'))
|
||||
transporterClient2.on('ready', () => console.debug('Client 2 is ready'))
|
||||
|
||||
docServer.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
const message = encodeDocumentUpdateMessage(update)
|
||||
if (origin !== transporterServerTo1) {
|
||||
console.debug('YDoc on Server updated. Sending to Client 1')
|
||||
transporterServerTo1.send(message)
|
||||
}
|
||||
if (origin !== transporterServerTo2) {
|
||||
console.debug('YDoc on Server updated. Sending to Client 2')
|
||||
transporterServerTo2.send(message)
|
||||
}
|
||||
})
|
||||
docClient1.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin !== transporterClient1) {
|
||||
console.debug('YDoc on client 1 updated. Sending to Server')
|
||||
transporterClient1.send(encodeDocumentUpdateMessage(update))
|
||||
}
|
||||
})
|
||||
docClient2.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin !== transporterClient2) {
|
||||
console.debug('YDoc on client 2 updated. Sending to Server')
|
||||
transporterClient2.send(encodeDocumentUpdateMessage(update))
|
||||
}
|
||||
})
|
||||
|
||||
transporterClient1.connect(transporterServerTo1)
|
||||
transporterClient2.connect(transporterServerTo2)
|
||||
|
||||
textClient1.insert(0, 'test2')
|
||||
textClient1.insert(0, 'test3')
|
||||
textClient2.insert(0, 'test4')
|
||||
|
||||
expect(textServer.toString()).toBe('test4test3test2This is a test note')
|
||||
expect(textClient1.toString()).toBe('test4test3test2This is a test note')
|
||||
expect(textClient2.toString()).toBe('test4test3test2This is a test note')
|
||||
|
||||
dummyAwareness.destroy()
|
||||
docServer.destroy()
|
||||
docClient1.destroy()
|
||||
docClient2.destroy()
|
||||
}))
|
|
@ -1,113 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
applyAwarenessUpdateMessage,
|
||||
encodeAwarenessUpdateMessage
|
||||
} from './messages/awareness-update-message.js'
|
||||
import { encodeCompleteDocumentStateAnswerMessage } from './messages/complete-document-state-answer-message.js'
|
||||
import { encodeCompleteDocumentStateRequestMessage } from './messages/complete-document-state-request-message.js'
|
||||
import { applyDocumentUpdateMessage } from './messages/document-update-message.js'
|
||||
import { MessageType } from './messages/message-type.enum.js'
|
||||
import { encodeReadyAnswerMessage } from './messages/ready-answer-message.js'
|
||||
import { encodeReadyRequestMessage } from './messages/ready-request-message.js'
|
||||
import { EventEmitter2 } from 'eventemitter2'
|
||||
import { Decoder, readVarUint } from 'lib0/decoding'
|
||||
import { Awareness } from 'y-protocols/awareness'
|
||||
import { Doc } from 'yjs'
|
||||
|
||||
export type Handler = (decoder: Decoder) => void
|
||||
|
||||
export type MessageTransporterEvents = {
|
||||
disconnected: () => void
|
||||
connected: () => void
|
||||
ready: () => void
|
||||
synced: () => void
|
||||
} & Partial<Record<MessageType, Handler>>
|
||||
|
||||
export abstract class YDocMessageTransporter extends EventEmitter2 {
|
||||
private synced = false
|
||||
|
||||
protected constructor(
|
||||
protected readonly doc: Doc,
|
||||
protected readonly awareness: Awareness
|
||||
) {
|
||||
super()
|
||||
this.on(String(MessageType.READY_REQUEST), () => {
|
||||
this.send(encodeReadyAnswerMessage())
|
||||
})
|
||||
this.on(String(MessageType.READY_ANSWER), () => {
|
||||
this.emit('ready')
|
||||
})
|
||||
this.bindDocumentSyncMessageEvents(doc)
|
||||
}
|
||||
|
||||
public isSynced(): boolean {
|
||||
return this.synced
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.emit('connected')
|
||||
this.send(encodeReadyRequestMessage())
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.emit('disconnected')
|
||||
}
|
||||
|
||||
protected markAsSynced(): void {
|
||||
if (!this.synced) {
|
||||
this.synced = true
|
||||
this.emit('synced')
|
||||
}
|
||||
}
|
||||
|
||||
protected decodeMessage(buffer: ArrayBuffer): void {
|
||||
const data = new Uint8Array(buffer)
|
||||
const decoder = new Decoder(data)
|
||||
const messageType = readVarUint(decoder) as MessageType
|
||||
|
||||
switch (messageType) {
|
||||
case MessageType.COMPLETE_DOCUMENT_STATE_REQUEST:
|
||||
this.send(encodeCompleteDocumentStateAnswerMessage(this.doc, decoder))
|
||||
break
|
||||
case MessageType.DOCUMENT_UPDATE:
|
||||
applyDocumentUpdateMessage(decoder, this.doc, this)
|
||||
break
|
||||
case MessageType.COMPLETE_DOCUMENT_STATE_ANSWER:
|
||||
applyDocumentUpdateMessage(decoder, this.doc, this)
|
||||
this.markAsSynced()
|
||||
break
|
||||
case MessageType.COMPLETE_AWARENESS_STATE_REQUEST:
|
||||
this.send(
|
||||
encodeAwarenessUpdateMessage(
|
||||
this.awareness,
|
||||
Array.from(this.awareness.getStates().keys())
|
||||
)
|
||||
)
|
||||
break
|
||||
case MessageType.AWARENESS_UPDATE:
|
||||
applyAwarenessUpdateMessage(decoder, this.awareness, this)
|
||||
}
|
||||
|
||||
this.emit(String(messageType), decoder)
|
||||
}
|
||||
|
||||
private bindDocumentSyncMessageEvents(doc: Doc) {
|
||||
this.on('ready', () => {
|
||||
this.send(encodeCompleteDocumentStateRequestMessage(doc))
|
||||
})
|
||||
this.on('disconnected', () => (this.synced = false))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends binary data to the client. Closes the connection on errors.
|
||||
*
|
||||
* @param content The binary data to send.
|
||||
*/
|
||||
public abstract send(content: Uint8Array): void
|
||||
|
||||
public abstract disconnect(): void
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
ConnectionState,
|
||||
MessageTransporter
|
||||
} from '../message-transporters/message-transporter.js'
|
||||
import { Message, MessageType } from '../message-transporters/message.js'
|
||||
|
||||
/**
|
||||
* Message transporter for testing purposes that redirects message to another in memory connection message transporter instance.
|
||||
*/
|
||||
export class InMemoryConnectionMessageTransporter extends MessageTransporter {
|
||||
private otherSide: InMemoryConnectionMessageTransporter | undefined
|
||||
|
||||
constructor(private name: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
public connect(other: InMemoryConnectionMessageTransporter): void {
|
||||
this.otherSide = other
|
||||
other.otherSide = this
|
||||
this.onConnected()
|
||||
other.onConnected()
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
this.onDisconnecting()
|
||||
|
||||
if (this.otherSide) {
|
||||
this.otherSide.onDisconnecting()
|
||||
this.otherSide.otherSide = undefined
|
||||
this.otherSide = undefined
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(content: Message<MessageType>): void {
|
||||
if (this.otherSide === undefined) {
|
||||
throw new Error('Disconnected')
|
||||
}
|
||||
console.debug(`${this.name}`, 'Sending', content)
|
||||
this.otherSide?.receiveMessage(content)
|
||||
}
|
||||
|
||||
getConnectionState(): ConnectionState {
|
||||
return this.otherSide !== undefined
|
||||
? ConnectionState.CONNECTED
|
||||
: ConnectionState.DISCONNECT
|
||||
}
|
||||
}
|
16
commons/src/y-doc-sync/realtime-doc.test.ts
Normal file
16
commons/src/y-doc-sync/realtime-doc.test.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { RealtimeDoc } from './realtime-doc.js'
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
|
||||
describe('websocket-doc', () => {
|
||||
it('saves the initial content', () => {
|
||||
const textContent = 'textContent'
|
||||
const websocketDoc = new RealtimeDoc(textContent)
|
||||
|
||||
expect(websocketDoc.getCurrentContent()).toBe(textContent)
|
||||
})
|
||||
})
|
48
commons/src/y-doc-sync/realtime-doc.ts
Normal file
48
commons/src/y-doc-sync/realtime-doc.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Doc } from 'yjs'
|
||||
import { Text as YText } from 'yjs'
|
||||
|
||||
const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent'
|
||||
|
||||
/**
|
||||
* This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving.
|
||||
*/
|
||||
export class RealtimeDoc extends Doc {
|
||||
/**
|
||||
* Creates a new instance.
|
||||
*
|
||||
* The new instance is filled with the given initial content.
|
||||
*
|
||||
* @param initialContent - the initial content of the {@link Doc YDoc}
|
||||
*/
|
||||
constructor(initialContent?: string) {
|
||||
super()
|
||||
if (initialContent) {
|
||||
this.getMarkdownContentChannel().insert(0, initialContent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the {@link YText text channel} that contains the markdown code.
|
||||
*
|
||||
* @return The markdown channel
|
||||
*/
|
||||
public getMarkdownContentChannel(): YText {
|
||||
return this.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current content of the note as it's currently edited in realtime.
|
||||
*
|
||||
* Please be aware that the return of this method may be very quickly outdated.
|
||||
*
|
||||
* @return The current note content.
|
||||
*/
|
||||
public getCurrentContent(): string {
|
||||
return this.getMarkdownContentChannel().toString()
|
||||
}
|
||||
}
|
162
commons/src/y-doc-sync/y-doc-sync-adapter.test.ts
Normal file
162
commons/src/y-doc-sync/y-doc-sync-adapter.test.ts
Normal file
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* 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 { InMemoryConnectionMessageTransporter } from './in-memory-connection-message.transporter.js'
|
||||
import { RealtimeDoc } from './realtime-doc.js'
|
||||
import { YDocSyncClientAdapter } from './y-doc-sync-client-adapter.js'
|
||||
import { YDocSyncServerAdapter } from './y-doc-sync-server-adapter.js'
|
||||
import { describe, expect, it } from '@jest/globals'
|
||||
|
||||
describe('message transporter', () => {
|
||||
it('server client communication', async () => {
|
||||
const docServer: RealtimeDoc = new RealtimeDoc('This is a test note')
|
||||
const docClient1: RealtimeDoc = new RealtimeDoc()
|
||||
const docClient2: RealtimeDoc = new RealtimeDoc()
|
||||
|
||||
const textServer = docServer.getMarkdownContentChannel()
|
||||
const textClient1 = docClient1.getMarkdownContentChannel()
|
||||
const textClient2 = docClient2.getMarkdownContentChannel()
|
||||
|
||||
textServer.observe(() =>
|
||||
console.debug('textServer', new Date(), textServer.toString())
|
||||
)
|
||||
textClient1.observe(() =>
|
||||
console.debug('textClient1', new Date(), textClient1.toString())
|
||||
)
|
||||
textClient2.observe(() =>
|
||||
console.debug('textClient2', new Date(), textClient2.toString())
|
||||
)
|
||||
|
||||
const transporterServerTo1 = new InMemoryConnectionMessageTransporter('s>1')
|
||||
const transporterServerTo2 = new InMemoryConnectionMessageTransporter('s>2')
|
||||
const transporterClient1 = new InMemoryConnectionMessageTransporter('1>s')
|
||||
const transporterClient2 = new InMemoryConnectionMessageTransporter('2>s')
|
||||
|
||||
transporterServerTo1.on(MessageType.NOTE_CONTENT_UPDATE, () =>
|
||||
console.debug('Received NOTE_CONTENT_UPDATE from client 1 to server')
|
||||
)
|
||||
transporterServerTo2.on(MessageType.NOTE_CONTENT_UPDATE, () =>
|
||||
console.debug('Received NOTE_CONTENT_UPDATE from client 2 to server')
|
||||
)
|
||||
transporterClient1.on(MessageType.NOTE_CONTENT_UPDATE, () =>
|
||||
console.debug('Received NOTE_CONTENT_UPDATE from server to client 1')
|
||||
)
|
||||
transporterClient2.on(MessageType.NOTE_CONTENT_UPDATE, () =>
|
||||
console.debug('Received NOTE_CONTENT_UPDATE from server to client 2')
|
||||
)
|
||||
transporterServerTo1.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
|
||||
console.debug('Received NOTE_CONTENT_REQUEST from client 1 to server')
|
||||
)
|
||||
transporterServerTo2.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
|
||||
console.debug('Received NOTE_CONTENT_REQUEST from client 2 to server')
|
||||
)
|
||||
transporterClient1.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
|
||||
console.debug('Received NOTE_CONTENT_REQUEST from server to client 1')
|
||||
)
|
||||
transporterClient2.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
|
||||
console.debug('Received NOTE_CONTENT_REQUEST from server to client 2')
|
||||
)
|
||||
transporterClient1.on('connected', () => console.debug('1>s is connected'))
|
||||
transporterClient2.on('connected', () => console.debug('2>s is connected'))
|
||||
transporterServerTo1.on('connected', () =>
|
||||
console.debug('s>1 is connected')
|
||||
)
|
||||
transporterServerTo2.on('connected', () =>
|
||||
console.debug('s>2 is connected')
|
||||
)
|
||||
|
||||
docServer.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
const message: Message<MessageType.NOTE_CONTENT_UPDATE> = {
|
||||
type: MessageType.NOTE_CONTENT_UPDATE,
|
||||
payload: Array.from(update)
|
||||
}
|
||||
if (origin !== transporterServerTo1) {
|
||||
console.debug('YDoc on Server updated. Sending to Client 1')
|
||||
transporterServerTo1.sendMessage(message)
|
||||
}
|
||||
if (origin !== transporterServerTo2) {
|
||||
console.debug('YDoc on Server updated. Sending to Client 2')
|
||||
transporterServerTo2.sendMessage(message)
|
||||
}
|
||||
})
|
||||
docClient1.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin !== transporterClient1) {
|
||||
console.debug('YDoc on client 1 updated. Sending to Server')
|
||||
}
|
||||
})
|
||||
docClient2.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin !== transporterClient2) {
|
||||
console.debug('YDoc on client 2 updated. Sending to Server')
|
||||
}
|
||||
})
|
||||
|
||||
const yDocSyncAdapter1 = new YDocSyncClientAdapter(transporterClient1)
|
||||
yDocSyncAdapter1.setYDoc(docClient1)
|
||||
|
||||
const yDocSyncAdapter2 = new YDocSyncClientAdapter(transporterClient2)
|
||||
yDocSyncAdapter2.setYDoc(docClient2)
|
||||
|
||||
const yDocSyncAdapterServerTo1 = new YDocSyncServerAdapter(
|
||||
transporterServerTo1
|
||||
)
|
||||
yDocSyncAdapterServerTo1.setYDoc(docServer)
|
||||
|
||||
const yDocSyncAdapterServerTo2 = new YDocSyncServerAdapter(
|
||||
transporterServerTo2
|
||||
)
|
||||
yDocSyncAdapterServerTo2.setYDoc(docServer)
|
||||
|
||||
const waitForClient1Sync = new Promise<void>((resolve) => {
|
||||
yDocSyncAdapter1.doAsSoonAsSynced(() => {
|
||||
console.debug('client 1 received the first sync')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const waitForClient2Sync = new Promise<void>((resolve) => {
|
||||
yDocSyncAdapter2.doAsSoonAsSynced(() => {
|
||||
console.debug('client 2 received the first sync')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const waitForServerTo11Sync = new Promise<void>((resolve) => {
|
||||
yDocSyncAdapterServerTo1.doAsSoonAsSynced(() => {
|
||||
console.debug('server 1 received the first sync')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const waitForServerTo21Sync = new Promise<void>((resolve) => {
|
||||
yDocSyncAdapterServerTo2.doAsSoonAsSynced(() => {
|
||||
console.debug('server 2 received the first sync')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
transporterClient1.connect(transporterServerTo1)
|
||||
transporterClient2.connect(transporterServerTo2)
|
||||
|
||||
yDocSyncAdapter1.requestDocumentState()
|
||||
yDocSyncAdapter2.requestDocumentState()
|
||||
|
||||
await Promise.all([
|
||||
waitForClient1Sync,
|
||||
waitForClient2Sync,
|
||||
waitForServerTo11Sync,
|
||||
waitForServerTo21Sync
|
||||
])
|
||||
|
||||
textClient1.insert(0, 'test2')
|
||||
textClient1.insert(0, 'test3')
|
||||
textClient2.insert(0, 'test4')
|
||||
|
||||
expect(textServer.toString()).toBe('test4test3test2This is a test note')
|
||||
expect(textClient1.toString()).toBe('test4test3test2This is a test note')
|
||||
expect(textClient2.toString()).toBe('test4test3test2This is a test note')
|
||||
|
||||
docServer.destroy()
|
||||
docClient1.destroy()
|
||||
docClient2.destroy()
|
||||
})
|
||||
})
|
148
commons/src/y-doc-sync/y-doc-sync-adapter.ts
Normal file
148
commons/src/y-doc-sync/y-doc-sync-adapter.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageTransporter } from '../message-transporters/message-transporter.js'
|
||||
import { Message, MessageType } from '../message-transporters/message.js'
|
||||
import { Listener } from 'eventemitter2'
|
||||
import { EventEmitter2 } from 'eventemitter2'
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'
|
||||
|
||||
type EventMap = Record<'synced' | 'desynced', () => void>
|
||||
|
||||
/**
|
||||
* Sends and processes messages that are used to first-synchronize and update a {@link Doc y-doc}.
|
||||
*/
|
||||
export abstract class YDocSyncAdapter {
|
||||
public readonly eventEmitter = new EventEmitter2<EventMap>()
|
||||
|
||||
protected doc: Doc | undefined
|
||||
|
||||
private destroyYDocUpdateCallback: undefined | (() => void)
|
||||
private destroyEventListenerCallback: undefined | (() => void)
|
||||
private synced = false
|
||||
|
||||
constructor(protected readonly messageTransporter: MessageTransporter) {
|
||||
this.bindDocumentSyncMessageEvents()
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given callback as soon as the sync adapter has synchronized the y-doc.
|
||||
* If the y-doc has already been synchronized then the callback is executed immediately.
|
||||
*
|
||||
* @param callback the callback to execute
|
||||
* @return The event listener that waits for the sync event
|
||||
*/
|
||||
public doAsSoonAsSynced(callback: () => void): Listener {
|
||||
if (this.isSynced()) {
|
||||
callback()
|
||||
}
|
||||
return this.eventEmitter.on('synced', callback, {
|
||||
objectify: true
|
||||
}) as Listener
|
||||
}
|
||||
|
||||
public getMessageTransporter(): MessageTransporter {
|
||||
return this.messageTransporter
|
||||
}
|
||||
|
||||
public isSynced(): boolean {
|
||||
return this.synced
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link Doc y-doc} that should be synchronized.
|
||||
*
|
||||
* @param doc the doc to synchronize.
|
||||
*/
|
||||
public setYDoc(doc: Doc | undefined): void {
|
||||
this.doc = doc
|
||||
|
||||
this.destroyYDocUpdateCallback?.()
|
||||
if (!doc) {
|
||||
return
|
||||
}
|
||||
const yDocUpdateCallback = this.processDocUpdate.bind(this)
|
||||
doc.on('update', yDocUpdateCallback)
|
||||
this.destroyYDocUpdateCallback = () => doc.off('update', yDocUpdateCallback)
|
||||
this.eventEmitter.emit('desynced')
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.destroyYDocUpdateCallback?.()
|
||||
this.destroyEventListenerCallback?.()
|
||||
}
|
||||
|
||||
protected bindDocumentSyncMessageEvents(): void {
|
||||
const stateRequestListener = this.messageTransporter.on(
|
||||
MessageType.NOTE_CONTENT_STATE_REQUEST,
|
||||
(payload) => {
|
||||
if (this.doc) {
|
||||
this.messageTransporter.sendMessage({
|
||||
type: MessageType.NOTE_CONTENT_UPDATE,
|
||||
payload: Array.from(
|
||||
encodeStateAsUpdate(this.doc, new Uint8Array(payload.payload))
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ objectify: true }
|
||||
) as Listener
|
||||
|
||||
const disconnectedListener = this.messageTransporter.on(
|
||||
'disconnected',
|
||||
() => {
|
||||
this.synced = false
|
||||
this.eventEmitter.emit('desynced')
|
||||
this.destroy()
|
||||
},
|
||||
{ objectify: true }
|
||||
) as Listener
|
||||
|
||||
const noteContentUpdateListener = this.messageTransporter.on(
|
||||
MessageType.NOTE_CONTENT_UPDATE,
|
||||
(payload) => {
|
||||
if (this.doc) {
|
||||
applyUpdate(this.doc, new Uint8Array(payload.payload), this)
|
||||
}
|
||||
},
|
||||
{ objectify: true }
|
||||
) as Listener
|
||||
|
||||
this.destroyEventListenerCallback = () => {
|
||||
stateRequestListener.off()
|
||||
disconnectedListener.off()
|
||||
noteContentUpdateListener.off()
|
||||
}
|
||||
}
|
||||
|
||||
private processDocUpdate(update: Uint8Array, origin: unknown): void {
|
||||
if (!this.isSynced() || origin === this) {
|
||||
return
|
||||
}
|
||||
const message: Message<MessageType.NOTE_CONTENT_UPDATE> = {
|
||||
type: MessageType.NOTE_CONTENT_UPDATE,
|
||||
payload: Array.from(update)
|
||||
}
|
||||
|
||||
this.messageTransporter.sendMessage(message)
|
||||
}
|
||||
|
||||
protected markAsSynced(): void {
|
||||
if (this.synced) {
|
||||
return
|
||||
}
|
||||
this.synced = true
|
||||
this.eventEmitter.emit('synced')
|
||||
}
|
||||
|
||||
public requestDocumentState(): void {
|
||||
if (this.doc) {
|
||||
this.messageTransporter.sendMessage({
|
||||
type: MessageType.NOTE_CONTENT_STATE_REQUEST,
|
||||
payload: Array.from(encodeStateVector(this.doc))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
17
commons/src/y-doc-sync/y-doc-sync-client-adapter.ts
Normal file
17
commons/src/y-doc-sync/y-doc-sync-client-adapter.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageType } from '../message-transporters/message.js'
|
||||
import { YDocSyncAdapter } from './y-doc-sync-adapter.js'
|
||||
|
||||
export class YDocSyncClientAdapter extends YDocSyncAdapter {
|
||||
protected bindDocumentSyncMessageEvents() {
|
||||
super.bindDocumentSyncMessageEvents()
|
||||
|
||||
this.messageTransporter.on(MessageType.NOTE_CONTENT_UPDATE, () => {
|
||||
this.markAsSynced()
|
||||
})
|
||||
}
|
||||
}
|
14
commons/src/y-doc-sync/y-doc-sync-server-adapter.ts
Normal file
14
commons/src/y-doc-sync/y-doc-sync-server-adapter.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageTransporter } from '../message-transporters/message-transporter.js'
|
||||
import { YDocSyncAdapter } from './y-doc-sync-adapter.js'
|
||||
|
||||
export class YDocSyncServerAdapter extends YDocSyncAdapter {
|
||||
constructor(readonly messageTransporter: MessageTransporter) {
|
||||
super(messageTransporter)
|
||||
this.markAsSynced()
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue