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
*/
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', () => {

View file

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

View file

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

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 './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
*/
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)
}
/**

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
*/
@ -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[]

View file

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

View file

@ -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', () => {

View file

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

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
*/
@ -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()