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

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