refactor: remove isomorphic-ws

The package caused some issues while working on other features.
Mostly because bundlers have been unable to determine the correct
websocket constructor.
So I replaced it with a more object-oriented approach.

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-05-31 22:38:45 +02:00
parent 14ba7ea9ce
commit 753c6e593f
23 changed files with 724 additions and 283 deletions

View file

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

View file

@ -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<MockedBackendMessageTransporter['sendMessage']>]
>;
type SendMessageSpy = jest.SpyInstance<void, [content: Message<MessageType>]>;
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,
);
});

View file

@ -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<RealtimeUserStatusAdapter>({})

View file

@ -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<WebSocket>({
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<MessageEvent>({ data: new ArrayBuffer(0) }));
expect(handler).toHaveBeenCalledTimes(0);
modifiedHandler(Mock.of<MessageEvent>({ 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<MessageType> = { 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);
});
});

View file

@ -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<MessageType>) => void,
): () => void {
function adjustedHandler(message: MessageEvent): void {
if (typeof message.data !== 'string') {
return;
}
handler(JSON.parse(message.data) as Message<MessageType>);
}
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<MessageType>): void {
this.socket.send(JSON.stringify(value));
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<MessageEventPayloadMap> {
export class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> {
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<M extends MessageType>(content: Message<M>): void
public sendMessage<M extends MessageType>(content: Message<M>): 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<L extends MessageType>(message: Message<L>): void {
if (message.type === MessageType.READY) {
@ -35,21 +78,53 @@ export abstract class MessageTransporter extends EventEmitter2<MessageEventPaylo
this.emit(message.type, message)
}
public sendReady(): void {
this.sendMessage({
type: MessageType.READY
})
public disconnect(): void {
this.transportAdapter?.disconnect()
}
public abstract disconnect(): void
public getConnectionState(): ConnectionState {
return (
this.transportAdapter?.getConnectionState() ??
ConnectionState.DISCONNECTED
)
}
public abstract getConnectionState(): ConnectionState
private unbindEventsFromPreviousWebsocket() {
if (this.transportAdapter) {
this.destroyOnMessageEventHandler?.()
this.destroyOnCloseEventHandler?.()
this.destroyOnErrorEventHandler?.()
this.destroyOnMessageEventHandler = undefined
this.destroyOnCloseEventHandler = undefined
this.destroyOnErrorEventHandler = undefined
}
}
private bindWebsocketEvents(websocket: TransportAdapter) {
this.destroyOnErrorEventHandler = websocket.bindOnErrorEvent(
this.onDisconnecting.bind(this)
)
this.destroyOnCloseEventHandler = websocket.bindOnCloseEvent(
this.onDisconnecting.bind(this)
)
this.destroyOnMessageEventHandler = websocket.bindOnMessageEvent(
this.receiveMessage.bind(this)
)
}
protected onConnected(): void {
this.destroyOnConnectedEventHandler?.()
this.destroyOnConnectedEventHandler = undefined
this.emit('connected')
}
protected onDisconnecting(): void {
if (this.transportAdapter === undefined) {
return
}
this.unbindEventsFromPreviousWebsocket()
this.transportAdapter = undefined
this.readyMessageReceived = false
this.emit('disconnected')
}
@ -99,4 +174,10 @@ export abstract class MessageTransporter extends EventEmitter2<MessageEventPaylo
objectify: true
}) as Listener
}
public sendReady(): void {
this.sendMessage({
type: MessageType.READY
})
}
}

View file

@ -1,56 +0,0 @@
/*
* 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'
/**
* 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: RealtimeDoc
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(() => {
this.receiveMessage({
type: MessageType.NOTE_CONTENT_UPDATE,
payload: this.doc.encodeStateAsUpdate(content.payload)
})
}, 10)
}
}
getConnectionState(): ConnectionState {
return this.connected
? ConnectionState.CONNECTED
: ConnectionState.DISCONNECT
}
}

View file

@ -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<MessageType>) => 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<MessageType>) => 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<MessageType>): 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))
}
}
}

View file

@ -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<MessageType>) => void): () => void
bindOnConnectedEvent(handler: () => void): () => void
bindOnErrorEvent(handler: () => void): () => void
bindOnCloseEvent(handler: () => void): () => void
disconnect(): void
send(value: Message<MessageType>): void
}

View file

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

View file

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

View file

@ -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<void> {
return new Promise((resolve) => process.nextTick(resolve))
}

View file

@ -47,6 +47,6 @@ export class InMemoryConnectionMessageTransporter extends MessageTransporter {
getConnectionState(): ConnectionState {
return this.otherSide !== undefined
? ConnectionState.CONNECTED
: ConnectionState.DISCONNECT
: ConnectionState.DISCONNECTED
}
}

View file

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

View file

@ -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<void> {
return new Promise((resolve) => process.nextTick(resolve))
}
jest.mock('../../../api/notes')
jest.mock('../../../hooks/common/use-single-string-url-parameter')

View file

@ -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<WebSocket>({
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<MessageEvent>({ data: new ArrayBuffer(0) }))
expect(handler).toHaveBeenCalledTimes(0)
modifiedHandler(Mock.of<MessageEvent>({ 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<MessageType> = { 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)
})
})

View file

@ -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<MessageType>) => void): () => void {
function processStringAsMessage(message: MessageEvent): void {
if (typeof message.data !== 'string') {
return
}
handler(JSON.parse(message.data) as Message<MessageType>)
}
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<MessageType>): void {
this.socket.send(JSON.stringify(value))
}
}

View file

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

View file

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