feat(packages): add commons package

This is an import of 166ca8da12
with some changes to make it fit into the mono repo.
- TypedEventEmitter has been replaced with EventEmitter2 because EventEmitter2 is faster and TypedEventEmitter had some troubles with the new way of compiling.
- tsc-esm has been replaced with microbundle. The problems that lib0 doesn't export its types correctly has been solved using yarn patch.

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-12-04 22:41:39 +01:00
parent 814d8bc856
commit 7320fe2ac1
49 changed files with 3058 additions and 88 deletions

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageType } from './messages/message-type.enum.js'
import type { YDocMessageTransporter } from './y-doc-message-transporter.js'
import { createEncoder, toUint8Array, writeVarUint } from 'lib0/encoding'
/**
* Provides a keep alive ping for a given {@link WebSocket websocket} connection by sending a periodic message.
*/
export class ConnectionKeepAliveHandler {
private pongReceived = false
private static readonly pingTimeout = 30 * 1000
private intervalId: NodeJS.Timer | undefined
/**
* Constructs the instance and starts the interval.
*
* @param messageTransporter The websocket to keep alive
*/
constructor(private messageTransporter: YDocMessageTransporter) {
this.messageTransporter.on('disconnected', () => this.stopTimer())
this.messageTransporter.on('ready', () => this.startTimer())
this.messageTransporter.on(String(MessageType.PING), () => {
this.sendPongMessage()
})
this.messageTransporter.on(
String(MessageType.PONG),
() => (this.pongReceived = true)
)
}
/**
* Starts the ping timer.
*/
public startTimer(): void {
this.pongReceived = false
this.intervalId = setInterval(
() => this.check(),
ConnectionKeepAliveHandler.pingTimeout
)
this.sendPingMessage()
}
public stopTimer(): void {
clearInterval(this.intervalId)
}
/**
* Checks if a pong has been received since the last run. If not, the connection is probably dead and will be terminated.
*/
private check(): void {
if (this.pongReceived) {
this.pongReceived = false
this.sendPingMessage()
} else {
this.messageTransporter.disconnect()
console.error(
`No pong received in the last ${ConnectionKeepAliveHandler.pingTimeout} seconds. Connection seems to be dead.`
)
}
}
private sendPingMessage(): void {
const encoder = createEncoder()
writeVarUint(encoder, MessageType.PING)
this.messageTransporter.send(toUint8Array(encoder))
}
private sendPongMessage(): void {
const encoder = createEncoder()
writeVarUint(encoder, MessageType.PONG)
this.messageTransporter.send(toUint8Array(encoder))
}
}

