diff --git a/backend/src/realtime/realtime-note/realtime-connection.spec.ts b/backend/src/realtime/realtime-note/realtime-connection.spec.ts index 3738a9d13..b72e759bb 100644 --- a/backend/src/realtime/realtime-note/realtime-connection.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-connection.spec.ts @@ -5,7 +5,7 @@ */ import { MessageTransporter, - MockedBackendMessageTransporter, + MockedBackendTransportAdapter, YDocSyncServerAdapter, } from '@hedgedoc/commons'; import * as HedgeDocCommonsModule from '@hedgedoc/commons'; @@ -49,7 +49,8 @@ describe('websocket connection', () => { displayName: mockedDisplayName, }); - mockedMessageTransporter = new MockedBackendMessageTransporter(''); + mockedMessageTransporter = new MessageTransporter(); + mockedMessageTransporter.setAdapter(new MockedBackendTransportAdapter('')); }); afterEach(() => { diff --git a/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts b/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts index 1c50164c7..b2dd601a1 100644 --- a/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts +++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.spec.ts @@ -5,16 +5,15 @@ */ import { Message, + MessageTransporter, MessageType, - MockedBackendMessageTransporter, + MockedBackendTransportAdapter, + waitForOtherPromisesToFinish, } from '@hedgedoc/commons'; import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter'; -type SendMessageSpy = jest.SpyInstance< - void, - [Required] ->; +type SendMessageSpy = jest.SpyInstance]>; describe('realtime user status adapter', () => { let clientLoggedIn1: RealtimeUserStatusAdapter | undefined; @@ -36,24 +35,42 @@ describe('realtime user status adapter', () => { const guestDisplayName = 'Virtuous Mockingbird'; - let messageTransporterLoggedIn1: MockedBackendMessageTransporter; - let messageTransporterLoggedIn2: MockedBackendMessageTransporter; - let messageTransporterGuest: MockedBackendMessageTransporter; - let messageTransporterNotReady: MockedBackendMessageTransporter; - let messageTransporterDecline: MockedBackendMessageTransporter; + let messageTransporterLoggedIn1: MessageTransporter; + let messageTransporterLoggedIn2: MessageTransporter; + let messageTransporterGuest: MessageTransporter; + let messageTransporterNotReady: MessageTransporter; + let messageTransporterDecline: MessageTransporter; - beforeEach(() => { + beforeEach(async () => { clientLoggedIn1 = undefined; clientLoggedIn2 = undefined; clientGuest = undefined; clientNotReady = undefined; clientDecline = undefined; - messageTransporterLoggedIn1 = new MockedBackendMessageTransporter(''); - messageTransporterLoggedIn2 = new MockedBackendMessageTransporter(''); - messageTransporterGuest = new MockedBackendMessageTransporter(''); - messageTransporterNotReady = new MockedBackendMessageTransporter(''); - messageTransporterDecline = new MockedBackendMessageTransporter(''); + messageTransporterLoggedIn1 = new MessageTransporter(); + messageTransporterLoggedIn2 = new MessageTransporter(); + messageTransporterGuest = new MessageTransporter(); + messageTransporterNotReady = new MessageTransporter(); + messageTransporterDecline = new MessageTransporter(); + + const mockedTransportAdapterLoggedIn1 = new MockedBackendTransportAdapter( + '', + ); + const mockedTransportAdapterLoggedIn2 = new MockedBackendTransportAdapter( + '', + ); + const mockedTransportAdapterGuest = new MockedBackendTransportAdapter(''); + const mockedTransportAdapterNotReady = new MockedBackendTransportAdapter( + '', + ); + const mockedTransportAdapterDecline = new MockedBackendTransportAdapter(''); + + messageTransporterLoggedIn1.setAdapter(mockedTransportAdapterLoggedIn1); + messageTransporterLoggedIn2.setAdapter(mockedTransportAdapterLoggedIn2); + messageTransporterGuest.setAdapter(mockedTransportAdapterGuest); + messageTransporterNotReady.setAdapter(mockedTransportAdapterNotReady); + messageTransporterDecline.setAdapter(mockedTransportAdapterDecline); function otherAdapterCollector(): RealtimeUserStatusAdapter[] { return [ @@ -126,14 +143,15 @@ describe('realtime user status adapter', () => { messageTransporterLoggedIn2.sendReady(); messageTransporterGuest.sendReady(); messageTransporterDecline.sendReady(); + await waitForOtherPromisesToFinish(); }); it('can answer a state request', () => { - expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(1); + expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(1); + expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(1); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1); messageTransporterLoggedIn1.emit(MessageType.REALTIME_USER_STATE_REQUEST); @@ -176,21 +194,21 @@ describe('realtime user status adapter', () => { }, }; expect(clientLoggedIn1SendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedMessage1, ); - expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(1); + expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(1); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1); }); it('can save an cursor update', () => { - expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(1); + expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(1); + expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(1); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1); const newFrom = Math.floor(Math.random() * 100); const newTo = Math.floor(Math.random() * 100); @@ -323,28 +341,28 @@ describe('realtime user status adapter', () => { }, }; - expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(1); expect(clientLoggedIn2SendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedMessage2, ); expect(clientGuestSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientDeclineSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedMessage5, ); }); it('will inform other clients about removed client', () => { - expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(1); + expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(1); + expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(1); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1); messageTransporterLoggedIn2.disconnect(); @@ -439,27 +457,27 @@ describe('realtime user status adapter', () => { }; expect(clientLoggedIn1SendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedMessage1, ); - expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(1); expect(clientGuestSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientDeclineSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedMessage5, ); }); it('will inform other clients about inactivity and reactivity', () => { - expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(1); + expect(clientLoggedIn2SendMessageSpy).toHaveBeenCalledTimes(1); + expect(clientGuestSendMessageSpy).toHaveBeenCalledTimes(1); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); - expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientDeclineSendMessageSpy).toHaveBeenCalledTimes(1); messageTransporterLoggedIn1.emit(MessageType.REALTIME_USER_SET_ACTIVITY, { type: MessageType.REALTIME_USER_SET_ACTIVITY, @@ -591,18 +609,18 @@ describe('realtime user status adapter', () => { }, }; - expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(1); expect(clientLoggedIn2SendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedInactivityMessage2, ); expect(clientGuestSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedInactivityMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientDeclineSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedInactivityMessage5, ); @@ -613,18 +631,18 @@ describe('realtime user status adapter', () => { }, }); - expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(1); expect(clientLoggedIn2SendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedInactivityMessage2, ); expect(clientGuestSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedInactivityMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientDeclineSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedInactivityMessage5, ); @@ -765,18 +783,18 @@ describe('realtime user status adapter', () => { }, }; - expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(1); expect(clientLoggedIn2SendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedReactivityMessage2, ); expect(clientGuestSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedReactivityMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientDeclineSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedReactivityMessage5, ); @@ -787,18 +805,18 @@ describe('realtime user status adapter', () => { }, }); - expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(0); + expect(clientLoggedIn1SendMessageSpy).toHaveBeenCalledTimes(1); expect(clientLoggedIn2SendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedReactivityMessage2, ); expect(clientGuestSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedReactivityMessage3, ); expect(clientNotReadySendMessageSpy).toHaveBeenCalledTimes(0); expect(clientDeclineSendMessageSpy).toHaveBeenNthCalledWith( - 1, + 2, expectedReactivityMessage5, ); }); diff --git a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts index 3c9c4d97f..9ffb1660c 100644 --- a/backend/src/realtime/realtime-note/test-utils/mock-connection.ts +++ b/backend/src/realtime/realtime-note/test-utils/mock-connection.ts @@ -4,7 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { - MockedBackendMessageTransporter, + MessageTransporter, + MockedBackendTransportAdapter, YDocSyncServerAdapter, } from '@hedgedoc/commons'; import { Mock } from 'ts-mockery'; @@ -81,7 +82,8 @@ export class MockConnectionBuilder { public build(): RealtimeConnection { const displayName = this.deriveDisplayName(); - const transporter = new MockedBackendMessageTransporter(''); + const transporter = new MessageTransporter(); + transporter.setAdapter(new MockedBackendTransportAdapter('')); const realtimeUserStateAdapter: RealtimeUserStatusAdapter = this.includeRealtimeUserStatus === RealtimeUserState.WITHOUT ? Mock.of({}) diff --git a/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts b/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts new file mode 100644 index 000000000..7cb882e85 --- /dev/null +++ b/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ConnectionState, Message, MessageType } from '@hedgedoc/commons'; +import { Mock } from 'ts-mockery'; +import WebSocket, { MessageEvent } from 'ws'; + +import { BackendWebsocketAdapter } from './backend-websocket-adapter'; + +describe('backend websocket adapter', () => { + let sut: BackendWebsocketAdapter; + let mockedSocket: WebSocket; + + function mockSocket(readyState: 0 | 1 | 2 | 3 = 0) { + mockedSocket = Mock.of({ + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + close: jest.fn(), + send: jest.fn(), + readyState: readyState, + }); + sut = new BackendWebsocketAdapter(mockedSocket); + } + + beforeEach(() => { + mockSocket(0); + }); + + it('can bind and unbind the close event', () => { + const handler = jest.fn(); + const unbind = sut.bindOnCloseEvent(handler); + expect(mockedSocket.addEventListener).toHaveBeenCalledWith( + 'close', + handler, + ); + unbind(); + expect(mockedSocket.removeEventListener).toHaveBeenCalledWith( + 'close', + handler, + ); + }); + + it('can bind and unbind the connect event', () => { + const handler = jest.fn(); + const unbind = sut.bindOnConnectedEvent(handler); + expect(mockedSocket.addEventListener).toHaveBeenCalledWith('open', handler); + unbind(); + expect(mockedSocket.removeEventListener).toHaveBeenCalledWith( + 'open', + handler, + ); + }); + + it('can bind and unbind the error event', () => { + const handler = jest.fn(); + const unbind = sut.bindOnErrorEvent(handler); + expect(mockedSocket.addEventListener).toHaveBeenCalledWith( + 'error', + handler, + ); + unbind(); + expect(mockedSocket.removeEventListener).toHaveBeenCalledWith( + 'error', + handler, + ); + }); + + it('can bind, unbind and translate the message event', () => { + const handler = jest.fn(); + + let modifiedHandler: (event: MessageEvent) => void = jest.fn(); + jest + .spyOn(mockedSocket, 'addEventListener') + .mockImplementation((event, handler_) => { + modifiedHandler = handler_; + }); + + const unbind = sut.bindOnMessageEvent(handler); + + modifiedHandler(Mock.of({ data: new ArrayBuffer(0) })); + expect(handler).toHaveBeenCalledTimes(0); + + modifiedHandler(Mock.of({ data: '{ "type": "READY" }' })); + expect(handler).toHaveBeenCalledWith({ type: 'READY' }); + + expect(mockedSocket.addEventListener).toHaveBeenCalledWith( + 'message', + modifiedHandler, + ); + unbind(); + expect(mockedSocket.removeEventListener).toHaveBeenCalledWith( + 'message', + modifiedHandler, + ); + }); + + it('can disconnect the socket', () => { + sut.disconnect(); + expect(mockedSocket.close).toHaveBeenCalled(); + }); + + it('can send messages', () => { + const value: Message = { type: MessageType.READY }; + sut.send(value); + expect(mockedSocket.send).toHaveBeenCalledWith('{"type":"READY"}'); + }); + + it('can read the connection state when open', () => { + mockSocket(WebSocket.OPEN); + expect(sut.getConnectionState()).toBe(ConnectionState.CONNECTED); + }); + + it('can read the connection state when connecting', () => { + mockSocket(WebSocket.CONNECTING); + expect(sut.getConnectionState()).toBe(ConnectionState.CONNECTING); + }); + + it('can read the connection state when closing', () => { + mockSocket(WebSocket.CLOSING); + expect(sut.getConnectionState()).toBe(ConnectionState.DISCONNECTED); + }); + + it('can read the connection state when closed', () => { + mockSocket(WebSocket.CLOSED); + expect(sut.getConnectionState()).toBe(ConnectionState.DISCONNECTED); + }); +}); diff --git a/backend/src/realtime/websocket/backend-websocket-adapter.ts b/backend/src/realtime/websocket/backend-websocket-adapter.ts new file mode 100644 index 000000000..c59a8893a --- /dev/null +++ b/backend/src/realtime/websocket/backend-websocket-adapter.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ConnectionState, Message, MessageType } from '@hedgedoc/commons'; +import type { TransportAdapter } from '@hedgedoc/commons'; +import WebSocket, { MessageEvent } from 'ws'; + +/** + * Implements a transport adapter that communicates using a nodejs socket. + */ +export class BackendWebsocketAdapter implements TransportAdapter { + constructor(private socket: WebSocket) {} + + bindOnCloseEvent(handler: () => void): () => void { + this.socket.addEventListener('close', handler); + return () => { + this.socket.removeEventListener('close', handler); + }; + } + + bindOnConnectedEvent(handler: () => void): () => void { + this.socket.addEventListener('open', handler); + return () => { + this.socket.removeEventListener('open', handler); + }; + } + + bindOnErrorEvent(handler: () => void): () => void { + this.socket.addEventListener('error', handler); + return () => { + this.socket.removeEventListener('error', handler); + }; + } + + bindOnMessageEvent( + handler: (value: Message) => void, + ): () => void { + function adjustedHandler(message: MessageEvent): void { + if (typeof message.data !== 'string') { + return; + } + + handler(JSON.parse(message.data) as Message); + } + + this.socket.addEventListener('message', adjustedHandler); + return () => { + this.socket.removeEventListener('message', adjustedHandler); + }; + } + + disconnect(): void { + this.socket.close(); + } + + getConnectionState(): ConnectionState { + if (this.socket.readyState === WebSocket.OPEN) { + return ConnectionState.CONNECTED; + } else if (this.socket.readyState === WebSocket.CONNECTING) { + return ConnectionState.CONNECTING; + } else { + return ConnectionState.DISCONNECTED; + } + } + + send(value: Message): void { + this.socket.send(JSON.stringify(value)); + } +} diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts index 7a55e5279..8fc5491c0 100644 --- a/backend/src/realtime/websocket/websocket.gateway.ts +++ b/backend/src/realtime/websocket/websocket.gateway.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { + MessageTransporter, NotePermissions, userCanEdit, - WebsocketTransporter, } from '@hedgedoc/commons'; import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets'; import { IncomingMessage } from 'http'; @@ -21,6 +21,7 @@ import { User } from '../../users/user.entity'; import { UsersService } from '../../users/users.service'; import { RealtimeConnection } from '../realtime-note/realtime-connection'; import { RealtimeNoteService } from '../realtime-note/realtime-note.service'; +import { BackendWebsocketAdapter } from './backend-websocket-adapter'; import { extractNoteIdFromRequestUrl } from './utils/extract-note-id-from-request-url'; /** @@ -85,7 +86,7 @@ export class WebsocketGateway implements OnGatewayConnection { const realtimeNote = await this.realtimeNoteService.getOrCreateRealtimeNote(note); - const websocketTransporter = new WebsocketTransporter(); + const websocketTransporter = new MessageTransporter(); const permissions = await this.noteService.toNotePermissionsDto(note); const acceptEdits: boolean = userCanEdit( permissions as NotePermissions, @@ -97,7 +98,9 @@ export class WebsocketGateway implements OnGatewayConnection { realtimeNote, acceptEdits, ); - websocketTransporter.setWebsocket(clientSocket); + websocketTransporter.setAdapter( + new BackendWebsocketAdapter(clientSocket), + ); realtimeNote.addClient(connection); diff --git a/commons/package.json b/commons/package.json index 42734ebeb..c5da0f29d 100644 --- a/commons/package.json +++ b/commons/package.json @@ -40,7 +40,6 @@ "dependencies": { "domhandler": "5.0.3", "eventemitter2": "6.4.9", - "isomorphic-ws": "5.0.0", "joi": "17.9.2", "reveal.js": "4.5.0", "ws": "8.13.0", diff --git a/commons/src/index.ts b/commons/src/index.ts index d6a3281c6..49cbb055a 100644 --- a/commons/src/index.ts +++ b/commons/src/index.ts @@ -12,3 +12,4 @@ export * from './parse-url/index.js' export * from './permissions/index.js' export * from './title-extraction/index.js' export * from './y-doc-sync/index.js' +export * from './utils/index.js' diff --git a/commons/src/message-transporters/index.ts b/commons/src/message-transporters/index.ts index f8cbd8c27..20e27e223 100644 --- a/commons/src/message-transporters/index.ts +++ b/commons/src/message-transporters/index.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export * from './mocked-backend-message-transporter.js' export * from './message.js' export * from './message-transporter.js' export * from './realtime-user.js' -export * from './websocket-transporter.js' +export * from './transport-adapter.js' +export * from './mocked-backend-transport-adapter.js' diff --git a/commons/src/message-transporters/message-transporter.ts b/commons/src/message-transporters/message-transporter.ts index 7b0b0d03c..c4a71c21f 100644 --- a/commons/src/message-transporters/message-transporter.ts +++ b/commons/src/message-transporters/message-transporter.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { Message, MessagePayloads, MessageType } from './message.js' +import { TransportAdapter } from './transport-adapter.js' import { EventEmitter2, Listener } from 'eventemitter2' export type MessageEvents = MessageType | 'connected' | 'disconnected' @@ -15,18 +16,60 @@ type MessageEventPayloadMap = { } export enum ConnectionState { - DISCONNECT, - CONNECTING, - CONNECTED + DISCONNECTED = 'DISCONNECTED', + CONNECTING = 'CONNECTING', + CONNECTED = 'CONNECTED' } /** - * Base class for event based message communication. + * Coordinates the sending, receiving and handling of messages for realtime communication. */ -export abstract class MessageTransporter extends EventEmitter2 { +export class MessageTransporter extends EventEmitter2 { + private transportAdapter: TransportAdapter | undefined private readyMessageReceived = false + private destroyOnMessageEventHandler: undefined | (() => void) + private destroyOnErrorEventHandler: undefined | (() => void) + private destroyOnCloseEventHandler: undefined | (() => void) + private destroyOnConnectedEventHandler: undefined | (() => void) - public abstract sendMessage(content: Message): void + public sendMessage(content: Message): void { + if (!this.isConnected()) { + this.onDisconnecting() + console.debug( + "Can't send message over closed connection. Triggering onDisconencted event. Message that couldn't be sent was", + content + ) + return + } + + if (this.transportAdapter === undefined) { + throw new Error('no transport adapter set') + } + + try { + this.transportAdapter.send(content) + } catch (error: unknown) { + this.disconnect() + throw error + } + } + + public setAdapter(websocket: TransportAdapter) { + if (websocket.getConnectionState() !== ConnectionState.CONNECTED) { + throw new Error('Websocket must be connected') + } + this.unbindEventsFromPreviousWebsocket() + this.transportAdapter = websocket + this.bindWebsocketEvents(websocket) + + if (this.isConnected()) { + this.onConnected() + } else { + this.destroyOnConnectedEventHandler = websocket.bindOnConnectedEvent( + this.onConnected.bind(this) + ) + } + } protected receiveMessage(message: Message): void { if (message.type === MessageType.READY) { @@ -35,21 +78,53 @@ export abstract class MessageTransporter extends EventEmitter2(content: Message) { - if (content.type === MessageType.NOTE_CONTENT_STATE_REQUEST) { - setTimeout(() => { - this.receiveMessage({ - type: MessageType.NOTE_CONTENT_UPDATE, - payload: this.doc.encodeStateAsUpdate(content.payload) - }) - }, 10) - } - } - - getConnectionState(): ConnectionState { - return this.connected - ? ConnectionState.CONNECTED - : ConnectionState.DISCONNECT - } -} diff --git a/commons/src/message-transporters/mocked-backend-transport-adapter.ts b/commons/src/message-transporters/mocked-backend-transport-adapter.ts new file mode 100644 index 000000000..dc46307f6 --- /dev/null +++ b/commons/src/message-transporters/mocked-backend-transport-adapter.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { RealtimeDoc } from '../y-doc-sync/index.js' +import { ConnectionState } from './message-transporter.js' +import { Message, MessageType } from './message.js' +import { TransportAdapter } from './transport-adapter.js' + +/** + * Provides a transport adapter that simulates a connection with a real HedgeDoc realtime backend. + */ +export class MockedBackendTransportAdapter implements TransportAdapter { + private readonly doc: RealtimeDoc + + private connected = true + + private closeHandler: undefined | (() => void) + + private messageHandler: undefined | ((value: Message) => void) + + constructor(initialContent: string) { + this.doc = new RealtimeDoc(initialContent) + } + + bindOnCloseEvent(handler: () => void): () => void { + this.closeHandler = handler + return () => { + this.connected = false + this.closeHandler = undefined + } + } + + bindOnConnectedEvent(handler: () => void): () => void { + handler() + return () => { + //empty on purpose + } + } + + bindOnErrorEvent(): () => void { + return () => { + //empty on purpose + } + } + + bindOnMessageEvent( + handler: (value: Message) => void + ): () => void { + this.messageHandler = handler + return () => { + this.messageHandler = undefined + } + } + + disconnect(): void { + if (!this.connected) { + return + } + this.connected = false + this.closeHandler?.() + } + + getConnectionState(): ConnectionState { + return this.connected + ? ConnectionState.CONNECTED + : ConnectionState.DISCONNECTED + } + + send(value: Message): void { + if (value.type === MessageType.NOTE_CONTENT_STATE_REQUEST) { + new Promise(() => { + this.messageHandler?.({ + type: MessageType.NOTE_CONTENT_UPDATE, + payload: this.doc.encodeStateAsUpdate(value.payload) + }) + }).catch((error: Error) => console.error(error)) + } else if (value.type === MessageType.READY) { + new Promise(() => { + this.messageHandler?.({ + type: MessageType.READY + }) + }).catch((error: Error) => console.error(error)) + } + } +} diff --git a/commons/src/message-transporters/transport-adapter.ts b/commons/src/message-transporters/transport-adapter.ts new file mode 100644 index 000000000..0a14f37cd --- /dev/null +++ b/commons/src/message-transporters/transport-adapter.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ConnectionState } from './message-transporter.js' +import { Message, MessageType } from './message.js' + +/** + * Defines methods that must be implemented to send and receive messages using an {@link AdapterMessageTransporter}. + */ +export interface TransportAdapter { + getConnectionState(): ConnectionState + + bindOnMessageEvent(handler: (value: Message) => void): () => void + + bindOnConnectedEvent(handler: () => void): () => void + + bindOnErrorEvent(handler: () => void): () => void + + bindOnCloseEvent(handler: () => void): () => void + + disconnect(): void + + send(value: Message): void +} diff --git a/commons/src/message-transporters/websocket-transporter.ts b/commons/src/message-transporters/websocket-transporter.ts deleted file mode 100644 index bf1e629d2..000000000 --- a/commons/src/message-transporters/websocket-transporter.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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, { MessageEvent } from 'isomorphic-ws' - -export class WebsocketTransporter extends MessageTransporter { - private websocket: WebSocket | undefined - - private messageCallback: undefined | ((event: MessageEvent) => void) - private closeCallback: undefined | (() => 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.isConnected()) { - 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.closeCallback) { - this.websocket.removeEventListener('error', this.closeCallback) - this.websocket.removeEventListener('close', this.closeCallback) - } - } - } - - private bindWebsocketEvents(websocket: WebSocket) { - this.messageCallback = this.processMessageEvent.bind(this) - this.closeCallback = this.onDisconnecting.bind(this) - - websocket.addEventListener('message', this.messageCallback) - websocket.addEventListener('error', this.closeCallback) - websocket.addEventListener('close', this.closeCallback) - } - - private processMessageEvent(event: MessageEvent): void { - if (typeof event.data !== 'string') { - return - } - const message = JSON.parse(event.data) as Message - this.receiveMessage(message) - } - - public disconnect(): void { - this.websocket?.close() - } - - protected onDisconnecting() { - if (this.websocket === undefined) { - return - } - this.undbindEventsFromPreviousWebsocket() - this.websocket = undefined - super.onDisconnecting() - } - - public sendMessage(content: Message): void { - if (!this.isConnected()) { - this.onDisconnecting() - console.debug( - "Can't send message over closed connection. Triggering onDisconencted event. Message that couldn't be sent was", - content - ) - return - } - - if (this.websocket === undefined) { - throw new Error('websocket transporter has no websocket connection') - } - - 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 - } - } -} diff --git a/commons/src/utils/index.ts b/commons/src/utils/index.ts new file mode 100644 index 000000000..8c5efc05d --- /dev/null +++ b/commons/src/utils/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export * from './wait-for-other-promises-to-finish.js' diff --git a/commons/src/utils/wait-for-other-promises-to-finish.ts b/commons/src/utils/wait-for-other-promises-to-finish.ts new file mode 100644 index 000000000..991880734 --- /dev/null +++ b/commons/src/utils/wait-for-other-promises-to-finish.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Waits until all other pending promises are processed. + * + * NodeJS has a queue for async code that waits for being processed. This method adds a promise to the very end of this queue. + * If the promise is resolved then this means that all other promises before it have been processed as well. + * + * @return A promise which resolves when all other promises have been processed + */ +export function waitForOtherPromisesToFinish(): Promise { + return new Promise((resolve) => process.nextTick(resolve)) +} diff --git a/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts b/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts index 6c7e158f0..2fee2476e 100644 --- a/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts +++ b/commons/src/y-doc-sync/in-memory-connection-message.transporter.ts @@ -47,6 +47,6 @@ export class InMemoryConnectionMessageTransporter extends MessageTransporter { getConnectionState(): ConnectionState { return this.otherSide !== undefined ? ConnectionState.CONNECTED - : ConnectionState.DISCONNECT + : ConnectionState.DISCONNECTED } } diff --git a/frontend/package.json b/frontend/package.json index c1fa51da1..434c4227a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -79,7 +79,6 @@ "i18next": "22.5.0", "i18next-browser-languagedetector": "7.0.2", "i18next-resources-to-backend": "1.1.4", - "isomorphic-ws": "5.0.0", "js-yaml": "4.1.0", "katex": "0.16.7", "luxon": "3.3.0", diff --git a/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.spec.tsx b/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.spec.tsx index 411cb7e79..d32aff7d7 100644 --- a/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.spec.tsx +++ b/frontend/src/components/common/note-loading-boundary/create-non-existing-note-hint.spec.tsx @@ -8,13 +8,10 @@ import type { Note, NoteMetadata } from '../../../api/notes/types' import * as useSingleStringUrlParameterModule from '../../../hooks/common/use-single-string-url-parameter' import { mockI18n } from '../../../test-utils/mock-i18n' import { CreateNonExistingNoteHint } from './create-non-existing-note-hint' +import { waitForOtherPromisesToFinish } from '@hedgedoc/commons' import { act, render, screen, waitFor } from '@testing-library/react' import { Mock } from 'ts-mockery' -function waitForOtherPromisesToFinish(): Promise { - return new Promise((resolve) => process.nextTick(resolve)) -} - jest.mock('../../../api/notes') jest.mock('../../../hooks/common/use-single-string-url-parameter') diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.spec.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.spec.ts new file mode 100644 index 000000000..9ba461b96 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.spec.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { FrontendWebsocketAdapter } from './frontend-websocket-adapter' +import type { Message } from '@hedgedoc/commons' +import { ConnectionState, MessageType } from '@hedgedoc/commons' +import { Mock } from 'ts-mockery' + +describe('frontend websocket', () => { + let addEventListenerSpy: jest.Mock + let removeEventListenerSpy: jest.Mock + let closeSpy: jest.Mock + let sendSpy: jest.Mock + let adapter: FrontendWebsocketAdapter + let mockedSocket: WebSocket + + function mockSocket(readyState: 0 | 1 | 2 | 3 = WebSocket.OPEN) { + addEventListenerSpy = jest.fn() + removeEventListenerSpy = jest.fn() + closeSpy = jest.fn() + sendSpy = jest.fn() + + mockedSocket = Mock.of({ + addEventListener: addEventListenerSpy, + removeEventListener: removeEventListenerSpy, + close: closeSpy, + send: sendSpy, + readyState: readyState + }) + adapter = new FrontendWebsocketAdapter(mockedSocket) + } + + it('can bind and unbind the close event', () => { + mockSocket() + const handler = jest.fn() + const unbind = adapter.bindOnCloseEvent(handler) + expect(addEventListenerSpy).toHaveBeenCalledWith('close', handler) + unbind() + expect(removeEventListenerSpy).toHaveBeenCalledWith('close', handler) + }) + + it('can bind and unbind the connect event', () => { + mockSocket() + const handler = jest.fn() + const unbind = adapter.bindOnConnectedEvent(handler) + expect(addEventListenerSpy).toHaveBeenCalledWith('open', handler) + unbind() + expect(removeEventListenerSpy).toHaveBeenCalledWith('open', handler) + }) + + it('can bind and unbind the error event', () => { + mockSocket() + const handler = jest.fn() + const unbind = adapter.bindOnErrorEvent(handler) + expect(addEventListenerSpy).toHaveBeenCalledWith('error', handler) + unbind() + expect(removeEventListenerSpy).toHaveBeenCalledWith('error', handler) + }) + + it('can bind, unbind and translate the message event', () => { + mockSocket() + const handler = jest.fn() + + let modifiedHandler: EventListenerOrEventListenerObject = jest.fn() + jest.spyOn(mockedSocket, 'addEventListener').mockImplementation((event, handler_) => { + modifiedHandler = handler_ + }) + + const unbind = adapter.bindOnMessageEvent(handler) + + modifiedHandler(Mock.of({ data: new ArrayBuffer(0) })) + expect(handler).toHaveBeenCalledTimes(0) + + modifiedHandler(Mock.of({ data: '{ "type": "READY" }' })) + expect(handler).toHaveBeenCalledWith({ type: 'READY' }) + + expect(addEventListenerSpy).toHaveBeenCalledWith('message', modifiedHandler) + unbind() + expect(removeEventListenerSpy).toHaveBeenCalledWith('message', modifiedHandler) + }) + + it('can disconnect the socket', () => { + mockSocket() + adapter.disconnect() + expect(closeSpy).toHaveBeenCalled() + }) + + it('can send messages', () => { + mockSocket() + const value: Message = { type: MessageType.READY } + adapter.send(value) + expect(sendSpy).toHaveBeenCalledWith('{"type":"READY"}') + }) + + it('can read the connection state when open', () => { + mockSocket(WebSocket.OPEN) + expect(adapter.getConnectionState()).toBe(ConnectionState.CONNECTED) + }) + + it('can read the connection state when connecting', () => { + mockSocket(WebSocket.CONNECTING) + expect(adapter.getConnectionState()).toBe(ConnectionState.CONNECTING) + }) + + it('can read the connection state when closing', () => { + mockSocket(WebSocket.CLOSING) + expect(adapter.getConnectionState()).toBe(ConnectionState.DISCONNECTED) + }) + + it('can read the connection state when closed', () => { + mockSocket(WebSocket.CLOSED) + expect(adapter.getConnectionState()).toBe(ConnectionState.DISCONNECTED) + }) +}) diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.ts new file mode 100644 index 000000000..4210e2fb5 --- /dev/null +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/frontend-websocket-adapter.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ConnectionState } from '@hedgedoc/commons' +import type { TransportAdapter } from '@hedgedoc/commons' +import type { Message, MessageType } from '@hedgedoc/commons/dist' + +/** + * Implements a transport adapter that communicates using a browser websocket. + */ +export class FrontendWebsocketAdapter implements TransportAdapter { + constructor(private socket: WebSocket) {} + + bindOnCloseEvent(handler: () => void): () => void { + this.socket.addEventListener('close', handler) + return () => { + this.socket.removeEventListener('close', handler) + } + } + + bindOnConnectedEvent(handler: () => void): () => void { + this.socket.addEventListener('open', handler) + return () => { + this.socket.removeEventListener('open', handler) + } + } + + bindOnErrorEvent(handler: () => void): () => void { + this.socket.addEventListener('error', handler) + return () => { + this.socket.removeEventListener('error', handler) + } + } + + bindOnMessageEvent(handler: (value: Message) => void): () => void { + function processStringAsMessage(message: MessageEvent): void { + if (typeof message.data !== 'string') { + return + } + + handler(JSON.parse(message.data) as Message) + } + + this.socket.addEventListener('message', processStringAsMessage) + return () => { + this.socket.removeEventListener('message', processStringAsMessage) + } + } + + disconnect(): void { + this.socket.close() + } + + getConnectionState(): ConnectionState { + if (this.socket.readyState === WebSocket.OPEN) { + return ConnectionState.CONNECTED + } else if (this.socket.readyState === WebSocket.CONNECTING) { + return ConnectionState.CONNECTING + } else { + return ConnectionState.DISCONNECTED + } + } + + send(value: Message): void { + this.socket.send(JSON.stringify(value)) + } +} 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 b6d6a480b..829a8b0a7 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 @@ -8,11 +8,10 @@ import { getGlobalState } from '../../../../../redux' import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods' import { Logger } from '../../../../../utils/logger' import { isMockMode } from '../../../../../utils/test-modes' +import { FrontendWebsocketAdapter } from './frontend-websocket-adapter' import { useWebsocketUrl } from './use-websocket-url' -import type { MessageTransporter } from '@hedgedoc/commons' -import { MockedBackendMessageTransporter, WebsocketTransporter } from '@hedgedoc/commons' +import { MessageTransporter, MockedBackendTransportAdapter } from '@hedgedoc/commons' import type { Listener } from 'eventemitter2' -import WebSocket from 'isomorphic-ws' import { useCallback, useEffect, useMemo, useRef } from 'react' const logger = new Logger('websocket connection') @@ -20,28 +19,25 @@ const WEBSOCKET_RECONNECT_INTERVAL = 2000 const WEBSOCKET_RECONNECT_MAX_DURATION = 5000 /** - * Creates a {@link WebsocketTransporter websocket message transporter} that handles the realtime communication with the backend. + * Creates a {@link MessageTransporter message transporter} that handles the realtime communication with the backend. * * @return the created connection handler */ export const useRealtimeConnection = (): MessageTransporter => { const websocketUrl = useWebsocketUrl() - const messageTransporter = useMemo(() => { - if (isMockMode) { - logger.debug('Creating Loopback connection...') - return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain) - } else { - logger.debug('Creating Websocket connection...') - return new WebsocketTransporter() - } - }, []) + const messageTransporter = useMemo(() => new MessageTransporter(), []) const reconnectCount = useRef(0) - const establishWebsocketConnection = useCallback(() => { - if (messageTransporter instanceof WebsocketTransporter && websocketUrl) { + if (isMockMode) { + logger.debug('Creating Loopback connection...') + messageTransporter.setAdapter( + new MockedBackendTransportAdapter(getGlobalState().noteDetails.markdownContent.plain) + ) + } else if (websocketUrl) { logger.debug(`Connecting to ${websocketUrl.toString()}`) - const socket = new WebSocket(websocketUrl) + + const socket = new WebSocket(websocketUrl.toString()) socket.addEventListener('error', () => { const timeout = WEBSOCKET_RECONNECT_INTERVAL + reconnectCount.current * 1000 + Math.random() * 1000 setTimeout(() => { @@ -50,7 +46,7 @@ export const useRealtimeConnection = (): MessageTransporter => { }, Math.max(timeout, WEBSOCKET_RECONNECT_MAX_DURATION)) }) socket.addEventListener('open', () => { - messageTransporter.setWebsocket(socket) + messageTransporter.setAdapter(new FrontendWebsocketAdapter(socket)) }) } }, [messageTransporter, websocketUrl]) diff --git a/yarn.lock b/yarn.lock index 54c1fccef..ff0a40177 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2384,7 +2384,6 @@ __metadata: eslint-plugin-jest: 27.2.1 eslint-plugin-prettier: 4.2.1 eventemitter2: 6.4.9 - isomorphic-ws: 5.0.0 jest: 29.5.0 joi: 17.9.2 microbundle: 0.15.1 @@ -2487,7 +2486,6 @@ __metadata: i18next: 22.5.0 i18next-browser-languagedetector: 7.0.2 i18next-resources-to-backend: 1.1.4 - isomorphic-ws: 5.0.0 jest: 29.5.0 jest-environment-jsdom: 29.5.0 js-yaml: 4.1.0 @@ -11625,15 +11623,6 @@ __metadata: languageName: node linkType: hard -"isomorphic-ws@npm:5.0.0": - version: 5.0.0 - resolution: "isomorphic-ws@npm:5.0.0" - peerDependencies: - ws: "*" - checksum: e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398 - languageName: node - linkType: hard - "isomorphic.js@npm:^0.2.4": version: 0.2.5 resolution: "isomorphic.js@npm:0.2.5"