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>
This commit is contained in:
Philip Molares 2023-10-22 21:33:34 +02:00
parent f6cfe74d8c
commit 723f3f611c
11 changed files with 111 additions and 46 deletions

View file

@ -3,9 +3,14 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * 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 { Mock } from 'ts-mockery';
import WebSocket, { MessageEvent } from 'ws'; import WebSocket, { CloseEvent, MessageEvent } from 'ws';
import { BackendWebsocketAdapter } from './backend-websocket-adapter'; import { BackendWebsocketAdapter } from './backend-websocket-adapter';
@ -29,17 +34,26 @@ describe('backend websocket adapter', () => {
}); });
it('can bind and unbind the close event', () => { 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); const unbind = sut.bindOnCloseEvent(handler);
expect(mockedSocket.addEventListener).toHaveBeenCalledWith(
'close', modifiedHandler(
handler, Mock.of<CloseEvent>({ code: DisconnectReason.USER_NOT_PERMITTED }),
); );
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(DisconnectReason.USER_NOT_PERMITTED);
unbind(); unbind();
expect(mockedSocket.removeEventListener).toHaveBeenCalledWith(
'close', expect(mockedSocket.removeEventListener).toHaveBeenCalled();
handler,
);
}); });
it('can bind and unbind the connect event', () => { it('can bind and unbind the connect event', () => {

View file

@ -3,9 +3,14 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * 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 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. * Implements a transport adapter that communicates using a nodejs socket.
@ -13,10 +18,13 @@ import WebSocket, { MessageEvent } from 'ws';
export class BackendWebsocketAdapter implements TransportAdapter { export class BackendWebsocketAdapter implements TransportAdapter {
constructor(private socket: WebSocket) {} constructor(private socket: WebSocket) {}
bindOnCloseEvent(handler: () => void): () => void { bindOnCloseEvent(handler: (reason?: DisconnectReason) => void): () => void {
this.socket.addEventListener('close', handler); function callback(event: CloseEvent): void {
handler(event.code);
}
this.socket.addEventListener('close', callback);
return () => { return () => {
this.socket.removeEventListener('close', handler); this.socket.removeEventListener('close', callback);
}; };
} }

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { import {
DisconnectReason,
MessageTransporter, MessageTransporter,
NotePermissions, NotePermissions,
userCanEdit, userCanEdit,
@ -66,13 +67,11 @@ export class WebsocketGateway implements OnGatewayConnection {
note, note,
); );
if (notePermission < NotePermission.READ) { if (notePermission < NotePermission.READ) {
//TODO: [mrdrogdrog] inform client about reason of disconnect.
// (https://github.com/hedgedoc/hedgedoc/issues/5034)
this.logger.log( this.logger.log(
`Access denied to note '${note.id}' for user '${username}'`, `Access denied to note '${note.id}' for user '${username}'`,
'handleConnection', 'handleConnection',
); );
clientSocket.close(); clientSocket.close(DisconnectReason.USER_NOT_PERMITTED);
return; return;
} }

View file

@ -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
}

View file

@ -9,3 +9,4 @@ export * from './message-transporter.js'
export * from './realtime-user.js' export * from './realtime-user.js'
export * from './transport-adapter.js' export * from './transport-adapter.js'
export * from './mocked-backend-transport-adapter.js' export * from './mocked-backend-transport-adapter.js'
export * from './disconnect_reason.js'

View file

@ -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 * 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 { TransportAdapter } from './transport-adapter.js'
import { EventEmitter2, Listener } from 'eventemitter2' 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 = { type MessageEventPayloadMap = {
[E in MessageEvents]: E extends keyof MessagePayloads [E in AllEvents]: E extends keyof MessagePayloads
? (message: Message<E>) => void ? (message: Message<E>) => void
: E extends ConnectionStateEvent.DISCONNECTED
? (reason?: DisconnectReason) => void
: () => void : () => void
} }
@ -157,14 +165,14 @@ export class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> {
} }
} }
private bindWebsocketEvents(websocket: TransportAdapter) { private bindWebsocketEvents(transportAdapter: TransportAdapter) {
this.destroyOnErrorEventHandler = websocket.bindOnErrorEvent( this.destroyOnErrorEventHandler = transportAdapter.bindOnErrorEvent(
this.onDisconnecting.bind(this) this.onDisconnecting.bind(this)
) )
this.destroyOnCloseEventHandler = websocket.bindOnCloseEvent( this.destroyOnCloseEventHandler = transportAdapter.bindOnCloseEvent(
this.onDisconnecting.bind(this) this.onDisconnecting.bind(this)
) )
this.destroyOnMessageEventHandler = websocket.bindOnMessageEvent( this.destroyOnMessageEventHandler = transportAdapter.bindOnMessageEvent(
this.receiveMessage.bind(this) this.receiveMessage.bind(this)
) )
} }
@ -172,10 +180,10 @@ export class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> {
protected onConnected(): void { protected onConnected(): void {
this.destroyOnConnectedEventHandler?.() this.destroyOnConnectedEventHandler?.()
this.destroyOnConnectedEventHandler = undefined this.destroyOnConnectedEventHandler = undefined
this.emit('connected') this.emit(ConnectionStateEvent.CONNECTED)
} }
protected onDisconnecting(): void { protected onDisconnecting(reason?: DisconnectReason): void {
if (this.transportAdapter === undefined) { if (this.transportAdapter === undefined) {
return return
} }
@ -184,7 +192,7 @@ export class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> {
this.thisSideReady = false this.thisSideReady = false
this.otherSideReady = false this.otherSideReady = false
this.transportAdapter = undefined this.transportAdapter = undefined
this.emit('disconnected') this.emit(ConnectionStateEvent.DISCONNECTED, reason)
} }
/** /**

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -22,6 +22,12 @@ export enum MessageType {
READY_ANSWER = 'READY_ANSWER' READY_ANSWER = 'READY_ANSWER'
} }
export enum ConnectionStateEvent {
READY = 'ready',
CONNECTED = 'connected',
DISCONNECTED = 'disconnected'
}
export interface MessagePayloads { export interface MessagePayloads {
[MessageType.NOTE_CONTENT_STATE_REQUEST]: number[] [MessageType.NOTE_CONTENT_STATE_REQUEST]: number[]
[MessageType.NOTE_CONTENT_UPDATE]: number[] [MessageType.NOTE_CONTENT_UPDATE]: number[]

View file

@ -5,6 +5,7 @@
*/ */
import { ConnectionState } from './message-transporter.js' import { ConnectionState } from './message-transporter.js'
import { Message, MessageType } from './message.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}. * 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 bindOnErrorEvent(handler: () => void): () => void
bindOnCloseEvent(handler: () => void): () => void bindOnCloseEvent(handler: (reason?: DisconnectReason) => void): () => void
disconnect(): void disconnect(): void

View file

@ -5,7 +5,7 @@
*/ */
import { FrontendWebsocketAdapter } from './frontend-websocket-adapter' import { FrontendWebsocketAdapter } from './frontend-websocket-adapter'
import type { Message } from '@hedgedoc/commons' import type { Message } from '@hedgedoc/commons'
import { ConnectionState, MessageType } from '@hedgedoc/commons' import { ConnectionState, DisconnectReason, MessageType } from '@hedgedoc/commons'
import { Mock } from 'ts-mockery' import { Mock } from 'ts-mockery'
describe('frontend websocket', () => { describe('frontend websocket', () => {
@ -34,11 +34,22 @@ describe('frontend websocket', () => {
it('can bind and unbind the close event', () => { it('can bind and unbind the close event', () => {
mockSocket() 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) 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() unbind()
expect(removeEventListenerSpy).toHaveBeenCalledWith('close', handler)
expect(removeEventListenerSpy).toHaveBeenCalled()
}) })
it('can bind and unbind the connect event', () => { it('can bind and unbind the connect event', () => {

View file

@ -3,9 +3,8 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * 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 { ConnectionState } from '@hedgedoc/commons'
import type { Message, MessageType } from '@hedgedoc/commons'
/** /**
* Implements a transport adapter that communicates using a browser websocket. * 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 { export class FrontendWebsocketAdapter implements TransportAdapter {
constructor(private socket: WebSocket) {} constructor(private socket: WebSocket) {}
bindOnCloseEvent(handler: () => void): () => void { bindOnCloseEvent(handler: (reason?: DisconnectReason) => void): () => void {
this.socket.addEventListener('close', handler) const callback = (event: CloseEvent): void => handler(event.code)
this.socket.addEventListener('close', callback)
return () => { return () => {
this.socket.removeEventListener('close', handler) this.socket.removeEventListener('close', callback)
} }
} }

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -10,7 +10,7 @@ import { Logger } from '../../../../../utils/logger'
import { isMockMode } from '../../../../../utils/test-modes' import { isMockMode } from '../../../../../utils/test-modes'
import { FrontendWebsocketAdapter } from './frontend-websocket-adapter' import { FrontendWebsocketAdapter } from './frontend-websocket-adapter'
import { useWebsocketUrl } from './use-websocket-url' 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 type { Listener } from 'eventemitter2'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@ -28,6 +28,7 @@ export const useRealtimeConnection = (): MessageTransporter => {
const messageTransporter = useMemo(() => new MessageTransporter(), []) const messageTransporter = useMemo(() => new MessageTransporter(), [])
const reconnectCount = useRef(0) const reconnectCount = useRef(0)
const disconnectReason = useRef<DisconnectReason | undefined>(undefined)
const establishWebsocketConnection = useCallback(() => { const establishWebsocketConnection = useCallback(() => {
if (isMockMode) { if (isMockMode) {
logger.debug('Creating Loopback connection...') logger.debug('Creating Loopback connection...')
@ -57,7 +58,7 @@ export const useRealtimeConnection = (): MessageTransporter => {
const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected) const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
useEffect(() => { useEffect(() => {
if (isConnected || reconnectCount.current > 0) { if (isConnected || reconnectCount.current > 0 || disconnectReason.current === DisconnectReason.USER_NOT_PERMITTED) {
return return
} }
establishWebsocketConnection() establishWebsocketConnection()
@ -86,9 +87,16 @@ export const useRealtimeConnection = (): MessageTransporter => {
useEffect(() => { useEffect(() => {
const connectedListener = messageTransporter.doAsSoonAsReady(() => setRealtimeConnectionState(true)) const connectedListener = messageTransporter.doAsSoonAsReady(() => setRealtimeConnectionState(true))
const disconnectedListener = messageTransporter.on('disconnected', () => setRealtimeConnectionState(false), { const disconnectedListener = messageTransporter.on(
objectify: true 'disconnected',
}) as Listener (reason?: DisconnectReason) => {
disconnectReason.current = reason
setRealtimeConnectionState(false)
},
{
objectify: true
}
) as Listener
return () => { return () => {
connectedListener.off() connectedListener.off()