27
commons/src/index.ts Normal file
View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export { MessageType } from './messages/message-type.enum.js'
export { ConnectionKeepAliveHandler } from './connection-keep-alive-handler.js'
export { YDocMessageTransporter } from './y-doc-message-transporter.js'
export {
applyAwarenessUpdateMessage,
encodeAwarenessUpdateMessage
} from './messages/awareness-update-message.js'
export {
applyDocumentUpdateMessage,
encodeDocumentUpdateMessage
} from './messages/document-update-message.js'
export { encodeCompleteAwarenessStateRequestMessage } from './messages/complete-awareness-state-request-message.js'
export { encodeCompleteDocumentStateRequestMessage } from './messages/complete-document-state-request-message.js'
export { encodeCompleteDocumentStateAnswerMessage } from './messages/complete-document-state-answer-message.js'
export { encodeDocumentDeletedMessage } from './messages/document-deleted-message.js'
export { encodeMetadataUpdatedMessage } from './messages/metadata-updated-message.js'
export { encodeServerVersionUpdatedMessage } from './messages/server-version-updated-message.js'
export { WebsocketTransporter } from './websocket-transporter.js'
export type { MessageTransporterEvents } from './y-doc-message-transporter.js'

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageType } from './message-type.enum.js'
import type { Decoder } from 'lib0/decoding'
import { readVarUint8Array } from 'lib0/decoding'
import {
createEncoder,
toUint8Array,
writeVarUint,
writeVarUint8Array
} from 'lib0/encoding'
import type { Awareness } from 'y-protocols/awareness'
import {
applyAwarenessUpdate,
encodeAwarenessUpdate
} from 'y-protocols/awareness'
export function applyAwarenessUpdateMessage(
decoder: Decoder,
awareness: Awareness,
origin: unknown
): void {
applyAwarenessUpdate(awareness, readVarUint8Array(decoder), origin)
}
export function encodeAwarenessUpdateMessage(
awareness: Awareness,
updatedClientIds: number[]
): Uint8Array {
const encoder = createEncoder()
writeVarUint(encoder, MessageType.AWARENESS_UPDATE)
writeVarUint8Array(
encoder,
encodeAwarenessUpdate(awareness, updatedClientIds)
)
return toUint8Array(encoder)
}

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeGenericMessage } from './generic-message.js'
import { MessageType } from './message-type.enum.js'
export function encodeCompleteAwarenessStateRequestMessage(): Uint8Array {
return encodeGenericMessage(MessageType.COMPLETE_AWARENESS_STATE_REQUEST)
}

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageType } from './message-type.enum.js'
import { decoding } from 'lib0'
import { Decoder } from 'lib0/decoding'
import {
createEncoder,
toUint8Array,
writeVarUint,
writeVarUint8Array
} from 'lib0/encoding'
import type { Doc } from 'yjs'
import { encodeStateAsUpdate } from 'yjs'
export function encodeCompleteDocumentStateAnswerMessage(
doc: Doc,
decoder: Decoder
): Uint8Array {
const encoder = createEncoder()
writeVarUint(encoder, MessageType.COMPLETE_DOCUMENT_STATE_ANSWER)
writeVarUint8Array(
encoder,
encodeStateAsUpdate(doc, decoding.readVarUint8Array(decoder))
)
return toUint8Array(encoder)
}

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageType } from './message-type.enum.js'
import {
createEncoder,
toUint8Array,
writeVarUint,
writeVarUint8Array
} from 'lib0/encoding'
import type { Doc } from 'yjs'
import { encodeStateVector } from 'yjs'
export function encodeCompleteDocumentStateRequestMessage(
doc: Doc
): Uint8Array {
const encoder = createEncoder()
writeVarUint(encoder, MessageType.COMPLETE_DOCUMENT_STATE_REQUEST)
writeVarUint8Array(encoder, encodeStateVector(doc))
return toUint8Array(encoder)
}

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeGenericMessage } from './generic-message.js'
import { MessageType } from './message-type.enum.js'
export function encodeDocumentDeletedMessage(): Uint8Array {
return encodeGenericMessage(MessageType.DOCUMENT_DELETED)
}

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageType } from './message-type.enum.js'
import { readVarUint8Array } from 'lib0/decoding'
import type { Decoder } from 'lib0/decoding.js'
import {
createEncoder,
toUint8Array,
writeVarUint,
writeVarUint8Array
} from 'lib0/encoding'
import type { Doc } from 'yjs'
import { applyUpdate } from 'yjs'
export function applyDocumentUpdateMessage(
decoder: Decoder,
doc: Doc,
origin: unknown
): void {
applyUpdate(doc, readVarUint8Array(decoder), origin)
}
export function encodeDocumentUpdateMessage(
documentUpdate: Uint8Array
): Uint8Array {
const encoder = createEncoder()
writeVarUint(encoder, MessageType.DOCUMENT_UPDATE)
writeVarUint8Array(encoder, documentUpdate)
return toUint8Array(encoder)
}

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageType } from './message-type.enum.js'
import { createEncoder, toUint8Array, writeVarUint } from 'lib0/encoding'
/**
* Encodes a generic message with a given message type but without content.
*/
export function encodeGenericMessage(messageType: MessageType): Uint8Array {
const encoder = createEncoder()
writeVarUint(encoder, messageType)
return toUint8Array(encoder)
}

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum MessageType {
COMPLETE_DOCUMENT_STATE_REQUEST = 0,
COMPLETE_DOCUMENT_STATE_ANSWER = 1,
DOCUMENT_UPDATE = 2,
AWARENESS_UPDATE = 3,
COMPLETE_AWARENESS_STATE_REQUEST = 4,
PING = 5,
PONG = 6,
READY_REQUEST = 7,
READY_ANSWER = 8,
METADATA_UPDATED = 9,
DOCUMENT_DELETED = 10,
SERVER_VERSION_UPDATED = 11
}

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeGenericMessage } from './generic-message.js'
import { MessageType } from './message-type.enum.js'
export function encodeMetadataUpdatedMessage(): Uint8Array {
return encodeGenericMessage(MessageType.METADATA_UPDATED)
}

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeGenericMessage } from './generic-message.js'
import { MessageType } from './message-type.enum.js'
export function encodeReadyAnswerMessage(): Uint8Array {
return encodeGenericMessage(MessageType.READY_ANSWER)
}

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeGenericMessage } from './generic-message.js'
import { MessageType } from './message-type.enum.js'
export function encodeReadyRequestMessage(): Uint8Array {
return encodeGenericMessage(MessageType.READY_REQUEST)
}

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeGenericMessage } from './generic-message.js'
import { MessageType } from './message-type.enum.js'
export function encodeServerVersionUpdatedMessage(): Uint8Array {
return encodeGenericMessage(MessageType.SERVER_VERSION_UPDATED)
}

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ConnectionKeepAliveHandler } from './connection-keep-alive-handler.js'
import { YDocMessageTransporter } from './y-doc-message-transporter.js'
import WebSocket from 'isomorphic-ws'
import { Awareness } from 'y-protocols/awareness'
import { Doc } from 'yjs'
export class WebsocketTransporter extends YDocMessageTransporter {
private websocket: WebSocket | undefined
constructor(doc: Doc, awareness: Awareness) {
super(doc, awareness)
new ConnectionKeepAliveHandler(this)
}
public setupWebsocket(websocket: WebSocket) {
if (
websocket.readyState === WebSocket.CLOSED ||
websocket.readyState === WebSocket.CLOSING
) {
throw new Error(`Socket is closed`)
}
this.websocket = websocket
websocket.binaryType = 'arraybuffer'
websocket.addEventListener('message', (event) =>
this.decodeMessage(event.data as ArrayBuffer)
)
websocket.addEventListener('error', () => this.disconnect())
websocket.addEventListener('close', () => this.onClose())
if (websocket.readyState === WebSocket.OPEN) {
this.onOpen()
} else {
websocket.addEventListener('open', this.onOpen.bind(this))
}
}
public disconnect(): void {
this.websocket?.close()
}
public send(content: Uint8Array): void {
if (this.websocket?.readyState !== WebSocket.OPEN) {
throw new Error("Can't send message over non-open socket")
}
try {
this.websocket.send(content)
} catch (error: unknown) {
this.disconnect()
throw error
}
}
public isWebSocketOpen(): boolean {
return this.websocket?.readyState === WebSocket.OPEN
}
}

