mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-24 18:56:32 -05:00
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:
parent
14ba7ea9ce
commit
753c6e593f
23 changed files with 724 additions and 283 deletions
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>({})
|
||||
|
|
129
backend/src/realtime/websocket/backend-websocket-adapter.spec.ts
Normal file
129
backend/src/realtime/websocket/backend-websocket-adapter.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
71
backend/src/realtime/websocket/backend-websocket-adapter.ts
Normal file
71
backend/src/realtime/websocket/backend-websocket-adapter.ts
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
26
commons/src/message-transporters/transport-adapter.ts
Normal file
26
commons/src/message-transporters/transport-adapter.ts
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
7
commons/src/utils/index.ts
Normal file
7
commons/src/utils/index.ts
Normal 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'
|
17
commons/src/utils/wait-for-other-promises-to-finish.ts
Normal file
17
commons/src/utils/wait-for-other-promises-to-finish.ts
Normal 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))
|
||||
}
|
|
@ -47,6 +47,6 @@ export class InMemoryConnectionMessageTransporter extends MessageTransporter {
|
|||
getConnectionState(): ConnectionState {
|
||||
return this.otherSide !== undefined
|
||||
? ConnectionState.CONNECTED
|
||||
: ConnectionState.DISCONNECT
|
||||
: ConnectionState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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])
|
||||
|
|
11
yarn.lock
11
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"
|
||||
|
|
Loading…
Reference in a new issue