From 723f3f611cf7306f037564441a728d6fab7e4430 Mon Sep 17 00:00:00 2001 From: Philip Molares <philip.molares@udo.edu> Date: Sun, 22 Oct 2023 21:33:34 +0200 Subject: [PATCH] feat(realtime): add disconnect reason The frontend now doesn't try to reconnect, when the disconnection happened because of a lack of permissions Signed-off-by: Philip Molares <philip.molares@udo.edu> --- .../backend-websocket-adapter.spec.ts | 34 +++++++++++++------ .../websocket/backend-websocket-adapter.ts | 18 +++++++--- .../realtime/websocket/websocket.gateway.ts | 5 ++- .../message-transporters/disconnect_reason.ts | 9 +++++ commons/src/message-transporters/index.ts | 1 + .../message-transporter.ts | 30 ++++++++++------ commons/src/message-transporters/message.ts | 8 ++++- .../message-transporters/transport-adapter.ts | 3 +- .../yjs/frontend-websocket-adapter.spec.ts | 19 ++++++++--- .../hooks/yjs/frontend-websocket-adapter.ts | 10 +++--- .../hooks/yjs/use-realtime-connection.ts | 20 +++++++---- 11 files changed, 111 insertions(+), 46 deletions(-) create mode 100644 commons/src/message-transporters/disconnect_reason.ts diff --git a/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts b/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts index 4bae2cf4c..0bda5122d 100644 --- a/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts +++ b/backend/src/realtime/websocket/backend-websocket-adapter.spec.ts @@ -3,9 +3,14 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ConnectionState, Message, MessageType } from '@hedgedoc/commons'; +import { + ConnectionState, + DisconnectReason, + Message, + MessageType, +} from '@hedgedoc/commons'; import { Mock } from 'ts-mockery'; -import WebSocket, { MessageEvent } from 'ws'; +import WebSocket, { CloseEvent, MessageEvent } from 'ws'; import { BackendWebsocketAdapter } from './backend-websocket-adapter'; @@ -29,17 +34,26 @@ describe('backend websocket adapter', () => { }); it('can bind and unbind the close event', () => { - const handler = jest.fn(); + const handler = jest.fn((reason?: DisconnectReason) => console.log(reason)); + + let modifiedHandler: (event: CloseEvent) => void = jest.fn(); + jest + .spyOn(mockedSocket, 'addEventListener') + .mockImplementation((event, handler_) => { + modifiedHandler = handler_; + }); + const unbind = sut.bindOnCloseEvent(handler); - expect(mockedSocket.addEventListener).toHaveBeenCalledWith( - 'close', - handler, + + modifiedHandler( + Mock.of<CloseEvent>({ code: DisconnectReason.USER_NOT_PERMITTED }), ); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(DisconnectReason.USER_NOT_PERMITTED); + unbind(); - expect(mockedSocket.removeEventListener).toHaveBeenCalledWith( - 'close', - handler, - ); + + expect(mockedSocket.removeEventListener).toHaveBeenCalled(); }); it('can bind and unbind the connect event', () => { diff --git a/backend/src/realtime/websocket/backend-websocket-adapter.ts b/backend/src/realtime/websocket/backend-websocket-adapter.ts index c59a8893a..4040a50fb 100644 --- a/backend/src/realtime/websocket/backend-websocket-adapter.ts +++ b/backend/src/realtime/websocket/backend-websocket-adapter.ts @@ -3,9 +3,14 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { ConnectionState, Message, MessageType } from '@hedgedoc/commons'; +import { + ConnectionState, + DisconnectReason, + Message, + MessageType, +} from '@hedgedoc/commons'; import type { TransportAdapter } from '@hedgedoc/commons'; -import WebSocket, { MessageEvent } from 'ws'; +import WebSocket, { CloseEvent, MessageEvent } from 'ws'; /** * Implements a transport adapter that communicates using a nodejs socket. @@ -13,10 +18,13 @@ import WebSocket, { MessageEvent } from 'ws'; export class BackendWebsocketAdapter implements TransportAdapter { constructor(private socket: WebSocket) {} - bindOnCloseEvent(handler: () => void): () => void { - this.socket.addEventListener('close', handler); + bindOnCloseEvent(handler: (reason?: DisconnectReason) => void): () => void { + function callback(event: CloseEvent): void { + handler(event.code); + } + this.socket.addEventListener('close', callback); return () => { - this.socket.removeEventListener('close', handler); + this.socket.removeEventListener('close', callback); }; } diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts index 75facc4dd..ad0466d46 100644 --- a/backend/src/realtime/websocket/websocket.gateway.ts +++ b/backend/src/realtime/websocket/websocket.gateway.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { + DisconnectReason, MessageTransporter, NotePermissions, userCanEdit, @@ -66,13 +67,11 @@ export class WebsocketGateway implements OnGatewayConnection { note, ); if (notePermission < NotePermission.READ) { - //TODO: [mrdrogdrog] inform client about reason of disconnect. - // (https://github.com/hedgedoc/hedgedoc/issues/5034) this.logger.log( `Access denied to note '${note.id}' for user '${username}'`, 'handleConnection', ); - clientSocket.close(); + clientSocket.close(DisconnectReason.USER_NOT_PERMITTED); return; } diff --git a/commons/src/message-transporters/disconnect_reason.ts b/commons/src/message-transporters/disconnect_reason.ts new file mode 100644 index 000000000..070bd4842 --- /dev/null +++ b/commons/src/message-transporters/disconnect_reason.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export enum DisconnectReason { + USER_NOT_PERMITTED = 4000 +} diff --git a/commons/src/message-transporters/index.ts b/commons/src/message-transporters/index.ts index 20e27e223..229af5a4a 100644 --- a/commons/src/message-transporters/index.ts +++ b/commons/src/message-transporters/index.ts @@ -9,3 +9,4 @@ export * from './message-transporter.js' export * from './realtime-user.js' export * from './transport-adapter.js' export * from './mocked-backend-transport-adapter.js' +export * from './disconnect_reason.js' diff --git a/commons/src/message-transporters/message-transporter.ts b/commons/src/message-transporters/message-transporter.ts index c070da270..c8d3fc180 100644 --- a/commons/src/message-transporters/message-transporter.ts +++ b/commons/src/message-transporters/message-transporter.ts @@ -1,17 +1,25 @@ /* - * 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 */ -import { Message, MessagePayloads, MessageType } from './message.js' +import { + ConnectionStateEvent, + Message, + MessagePayloads, + MessageType +} from './message.js' import { TransportAdapter } from './transport-adapter.js' import { EventEmitter2, Listener } from 'eventemitter2' +import { DisconnectReason } from './disconnect_reason.js' -export type MessageEvents = MessageType | 'connected' | 'disconnected' | 'ready' +export type AllEvents = MessageType | ConnectionStateEvent type MessageEventPayloadMap = { - [E in MessageEvents]: E extends keyof MessagePayloads + [E in AllEvents]: E extends keyof MessagePayloads ? (message: Message<E>) => void + : E extends ConnectionStateEvent.DISCONNECTED + ? (reason?: DisconnectReason) => void : () => void } @@ -157,14 +165,14 @@ export class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> { } } - private bindWebsocketEvents(websocket: TransportAdapter) { - this.destroyOnErrorEventHandler = websocket.bindOnErrorEvent( + private bindWebsocketEvents(transportAdapter: TransportAdapter) { + this.destroyOnErrorEventHandler = transportAdapter.bindOnErrorEvent( this.onDisconnecting.bind(this) ) - this.destroyOnCloseEventHandler = websocket.bindOnCloseEvent( + this.destroyOnCloseEventHandler = transportAdapter.bindOnCloseEvent( this.onDisconnecting.bind(this) ) - this.destroyOnMessageEventHandler = websocket.bindOnMessageEvent( + this.destroyOnMessageEventHandler = transportAdapter.bindOnMessageEvent( this.receiveMessage.bind(this) ) } @@ -172,10 +180,10 @@ export class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> { protected onConnected(): void { this.destroyOnConnectedEventHandler?.() this.destroyOnConnectedEventHandler = undefined - this.emit('connected') + this.emit(ConnectionStateEvent.CONNECTED) } - protected onDisconnecting(): void { + protected onDisconnecting(reason?: DisconnectReason): void { if (this.transportAdapter === undefined) { return } @@ -184,7 +192,7 @@ export class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> { this.thisSideReady = false this.otherSideReady = false this.transportAdapter = undefined - this.emit('disconnected') + this.emit(ConnectionStateEvent.DISCONNECTED, reason) } /** diff --git a/commons/src/message-transporters/message.ts b/commons/src/message-transporters/message.ts index 76fd22c47..f45e33c06 100644 --- a/commons/src/message-transporters/message.ts +++ b/commons/src/message-transporters/message.ts @@ -1,5 +1,5 @@ /* - * 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 */ @@ -22,6 +22,12 @@ export enum MessageType { READY_ANSWER = 'READY_ANSWER' } +export enum ConnectionStateEvent { + READY = 'ready', + CONNECTED = 'connected', + DISCONNECTED = 'disconnected' +} + export interface MessagePayloads { [MessageType.NOTE_CONTENT_STATE_REQUEST]: number[] [MessageType.NOTE_CONTENT_UPDATE]: number[] diff --git a/commons/src/message-transporters/transport-adapter.ts b/commons/src/message-transporters/transport-adapter.ts index 0a14f37cd..5d1a0680b 100644 --- a/commons/src/message-transporters/transport-adapter.ts +++ b/commons/src/message-transporters/transport-adapter.ts @@ -5,6 +5,7 @@ */ import { ConnectionState } from './message-transporter.js' import { Message, MessageType } from './message.js' +import { DisconnectReason } from './disconnect_reason.js' /** * Defines methods that must be implemented to send and receive messages using an {@link AdapterMessageTransporter}. @@ -18,7 +19,7 @@ export interface TransportAdapter { bindOnErrorEvent(handler: () => void): () => void - bindOnCloseEvent(handler: () => void): () => void + bindOnCloseEvent(handler: (reason?: DisconnectReason) => void): () => void disconnect(): void 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 index 57c747567..9e6ba52bc 100644 --- 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 @@ -5,7 +5,7 @@ */ import { FrontendWebsocketAdapter } from './frontend-websocket-adapter' import type { Message } from '@hedgedoc/commons' -import { ConnectionState, MessageType } from '@hedgedoc/commons' +import { ConnectionState, DisconnectReason, MessageType } from '@hedgedoc/commons' import { Mock } from 'ts-mockery' describe('frontend websocket', () => { @@ -34,11 +34,22 @@ describe('frontend websocket', () => { it('can bind and unbind the close event', () => { mockSocket() - const handler = jest.fn() + const handler = jest.fn((reason?: DisconnectReason) => console.log(reason)) + + let modifiedHandler: (event: CloseEvent) => void = jest.fn() + jest.spyOn(mockedSocket, 'addEventListener').mockImplementation((event, handler_) => { + modifiedHandler = handler_ + }) + const unbind = adapter.bindOnCloseEvent(handler) - expect(addEventListenerSpy).toHaveBeenCalledWith('close', handler) + + modifiedHandler(Mock.of<CloseEvent>({ code: DisconnectReason.USER_NOT_PERMITTED })) + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith(DisconnectReason.USER_NOT_PERMITTED) + unbind() - expect(removeEventListenerSpy).toHaveBeenCalledWith('close', handler) + + expect(removeEventListenerSpy).toHaveBeenCalled() }) it('can bind and unbind the connect event', () => { 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 index a3deb3ba2..6e7eda5b5 100644 --- 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 @@ -3,9 +3,8 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { TransportAdapter } from '@hedgedoc/commons' +import type { DisconnectReason, Message, MessageType, TransportAdapter } from '@hedgedoc/commons' import { ConnectionState } from '@hedgedoc/commons' -import type { Message, MessageType } from '@hedgedoc/commons' /** * Implements a transport adapter that communicates using a browser websocket. @@ -13,10 +12,11 @@ import type { Message, MessageType } from '@hedgedoc/commons' export class FrontendWebsocketAdapter implements TransportAdapter { constructor(private socket: WebSocket) {} - bindOnCloseEvent(handler: () => void): () => void { - this.socket.addEventListener('close', handler) + bindOnCloseEvent(handler: (reason?: DisconnectReason) => void): () => void { + const callback = (event: CloseEvent): void => handler(event.code) + this.socket.addEventListener('close', callback) return () => { - this.socket.removeEventListener('close', handler) + this.socket.removeEventListener('close', callback) } } 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 79813c2a2..a461b5ac0 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 @@ -1,5 +1,5 @@ /* - * 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 */ @@ -10,7 +10,7 @@ import { Logger } from '../../../../../utils/logger' import { isMockMode } from '../../../../../utils/test-modes' import { FrontendWebsocketAdapter } from './frontend-websocket-adapter' import { useWebsocketUrl } from './use-websocket-url' -import { MessageTransporter, MockedBackendTransportAdapter } from '@hedgedoc/commons' +import { DisconnectReason, MessageTransporter, MockedBackendTransportAdapter } from '@hedgedoc/commons' import type { Listener } from 'eventemitter2' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -28,6 +28,7 @@ export const useRealtimeConnection = (): MessageTransporter => { const messageTransporter = useMemo(() => new MessageTransporter(), []) const reconnectCount = useRef(0) + const disconnectReason = useRef<DisconnectReason | undefined>(undefined) const establishWebsocketConnection = useCallback(() => { if (isMockMode) { logger.debug('Creating Loopback connection...') @@ -57,7 +58,7 @@ export const useRealtimeConnection = (): MessageTransporter => { const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected) useEffect(() => { - if (isConnected || reconnectCount.current > 0) { + if (isConnected || reconnectCount.current > 0 || disconnectReason.current === DisconnectReason.USER_NOT_PERMITTED) { return } establishWebsocketConnection() @@ -86,9 +87,16 @@ export const useRealtimeConnection = (): MessageTransporter => { useEffect(() => { const connectedListener = messageTransporter.doAsSoonAsReady(() => setRealtimeConnectionState(true)) - const disconnectedListener = messageTransporter.on('disconnected', () => setRealtimeConnectionState(false), { - objectify: true - }) as Listener + const disconnectedListener = messageTransporter.on( + 'disconnected', + (reason?: DisconnectReason) => { + disconnectReason.current = reason + setRealtimeConnectionState(false) + }, + { + objectify: true + } + ) as Listener return () => { connectedListener.off()