View file

@ -0,0 +1,206 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeDocumentUpdateMessage } from './messages/document-update-message.js'
import { MessageType } from './messages/message-type.enum.js'
import { YDocMessageTransporter } from './y-doc-message-transporter.js'
import { describe, expect, it } from '@jest/globals'
import { Awareness } from 'y-protocols/awareness'
import { Doc } from 'yjs'
class InMemoryMessageTransporter extends YDocMessageTransporter {
private otherSide: InMemoryMessageTransporter | undefined
constructor(private name: string, doc: Doc, awareness: Awareness) {
super(doc, awareness)
}
public connect(other: InMemoryMessageTransporter): void {
this.setOtherSide(other)
other.setOtherSide(this)
this.onOpen()
other.onOpen()
}
private setOtherSide(other: InMemoryMessageTransporter | undefined): void {
this.otherSide = other
}
public disconnect(): void {
this.onClose()
this.setOtherSide(undefined)
this.otherSide?.onClose()
this.otherSide?.setOtherSide(undefined)
}
send(content: Uint8Array): void {
if (this.otherSide === undefined) {
throw new Error('Disconnected')
}
console.debug(`${this.name}`, 'Sending', content)
this.otherSide?.decodeMessage(content)
}
public onOpen(): void {
super.onOpen()
}
}
describe('message transporter', () =>
it('server client communication', () => {
const docServer: Doc = new Doc()
const docClient1: Doc = new Doc()
const docClient2: Doc = new Doc()
const dummyAwareness: Awareness = new Awareness(docServer)
const textServer = docServer.getText('markdownContent')
const textClient1 = docClient1.getText('markdownContent')
const textClient2 = docClient2.getText('markdownContent')
textServer.insert(0, 'This is a test note')
textServer.observe(() =>
console.debug('textServer', new Date(), textServer.toString())
)
textClient1.observe(() =>
console.debug('textClient1', new Date(), textClient1.toString())
)
textClient2.observe(() =>
console.debug('textClient2', new Date(), textClient2.toString())
)
const transporterServerTo1 = new InMemoryMessageTransporter(
's>1',
docServer,
dummyAwareness
)
const transporterServerTo2 = new InMemoryMessageTransporter(
's>2',
docServer,
dummyAwareness
)
const transporterClient1 = new InMemoryMessageTransporter(
'1>s',
docClient1,
dummyAwareness
)
const transporterClient2 = new InMemoryMessageTransporter(
'2>s',
docClient2,
dummyAwareness
)
transporterServerTo1.on(String(MessageType.DOCUMENT_UPDATE), () =>
console.debug('Received DOCUMENT_UPDATE from client 1 to server')
)
transporterServerTo2.on(String(MessageType.DOCUMENT_UPDATE), () =>
console.debug('Received DOCUMENT_UPDATE from client 2 to server')
)
transporterClient1.on(String(MessageType.DOCUMENT_UPDATE), () =>
console.debug('Received DOCUMENT_UPDATE from server to client 1')
)
transporterClient2.on(String(MessageType.DOCUMENT_UPDATE), () =>
console.debug('Received DOCUMENT_UPDATE from server to client 2')
)
transporterServerTo1.on(
String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
() =>
console.debug(
'Received COMPLETE_DOCUMENT_STATE_ANSWER from client 1 to server'
)
)
transporterServerTo2.on(
String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
() =>
console.debug(
'Received COMPLETE_DOCUMENT_STATE_ANSWER from client 2 to server'
)
)
transporterClient1.on(
String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
() =>
console.debug(
'Received COMPLETE_DOCUMENT_STATE_ANSWER from server to client 1'
)
)
transporterClient2.on(
String(MessageType.COMPLETE_DOCUMENT_STATE_ANSWER),
() =>
console.debug(
'Received COMPLETE_DOCUMENT_STATE_ANSWER from server to client 2'
)
)
transporterServerTo1.on(
String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
() =>
console.debug(
'Received COMPLETE_DOCUMENT_STATE_REQUEST from client 1 to server'
)
)
transporterServerTo2.on(
String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
() =>
console.debug(
'Received COMPLETE_DOCUMENT_STATE_REQUEST from client 2 to server'
)
)
transporterClient1.on(
String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
() =>
console.debug(
'Received COMPLETE_DOCUMENT_STATE_REQUEST from server to client 1'
)
)
transporterClient2.on(
String(MessageType.COMPLETE_DOCUMENT_STATE_REQUEST),
() =>
console.debug(
'Received COMPLETE_DOCUMENT_STATE_REQUEST from server to client 2'
)
)
transporterClient1.on('ready', () => console.debug('Client 1 is ready'))
transporterClient2.on('ready', () => console.debug('Client 2 is ready'))
docServer.on('update', (update: Uint8Array, origin: unknown) => {
const message = encodeDocumentUpdateMessage(update)
if (origin !== transporterServerTo1) {
console.debug('YDoc on Server updated. Sending to Client 1')
transporterServerTo1.send(message)
}
if (origin !== transporterServerTo2) {
console.debug('YDoc on Server updated. Sending to Client 2')
transporterServerTo2.send(message)
}
})
docClient1.on('update', (update: Uint8Array, origin: unknown) => {
if (origin !== transporterClient1) {
console.debug('YDoc on client 1 updated. Sending to Server')
transporterClient1.send(encodeDocumentUpdateMessage(update))
}
})
docClient2.on('update', (update: Uint8Array, origin: unknown) => {
if (origin !== transporterClient2) {
console.debug('YDoc on client 2 updated. Sending to Server')
transporterClient2.send(encodeDocumentUpdateMessage(update))
}
})
transporterClient1.connect(transporterServerTo1)
transporterClient2.connect(transporterServerTo2)
textClient1.insert(0, 'test2')
textClient1.insert(0, 'test3')
textClient2.insert(0, 'test4')
expect(textServer.toString()).toBe('test4test3test2This is a test note')
expect(textClient1.toString()).toBe('test4test3test2This is a test note')
expect(textClient2.toString()).toBe('test4test3test2This is a test note')
dummyAwareness.destroy()
docServer.destroy()
docClient1.destroy()
docClient2.destroy()
}))

View file

@ -0,0 +1,112 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
applyAwarenessUpdateMessage,
encodeAwarenessUpdateMessage
} from './messages/awareness-update-message.js'
import { encodeCompleteDocumentStateAnswerMessage } from './messages/complete-document-state-answer-message.js'
import { encodeCompleteDocumentStateRequestMessage } from './messages/complete-document-state-request-message.js'
import { applyDocumentUpdateMessage } from './messages/document-update-message.js'
import { MessageType } from './messages/message-type.enum.js'
import { encodeReadyAnswerMessage } from './messages/ready-answer-message.js'
import { encodeReadyRequestMessage } from './messages/ready-request-message.js'
import { EventEmitter2 } from 'eventemitter2'
import { Decoder, readVarUint } from 'lib0/decoding'
import { Awareness } from 'y-protocols/awareness'
import { Doc } from 'yjs'
export type Handler = (decoder: Decoder) => void
export type MessageTransporterEvents = {
disconnected: () => void
connected: () => void
ready: () => void
synced: () => void
} & Partial<Record<MessageType, Handler>>
export abstract class YDocMessageTransporter extends EventEmitter2 {
private synced = false
protected constructor(
protected readonly doc: Doc,
protected readonly awareness: Awareness
) {
super()
this.on(String(MessageType.READY_REQUEST), () => {
this.send(encodeReadyAnswerMessage())
})
this.on(String(MessageType.READY_ANSWER), () => {
this.emit('ready')
})
this.bindDocumentSyncMessageEvents(doc)
}
public isSynced(): boolean {
return this.synced
}
protected onOpen(): void {
this.emit('connected')
this.send(encodeReadyRequestMessage())
}
protected onClose(): void {
this.emit('disconnected')
}
protected markAsSynced(): void {
if (!this.synced) {
this.synced = true
this.emit('synced')
}
}
protected decodeMessage(buffer: ArrayBuffer): void {
const data = new Uint8Array(buffer)
const decoder = new Decoder(data)
const messageType = readVarUint(decoder) as MessageType
switch (messageType) {
case MessageType.COMPLETE_DOCUMENT_STATE_REQUEST:
this.send(encodeCompleteDocumentStateAnswerMessage(this.doc, decoder))
break
case MessageType.DOCUMENT_UPDATE:
applyDocumentUpdateMessage(decoder, this.doc, this)
break
case MessageType.COMPLETE_DOCUMENT_STATE_ANSWER:
applyDocumentUpdateMessage(decoder, this.doc, this)
this.markAsSynced()
break
case MessageType.COMPLETE_AWARENESS_STATE_REQUEST:
this.send(
encodeAwarenessUpdateMessage(this.awareness, [
...this.awareness.getStates().keys()
])
)
break
case MessageType.AWARENESS_UPDATE:
applyAwarenessUpdateMessage(decoder, this.awareness, this)
}
this.emit(String(messageType), decoder)
}
private bindDocumentSyncMessageEvents(doc: Doc) {
this.on('ready', () => {
this.send(encodeCompleteDocumentStateRequestMessage(doc))
})
this.on('disconnected', () => (this.synced = false))
}
/**
* Sends binary data to the client. Closes the connection on errors.
*
* @param content The binary data to send.
*/
public abstract send(content: Uint8Array): void
public abstract disconnect(): void
}