refactor: reimplement realtime-communication

This commit refactors a lot of things that are not easy to separate.
It replaces the binary protocol of y-protocols with json.
It introduces event based message processing.
It implements our own code mirror plugins for synchronisation of content and remote cursors

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-03-22 20:21:40 +01:00
parent 67cf1432b2
commit 3a06f84af1
110 changed files with 3920 additions and 2201 deletions

View file

@ -58,7 +58,6 @@
"file-type": "16.5.4",
"joi": "17.9.1",
"ldapauth-fork": "5.0.5",
"lib0": "0.2.73",
"minio": "7.0.33",
"mysql": "2.18.1",
"nest-router": "1.0.9",
@ -75,7 +74,6 @@
"sqlite3": "5.1.6",
"typeorm": "0.3.7",
"ws": "8.13.0",
"y-protocols": "1.0.5",
"yjs": "13.5.51"
},
"devDependencies": {

View file

@ -182,7 +182,10 @@ export class NotesService {
*/
async getNoteContent(note: Note): Promise<string> {
return (
this.realtimeNoteStore.find(note.id)?.getYDoc().getCurrentContent() ??
this.realtimeNoteStore
.find(note.id)
?.getRealtimeDoc()
.getCurrentContent() ??
(await this.revisionsService.getLatestRevision(note)).content
);
}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { generateRandomName } from './name-randomizer';
describe('name randomizer', () => {
it('generates random names', () => {
const firstName = generateRandomName();
const secondName = generateRandomName();
expect(firstName).not.toBe('');
expect(firstName).not.toBe(secondName);
});
});

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import lists from './random-words.json';
/**
* Generates a random names based on an adjective and a noun.
*
* @return the generated name
*/
export function generateRandomName(): string {
const adjective = generateRandomWord(lists.adjectives);
const things = generateRandomWord(lists.items);
return `${adjective} ${things}`;
}
function generateRandomWord(list: string[]): string {
const index = Math.floor(Math.random() * list.length);
const word = list[index];
return word.slice(0, 1).toUpperCase() + word.slice(1).toLowerCase();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: The author of https://www.randomlists.com/
SPDX-License-Identifier: CC0-1.0

View file

@ -0,0 +1,156 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
MessageTransporter,
MockedBackendMessageTransporter,
YDocSyncServerAdapter,
} from '@hedgedoc/commons';
import * as HedgeDocCommonsModule from '@hedgedoc/commons';
import { Mock } from 'ts-mockery';
import { Note } from '../../notes/note.entity';
import { User } from '../../users/user.entity';
import * as NameRandomizerModule from './random-word-lists/name-randomizer';
import { RealtimeConnection } from './realtime-connection';
import { RealtimeNote } from './realtime-note';
import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter';
import * as RealtimeUserStatusModule from './realtime-user-status-adapter';
jest.mock('./random-word-lists/name-randomizer');
jest.mock('./realtime-user-status-adapter');
jest.mock(
'@hedgedoc/commons',
() =>
({
...jest.requireActual('@hedgedoc/commons'),
// eslint-disable-next-line @typescript-eslint/naming-convention
YDocSyncServerAdapter: jest.fn(() =>
Mock.of<YDocSyncServerAdapter>({
setYDoc: jest.fn(),
}),
),
} as Record<string, unknown>),
);
describe('websocket connection', () => {
let mockedRealtimeNote: RealtimeNote;
let mockedUser: User;
let mockedMessageTransporter: MessageTransporter;
beforeEach(() => {
mockedRealtimeNote = new RealtimeNote(Mock.of<Note>({}), '');
mockedUser = Mock.of<User>({});
mockedMessageTransporter = new MockedBackendMessageTransporter('');
});
afterAll(() => {
jest.resetAllMocks();
jest.resetModules();
});
it('returns the correct transporter', () => {
const sut = new RealtimeConnection(
mockedMessageTransporter,
mockedUser,
mockedRealtimeNote,
);
expect(sut.getTransporter()).toBe(mockedMessageTransporter);
});
it('returns the correct realtime note', () => {
const sut = new RealtimeConnection(
mockedMessageTransporter,
mockedUser,
mockedRealtimeNote,
);
expect(sut.getRealtimeNote()).toBe(mockedRealtimeNote);
});
it('returns the correct realtime user status', () => {
const realtimeUserStatus = Mock.of<RealtimeUserStatusAdapter>();
jest
.spyOn(RealtimeUserStatusModule, 'RealtimeUserStatusAdapter')
.mockImplementation(() => realtimeUserStatus);
const sut = new RealtimeConnection(
mockedMessageTransporter,
mockedUser,
mockedRealtimeNote,
);
expect(sut.getRealtimeUserStateAdapter()).toBe(realtimeUserStatus);
});
it('returns the correct sync adapter', () => {
const yDocSyncServerAdapter = Mock.of<YDocSyncServerAdapter>({
setYDoc: jest.fn(),
});
jest
.spyOn(HedgeDocCommonsModule, 'YDocSyncServerAdapter')
.mockImplementation(() => yDocSyncServerAdapter);
const sut = new RealtimeConnection(
mockedMessageTransporter,
mockedUser,
mockedRealtimeNote,
);
expect(sut.getSyncAdapter()).toBe(yDocSyncServerAdapter);
});
it('removes the client from the note on transporter disconnect', () => {
const sut = new RealtimeConnection(
mockedMessageTransporter,
mockedUser,
mockedRealtimeNote,
);
const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient');
mockedMessageTransporter.disconnect();
expect(removeClientSpy).toHaveBeenCalledWith(sut);
});
it('saves the correct user', () => {
const sut = new RealtimeConnection(
mockedMessageTransporter,
mockedUser,
mockedRealtimeNote,
);
expect(sut.getUser()).toBe(mockedUser);
});
it('returns the correct username', () => {
const mockedUserWithUsername = Mock.of<User>({ displayName: 'MockUser' });
const sut = new RealtimeConnection(
mockedMessageTransporter,
mockedUserWithUsername,
mockedRealtimeNote,
);
expect(sut.getDisplayName()).toBe('MockUser');
});
it('returns a fallback if no username has been set', () => {
const randomName = 'I am a random name';
jest
.spyOn(NameRandomizerModule, 'generateRandomName')
.mockReturnValue(randomName);
const sut = new RealtimeConnection(
mockedMessageTransporter,
mockedUser,
mockedRealtimeNote,
);
expect(sut.getDisplayName()).toBe(randomName);
});
});

View file

@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageTransporter, YDocSyncServerAdapter } from '@hedgedoc/commons';
import { Logger } from '@nestjs/common';
import { User } from '../../users/user.entity';
import { generateRandomName } from './random-word-lists/name-randomizer';
import { RealtimeNote } from './realtime-note';
import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter';
/**
* Manages the connection to a specific client.
*/
export class RealtimeConnection {
protected readonly logger = new Logger(RealtimeConnection.name);
private readonly transporter: MessageTransporter;
private readonly yDocSyncAdapter: YDocSyncServerAdapter;
private readonly realtimeUserStateAdapter: RealtimeUserStatusAdapter;
private displayName: string;
/**
* Instantiates the connection wrapper.
*
* @param messageTransporter The message transporter that handles the communication with the client.
* @param user The user of the client
* @param realtimeNote The {@link RealtimeNote} that the client connected to.
* @throws Error if the socket is not open
*/
constructor(
messageTransporter: MessageTransporter,
private user: User | null,
private realtimeNote: RealtimeNote,
) {
this.displayName = user?.displayName ?? generateRandomName();
this.transporter = messageTransporter;
this.transporter.on('disconnected', () => {
realtimeNote.removeClient(this);
});
this.yDocSyncAdapter = new YDocSyncServerAdapter(this.transporter);
this.yDocSyncAdapter.setYDoc(realtimeNote.getRealtimeDoc());
this.realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
this.user?.username ?? null,
this.getDisplayName(),
this,
);
}
public getRealtimeUserStateAdapter(): RealtimeUserStatusAdapter {
return this.realtimeUserStateAdapter;
}
public getTransporter(): MessageTransporter {
return this.transporter;
}
public getUser(): User | null {
return this.user;
}
public getSyncAdapter(): YDocSyncServerAdapter {
return this.yDocSyncAdapter;
}
public getDisplayName(): string {
return this.displayName;
}
public getRealtimeNote(): RealtimeNote {
return this.realtimeNote;
}
}

View file

@ -9,9 +9,6 @@ import { Note } from '../../notes/note.entity';
import * as realtimeNoteModule from './realtime-note';
import { RealtimeNote } from './realtime-note';
import { RealtimeNoteStore } from './realtime-note-store';
import { mockRealtimeNote } from './test-utils/mock-realtime-note';
import { WebsocketAwareness } from './websocket-awareness';
import { WebsocketDoc } from './websocket-doc';
describe('RealtimeNoteStore', () => {
let realtimeNoteStore: RealtimeNoteStore;
@ -22,22 +19,21 @@ describe('RealtimeNoteStore', () => {
const mockedNoteId = 4711;
beforeEach(async () => {
jest.resetAllMocks();
jest.resetModules();
realtimeNoteStore = new RealtimeNoteStore();
mockedNote = Mock.of<Note>({ id: mockedNoteId });
mockedRealtimeNote = mockRealtimeNote(
mockedNote,
Mock.of<WebsocketDoc>(),
Mock.of<WebsocketAwareness>(),
);
mockedRealtimeNote = new RealtimeNote(mockedNote, '');
realtimeNoteConstructorSpy = jest
.spyOn(realtimeNoteModule, 'RealtimeNote')
.mockReturnValue(mockedRealtimeNote);
});
afterEach(() => {
jest.restoreAllMocks();
jest.resetAllMocks();
jest.resetModules();
});
it("can create a new realtime note if it doesn't exist yet", () => {
expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe(
mockedRealtimeNote,

View file

@ -14,17 +14,12 @@ import { RevisionsService } from '../../revisions/revisions.service';
import { RealtimeNote } from './realtime-note';
import { RealtimeNoteStore } from './realtime-note-store';
import { RealtimeNoteService } from './realtime-note.service';
import { mockAwareness } from './test-utils/mock-awareness';
import { mockRealtimeNote } from './test-utils/mock-realtime-note';
import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
import { WebsocketDoc } from './websocket-doc';
describe('RealtimeNoteService', () => {
const mockedContent = 'mockedContent';
const mockedNoteId = 4711;
let websocketDoc: WebsocketDoc;
let mockedNote: Note;
let mockedRealtimeNote: RealtimeNote;
let note: Note;
let realtimeNote: RealtimeNote;
let realtimeNoteService: RealtimeNoteService;
let revisionsService: RevisionsService;
let realtimeNoteStore: RealtimeNoteStore;
@ -46,7 +41,7 @@ describe('RealtimeNoteService', () => {
jest
.spyOn(revisionsService, 'getLatestRevision')
.mockImplementation((note: Note) =>
note === mockedNote && latestRevisionExists
note.id === mockedNoteId && latestRevisionExists
? Promise.resolve(
Mock.of<Revision>({
content: mockedContent,
@ -60,13 +55,8 @@ describe('RealtimeNoteService', () => {
jest.resetAllMocks();
jest.resetModules();
websocketDoc = mockWebsocketDoc();
mockedNote = Mock.of<Note>({ id: mockedNoteId });
mockedRealtimeNote = mockRealtimeNote(
mockedNote,
websocketDoc,
mockAwareness(),
);
note = Mock.of<Note>({ id: mockedNoteId });
realtimeNote = new RealtimeNote(note, mockedContent);
revisionsService = Mock.of<RevisionsService>({
getLatestRevision: jest.fn(),
@ -108,18 +98,15 @@ describe('RealtimeNoteService', () => {
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
.mockImplementation(() => realtimeNote);
mockedAppConfig.persistInterval = 0;
await expect(
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
).resolves.toBe(mockedRealtimeNote);
realtimeNoteService.getOrCreateRealtimeNote(note),
).resolves.toBe(realtimeNote);
expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId);
expect(realtimeNoteStore.create).toHaveBeenCalledWith(
mockedNote,
mockedContent,
);
expect(realtimeNoteStore.create).toHaveBeenCalledWith(note, mockedContent);
expect(setIntervalSpy).not.toHaveBeenCalled();
});
@ -129,10 +116,10 @@ describe('RealtimeNoteService', () => {
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
.mockImplementation(() => realtimeNote);
mockedAppConfig.persistInterval = 10;
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
await realtimeNoteService.getOrCreateRealtimeNote(note);
expect(setIntervalSpy).toHaveBeenCalledWith(
expect.any(Function),
@ -146,11 +133,11 @@ describe('RealtimeNoteService', () => {
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
.mockImplementation(() => realtimeNote);
mockedAppConfig.persistInterval = 10;
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
mockedRealtimeNote.emit('destroy');
await realtimeNoteService.getOrCreateRealtimeNote(note);
realtimeNote.emit('destroy');
expect(deleteIntervalSpy).toHaveBeenCalled();
expect(clearIntervalSpy).toHaveBeenCalled();
});
@ -162,7 +149,7 @@ describe('RealtimeNoteService', () => {
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
await expect(
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
realtimeNoteService.getOrCreateRealtimeNote(note),
).rejects.toBe(`Revision for note mockedNoteId not found.`);
expect(realtimeNoteStore.create).not.toHaveBeenCalled();
expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId);
@ -174,53 +161,46 @@ describe('RealtimeNoteService', () => {
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
.mockImplementation(() => realtimeNote);
await expect(
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
).resolves.toBe(mockedRealtimeNote);
realtimeNoteService.getOrCreateRealtimeNote(note),
).resolves.toBe(realtimeNote);
jest
.spyOn(realtimeNoteStore, 'find')
.mockImplementation(() => mockedRealtimeNote);
.mockImplementation(() => realtimeNote);
await expect(
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
).resolves.toBe(mockedRealtimeNote);
realtimeNoteService.getOrCreateRealtimeNote(note),
).resolves.toBe(realtimeNote);
expect(realtimeNoteStore.create).toHaveBeenCalledTimes(1);
});
it('saves a realtime note if it gets destroyed', async () => {
mockGetLatestRevision(true);
const mockedCurrentContent = 'mockedCurrentContent';
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
jest
.spyOn(websocketDoc, 'getCurrentContent')
.mockReturnValue(mockedCurrentContent);
.mockImplementation(() => realtimeNote);
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
await realtimeNoteService.getOrCreateRealtimeNote(note);
const createRevisionSpy = jest
.spyOn(revisionsService, 'createRevision')
.mockImplementation(() => Promise.resolve(Mock.of<Revision>()));
mockedRealtimeNote.emit('beforeDestroy');
expect(createRevisionSpy).toHaveBeenCalledWith(
mockedNote,
mockedCurrentContent,
);
realtimeNote.emit('beforeDestroy');
expect(createRevisionSpy).toHaveBeenCalledWith(note, mockedContent);
});
it('destroys every realtime note on application shutdown', () => {
jest
.spyOn(realtimeNoteStore, 'getAllRealtimeNotes')
.mockReturnValue([mockedRealtimeNote]);
.mockReturnValue([realtimeNote]);
const destroySpy = jest.spyOn(mockedRealtimeNote, 'destroy');
const destroySpy = jest.spyOn(realtimeNote, 'destroy');
realtimeNoteService.beforeApplicationShutdown();

View file

@ -42,7 +42,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
this.revisionsService
.createRevision(
realtimeNote.getNote(),
realtimeNote.getYDoc().getCurrentContent(),
realtimeNote.getRealtimeDoc().getCurrentContent(),
)
.catch((reason) => this.logger.error(reason));
}

View file

@ -3,39 +3,20 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
encodeDocumentDeletedMessage,
encodeMetadataUpdatedMessage,
} from '@hedgedoc/commons';
import { MessageType, RealtimeDoc } from '@hedgedoc/commons';
import * as hedgedocCommonsModule from '@hedgedoc/commons';
import { Mock } from 'ts-mockery';
import { Note } from '../../notes/note.entity';
import { RealtimeNote } from './realtime-note';
import { mockAwareness } from './test-utils/mock-awareness';
import { mockConnection } from './test-utils/mock-connection';
import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
import * as websocketAwarenessModule from './websocket-awareness';
import { WebsocketAwareness } from './websocket-awareness';
import * as websocketDocModule from './websocket-doc';
import { WebsocketDoc } from './websocket-doc';
import { MockConnectionBuilder } from './test-utils/mock-connection';
jest.mock('@hedgedoc/commons');
describe('realtime note', () => {
let mockedDoc: WebsocketDoc;
let mockedAwareness: WebsocketAwareness;
let mockedNote: Note;
beforeEach(() => {
jest.resetAllMocks();
jest.resetModules();
mockedDoc = mockWebsocketDoc();
mockedAwareness = mockAwareness();
jest
.spyOn(websocketDocModule, 'WebsocketDoc')
.mockImplementation(() => mockedDoc);
jest
.spyOn(websocketAwarenessModule, 'WebsocketAwareness')
.mockImplementation(() => mockedAwareness);
mockedNote = Mock.of<Note>({ id: 4711 });
});
@ -51,8 +32,7 @@ describe('realtime note', () => {
it('can connect and disconnect clients', () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
const client1 = mockConnection(true);
sut.addClient(client1);
const client1 = new MockConnectionBuilder(sut).build();
expect(sut.getConnections()).toStrictEqual([client1]);
expect(sut.hasConnections()).toBeTruthy();
sut.removeClient(client1);
@ -60,19 +40,22 @@ describe('realtime note', () => {
expect(sut.hasConnections()).toBeFalsy();
});
it('creates a y-doc and y-awareness', () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
expect(sut.getYDoc()).toBe(mockedDoc);
expect(sut.getAwareness()).toBe(mockedAwareness);
it('creates a y-doc', () => {
const initialContent = 'nothing';
const mockedDoc = new RealtimeDoc(initialContent);
const docSpy = jest
.spyOn(hedgedocCommonsModule, 'RealtimeDoc')
.mockReturnValue(mockedDoc);
const sut = new RealtimeNote(mockedNote, initialContent);
expect(docSpy).toHaveBeenCalledWith(initialContent);
expect(sut.getRealtimeDoc()).toBe(mockedDoc);
});
it('destroys y-doc and y-awareness on self-destruction', () => {
it('destroys y-doc on self-destruction', () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
const docDestroy = jest.spyOn(mockedDoc, 'destroy');
const awarenessDestroy = jest.spyOn(mockedAwareness, 'destroy');
const docDestroy = jest.spyOn(sut.getRealtimeDoc(), 'destroy');
sut.destroy();
expect(docDestroy).toHaveBeenCalled();
expect(awarenessDestroy).toHaveBeenCalled();
});
it('emits destroy event on destruction', async () => {
@ -94,33 +77,38 @@ describe('realtime note', () => {
it('announcePermissionChange to all clients', () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
const client1 = mockConnection(true);
sut.addClient(client1);
const client2 = mockConnection(true);
sut.addClient(client2);
const metadataMessage = encodeMetadataUpdatedMessage();
const client1 = new MockConnectionBuilder(sut).build();
const client2 = new MockConnectionBuilder(sut).build();
const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage');
const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage');
const metadataMessage = { type: MessageType.METADATA_UPDATED };
sut.announcePermissionChange();
expect(client1.send).toHaveBeenCalledWith(metadataMessage);
expect(client2.send).toHaveBeenCalledWith(metadataMessage);
expect(sendMessage1Spy).toHaveBeenCalledWith(metadataMessage);
expect(sendMessage2Spy).toHaveBeenCalledWith(metadataMessage);
sut.removeClient(client2);
sut.announcePermissionChange();
expect(client1.send).toHaveBeenCalledTimes(2);
expect(client2.send).toHaveBeenCalledTimes(1);
expect(sendMessage1Spy).toHaveBeenCalledTimes(2);
expect(sendMessage2Spy).toHaveBeenCalledTimes(1);
});
it('announceNoteDeletion to all clients', () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
const client1 = mockConnection(true);
sut.addClient(client1);
const client2 = mockConnection(true);
sut.addClient(client2);
const deletedMessage = encodeDocumentDeletedMessage();
const client1 = new MockConnectionBuilder(sut).build();
const client2 = new MockConnectionBuilder(sut).build();
const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage');
const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage');
const deletedMessage = { type: MessageType.DOCUMENT_DELETED };
sut.announceNoteDeletion();
expect(client1.send).toHaveBeenCalledWith(deletedMessage);
expect(client2.send).toHaveBeenCalledWith(deletedMessage);
expect(sendMessage1Spy).toHaveBeenCalledWith(deletedMessage);
expect(sendMessage2Spy).toHaveBeenCalledWith(deletedMessage);
sut.removeClient(client2);
sut.announceNoteDeletion();
expect(client1.send).toHaveBeenCalledTimes(2);
expect(client2.send).toHaveBeenCalledTimes(1);
expect(sendMessage1Spy).toHaveBeenNthCalledWith(2, deletedMessage);
expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, deletedMessage);
});
});

View file

@ -3,52 +3,51 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
encodeDocumentDeletedMessage,
encodeMetadataUpdatedMessage,
} from '@hedgedoc/commons';
import { Message, MessageType, RealtimeDoc } from '@hedgedoc/commons';
import { Logger } from '@nestjs/common';
import { EventEmitter2, EventMap } from 'eventemitter2';
import { Awareness } from 'y-protocols/awareness';
import { Note } from '../../notes/note.entity';
import { WebsocketAwareness } from './websocket-awareness';
import { WebsocketConnection } from './websocket-connection';
import { WebsocketDoc } from './websocket-doc';
import { RealtimeConnection } from './realtime-connection';
export interface MapType extends EventMap {
export interface RealtimeNoteEventMap extends EventMap {
destroy: () => void;
beforeDestroy: () => void;
clientAdded: (client: RealtimeConnection) => void;
clientRemoved: (client: RealtimeConnection) => void;
yDocUpdate: (update: number[], origin: unknown) => void;
}
/**
* Represents a note currently being edited by a number of clients.
*/
export class RealtimeNote extends EventEmitter2<MapType> {
export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
protected logger: Logger;
private readonly websocketDoc: WebsocketDoc;
private readonly websocketAwareness: WebsocketAwareness;
private readonly clients = new Set<WebsocketConnection>();
private readonly doc: RealtimeDoc;
private readonly clients = new Set<RealtimeConnection>();
private isClosing = false;
constructor(private readonly note: Note, initialContent: string) {
super();
this.logger = new Logger(`${RealtimeNote.name} ${note.id}`);
this.websocketDoc = new WebsocketDoc(this, initialContent);
this.websocketAwareness = new WebsocketAwareness(this);
this.logger.debug(`New realtime session for note ${note.id} created.`);
this.doc = new RealtimeDoc(initialContent);
this.logger.debug(
`New realtime session for note ${note.id} created. Length of initial content: ${initialContent.length} characters`,
);
}
/**
* Connects a new client to the note.
*
* For this purpose a {@link WebsocketConnection} is created and added to the client map.
* For this purpose a {@link RealtimeConnection} is created and added to the client map.
*
* @param client the websocket connection to the client
*/
public addClient(client: WebsocketConnection): void {
public addClient(client: RealtimeConnection): void {
this.clients.add(client);
this.logger.debug(`User '${client.getUsername()}' connected`);
this.logger.debug(`User '${client.getDisplayName()}' connected`);
this.emit('clientAdded', client);
}
/**
@ -56,13 +55,14 @@ export class RealtimeNote extends EventEmitter2<MapType> {
*
* @param {WebSocket} client The websocket client that disconnects.
*/
public removeClient(client: WebsocketConnection): void {
public removeClient(client: RealtimeConnection): void {
this.clients.delete(client);
this.logger.debug(
`User '${client.getUsername()}' disconnected. ${
`User '${client.getDisplayName()}' disconnected. ${
this.clients.size
} clients left.`,
);
this.emit('clientRemoved', client);
if (!this.hasConnections() && !this.isClosing) {
this.destroy();
}
@ -80,9 +80,8 @@ export class RealtimeNote extends EventEmitter2<MapType> {
this.logger.debug('Destroying realtime note.');
this.emit('beforeDestroy');
this.isClosing = true;
this.websocketDoc.destroy();
this.websocketAwareness.destroy();
this.clients.forEach((value) => value.disconnect());
this.doc.destroy();
this.clients.forEach((value) => value.getTransporter().disconnect());
this.emit('destroy');
}
@ -96,30 +95,21 @@ export class RealtimeNote extends EventEmitter2<MapType> {
}
/**
* Returns all {@link WebsocketConnection WebsocketConnections} currently hold by this note.
* Returns all {@link RealtimeConnection WebsocketConnections} currently hold by this note.
*
* @return an array of {@link WebsocketConnection WebsocketConnections}
* @return an array of {@link RealtimeConnection WebsocketConnections}
*/
public getConnections(): WebsocketConnection[] {
public getConnections(): RealtimeConnection[] {
return [...this.clients];
}
/**
* Get the {@link Doc YDoc} of the note.
* Get the {@link RealtimeDoc realtime note} of the note.
*
* @return the {@link Doc YDoc} of the note
* @return the {@link RealtimeDoc realtime note} of the note
*/
public getYDoc(): WebsocketDoc {
return this.websocketDoc;
}
/**
* Get the {@link Awareness YAwareness} of the note.
*
* @return the {@link Awareness YAwareness} of the note
*/
public getAwareness(): Awareness {
return this.websocketAwareness;
public getRealtimeDoc(): RealtimeDoc {
return this.doc;
}
/**
@ -135,14 +125,14 @@ export class RealtimeNote extends EventEmitter2<MapType> {
* Announce to all clients that the permissions of the note have been changed.
*/
public announcePermissionChange(): void {
this.sendToAllClients(encodeMetadataUpdatedMessage());
this.sendToAllClients({ type: MessageType.METADATA_UPDATED });
}
/**
* Announce to all clients that the note has been deleted.
*/
public announceNoteDeletion(): void {
this.sendToAllClients(encodeDocumentDeletedMessage());
this.sendToAllClients({ type: MessageType.DOCUMENT_DELETED });
}
/**
@ -150,9 +140,9 @@ export class RealtimeNote extends EventEmitter2<MapType> {
*
* @param {Uint8Array} content The binary message to broadcast
*/
private sendToAllClients(content: Uint8Array): void {
private sendToAllClients(content: Message<MessageType>): void {
this.getConnections().forEach((connection) => {
connection.send(content);
connection.getTransporter().sendMessage(content);
});
}
}

View file

@ -0,0 +1,229 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Message, MessageTransporter, MessageType } from '@hedgedoc/commons';
import { Mock } from 'ts-mockery';
import { Note } from '../../notes/note.entity';
import { RealtimeConnection } from './realtime-connection';
import { RealtimeNote } from './realtime-note';
import { MockConnectionBuilder } from './test-utils/mock-connection';
type SendMessageSpy = jest.SpyInstance<
void,
[Required<MessageTransporter['sendMessage']>]
>;
describe('realtime user status adapter', () => {
let client1: RealtimeConnection;
let client2: RealtimeConnection;
let client3: RealtimeConnection;
let client4: RealtimeConnection;
let sendMessage1Spy: SendMessageSpy;
let sendMessage2Spy: SendMessageSpy;
let sendMessage3Spy: SendMessageSpy;
let sendMessage4Spy: SendMessageSpy;
let realtimeNote: RealtimeNote;
const username1 = 'mock1';
const username2 = 'mock2';
const username3 = 'mock3';
const username4 = 'mock4';
beforeEach(() => {
realtimeNote = new RealtimeNote(
Mock.of<Note>({ id: 9876 }),
'mockedContent',
);
client1 = new MockConnectionBuilder(realtimeNote)
.withRealtimeUserState()
.withUsername(username1)
.build();
client2 = new MockConnectionBuilder(realtimeNote)
.withRealtimeUserState()
.withUsername(username2)
.build();
client3 = new MockConnectionBuilder(realtimeNote)
.withRealtimeUserState()
.withUsername(username3)
.build();
client4 = new MockConnectionBuilder(realtimeNote)
.withRealtimeUserState()
.withUsername(username4)
.build();
sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage');
sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage');
sendMessage3Spy = jest.spyOn(client3.getTransporter(), 'sendMessage');
sendMessage4Spy = jest.spyOn(client4.getTransporter(), 'sendMessage');
client1.getTransporter().sendReady();
client2.getTransporter().sendReady();
client3.getTransporter().sendReady();
//client 4 shouldn't be ready on purpose
});
it('can answer a state request', () => {
expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
client1.getTransporter().emit(MessageType.REALTIME_USER_STATE_REQUEST);
const expectedMessage1: Message<MessageType.REALTIME_USER_STATE_SET> = {
type: MessageType.REALTIME_USER_STATE_SET,
payload: [
{
active: true,
cursor: {
from: 0,
to: 0,
},
styleIndex: 1,
username: username2,
displayName: username2,
},
{
active: true,
cursor: {
from: 0,
to: 0,
},
styleIndex: 2,
username: username3,
displayName: username3,
},
],
};
expect(sendMessage1Spy).toHaveBeenNthCalledWith(1, expectedMessage1);
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
});
it('can save an cursor update', () => {
expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
const newFrom = Math.floor(Math.random() * 100);
const newTo = Math.floor(Math.random() * 100);
client1.getTransporter().emit(MessageType.REALTIME_USER_SINGLE_UPDATE, {
type: MessageType.REALTIME_USER_SINGLE_UPDATE,
payload: {
from: newFrom,
to: newTo,
},
});
const expectedMessage2: Message<MessageType.REALTIME_USER_STATE_SET> = {
type: MessageType.REALTIME_USER_STATE_SET,
payload: [
{
active: true,
cursor: {
from: newFrom,
to: newTo,
},
styleIndex: 0,
username: username1,
displayName: username1,
},
{
active: true,
cursor: {
from: 0,
to: 0,
},
styleIndex: 2,
username: username3,
displayName: username3,
},
],
};
const expectedMessage3: Message<MessageType.REALTIME_USER_STATE_SET> = {
type: MessageType.REALTIME_USER_STATE_SET,
payload: [
{
active: true,
cursor: {
from: newFrom,
to: newTo,
},
styleIndex: 0,
username: username1,
displayName: username1,
},
{
active: true,
cursor: {
from: 0,
to: 0,
},
styleIndex: 1,
username: username2,
displayName: username2,
},
],
};
expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, expectedMessage2);
expect(sendMessage3Spy).toHaveBeenNthCalledWith(1, expectedMessage3);
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
});
it('will inform other clients about removed client', () => {
expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
client2.getTransporter().disconnect();
const expectedMessage1: Message<MessageType.REALTIME_USER_STATE_SET> = {
type: MessageType.REALTIME_USER_STATE_SET,
payload: [
{
active: true,
cursor: {
from: 0,
to: 0,
},
styleIndex: 2,
username: username3,
displayName: username3,
},
],
};
const expectedMessage3: Message<MessageType.REALTIME_USER_STATE_SET> = {
type: MessageType.REALTIME_USER_STATE_SET,
payload: [
{
active: true,
cursor: {
from: 0,
to: 0,
},
styleIndex: 0,
username: username1,
displayName: username1,
},
],
};
expect(sendMessage1Spy).toHaveBeenNthCalledWith(1, expectedMessage1);
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
expect(sendMessage3Spy).toHaveBeenNthCalledWith(1, expectedMessage3);
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
});
});

View file

@ -0,0 +1,147 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageType, RealtimeUser } from '@hedgedoc/commons';
import { Listener } from 'eventemitter2';
import { RealtimeConnection } from './realtime-connection';
import { RealtimeNote } from './realtime-note';
/**
* Saves the current realtime status of a specific client and sends updates of changes to other clients.
*/
export class RealtimeUserStatusAdapter {
private readonly realtimeUser: RealtimeUser;
constructor(
username: string | null,
displayName: string,
private connection: RealtimeConnection,
) {
this.realtimeUser = this.createInitialRealtimeUserState(
username,
displayName,
connection.getRealtimeNote(),
);
this.bindRealtimeUserStateEvents(connection);
}
private createInitialRealtimeUserState(
username: string | null,
displayName: string,
realtimeNote: RealtimeNote,
): RealtimeUser {
return {
username: username,
displayName: displayName,
active: true,
styleIndex: this.findLeastUsedStyleIndex(
this.createStyleIndexToCountMap(realtimeNote),
),
cursor: {
from: 0,
to: 0,
},
};
}
private bindRealtimeUserStateEvents(connection: RealtimeConnection): void {
const realtimeNote = connection.getRealtimeNote();
const transporterMessagesListener = connection.getTransporter().on(
MessageType.REALTIME_USER_SINGLE_UPDATE,
(message) => {
this.realtimeUser.cursor = message.payload;
this.sendRealtimeUserStatusUpdateEvent(connection);
},
{ objectify: true },
) as Listener;
const transporterRequestMessageListener = connection.getTransporter().on(
MessageType.REALTIME_USER_STATE_REQUEST,
() => {
this.sendCompleteStateToClient(connection);
},
{ objectify: true },
) as Listener;
const clientRemoveListener = realtimeNote.on(
'clientRemoved',
(client: RealtimeConnection) => {
if (client === connection) {
this.sendRealtimeUserStatusUpdateEvent(connection);
}
},
{
objectify: true,
},
) as Listener;
connection.getTransporter().on('disconnected', () => {
transporterMessagesListener.off();
transporterRequestMessageListener.off();
clientRemoveListener.off();
});
}
private sendRealtimeUserStatusUpdateEvent(
exceptClient: RealtimeConnection,
): void {
this.collectAllConnectionsExcept(exceptClient).forEach(
this.sendCompleteStateToClient.bind(this),
);
}
private sendCompleteStateToClient(client: RealtimeConnection): void {
const payload = this.collectAllConnectionsExcept(client).map(
(client) => client.getRealtimeUserStateAdapter().realtimeUser,
);
client.getTransporter().sendMessage({
type: MessageType.REALTIME_USER_STATE_SET,
payload,
});
}
private collectAllConnectionsExcept(
exceptClient: RealtimeConnection,
): RealtimeConnection[] {
return this.connection
.getRealtimeNote()
.getConnections()
.filter(
(client) =>
client !== exceptClient && client.getTransporter().isReady(),
);
}
private findLeastUsedStyleIndex(map: Map<number, number>): number {
let leastUsedStyleIndex = 0;
let leastUsedStyleIndexCount = map.get(0) ?? 0;
for (let styleIndex = 0; styleIndex < 8; styleIndex++) {
const count = map.get(styleIndex) ?? 0;
if (count < leastUsedStyleIndexCount) {
leastUsedStyleIndexCount = count;
leastUsedStyleIndex = styleIndex;
}
}
return leastUsedStyleIndex;
}
private createStyleIndexToCountMap(
realtimeNote: RealtimeNote,
): Map<number, number> {
return realtimeNote
.getConnections()
.map(
(connection) =>
connection.getRealtimeUserStateAdapter().realtimeUser.styleIndex,
)
.reduce((map, styleIndex) => {
const count = (map.get(styleIndex) ?? 0) + 1;
map.set(styleIndex, count);
return map;
}, new Map<number, number>());
}
}

View file

@ -1,22 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Observable } from 'lib0/observable';
import { Mock } from 'ts-mockery';
import { WebsocketAwareness } from '../websocket-awareness';
class MockAwareness extends Observable<string> {
destroy(): void {
//intentionally left blank
}
}
/**
* Provides a partial mock for {@link WebsocketAwareness}.
*/
export function mockAwareness(): WebsocketAwareness {
return Mock.from<WebsocketAwareness>(new MockAwareness());
}

View file

@ -3,21 +3,61 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
MockedBackendMessageTransporter,
YDocSyncServerAdapter,
} from '@hedgedoc/commons';
import { Mock } from 'ts-mockery';
import { User } from '../../../users/user.entity';
import { WebsocketConnection } from '../websocket-connection';
import { RealtimeConnection } from '../realtime-connection';
import { RealtimeNote } from '../realtime-note';
import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter';
/**
* Provides a partial mock for {@link WebsocketConnection}.
*
* @param synced Defines the return value for the `isSynced` function.
*/
export function mockConnection(synced: boolean): WebsocketConnection {
return Mock.of<WebsocketConnection>({
isSynced: jest.fn(() => synced),
send: jest.fn(),
getUser: jest.fn(() => Mock.of<User>({ username: 'mockedUser' })),
getUsername: jest.fn(() => 'mocked user'),
});
export class MockConnectionBuilder {
private username = 'mock';
private includeRealtimeUserState = false;
constructor(private readonly realtimeNote: RealtimeNote) {}
public withUsername(username: string): this {
this.username = username;
return this;
}
public withRealtimeUserState(): this {
this.includeRealtimeUserState = true;
return this;
}
public build(): RealtimeConnection {
const transporter = new MockedBackendMessageTransporter('');
let realtimeUserStateAdapter: RealtimeUserStatusAdapter =
Mock.of<RealtimeUserStatusAdapter>();
const connection = Mock.of<RealtimeConnection>({
getUser: jest.fn(() => Mock.of<User>({ username: this.username })),
getDisplayName: jest.fn(() => this.username),
getSyncAdapter: jest.fn(() => Mock.of<YDocSyncServerAdapter>({})),
getTransporter: jest.fn(() => transporter),
getRealtimeUserStateAdapter: () => realtimeUserStateAdapter,
getRealtimeNote: () => this.realtimeNote,
});
transporter.on('disconnected', () =>
this.realtimeNote.removeClient(connection),
);
if (this.includeRealtimeUserState) {
realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
this.username,
this.username,
connection,
);
}
this.realtimeNote.addClient(connection);
return connection;
}
}

View file

@ -1,63 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EventEmitter2 } from 'eventemitter2';
import { Mock } from 'ts-mockery';
import { Note } from '../../../notes/note.entity';
import { MapType, RealtimeNote } from '../realtime-note';
import { WebsocketAwareness } from '../websocket-awareness';
import { WebsocketDoc } from '../websocket-doc';
import { mockAwareness } from './mock-awareness';
import { mockWebsocketDoc } from './mock-websocket-doc';
class MockRealtimeNote extends EventEmitter2<MapType> {
constructor(
private note: Note,
private doc: WebsocketDoc,
private awareness: WebsocketAwareness,
) {
super();
}
public getNote(): Note {
return this.note;
}
public getYDoc(): WebsocketDoc {
return this.doc;
}
public getAwareness(): WebsocketAwareness {
return this.awareness;
}
public removeClient(): void {
//left blank for mock
}
public destroy(): void {
//left blank for mock
}
}
/**
* Provides a partial mock for {@link RealtimeNote}
* @param doc Defines the return value for `getYDoc`
* @param awareness Defines the return value for `getAwareness`
*/
export function mockRealtimeNote(
note?: Note,
doc?: WebsocketDoc,
awareness?: WebsocketAwareness,
): RealtimeNote {
return Mock.from<RealtimeNote>(
new MockRealtimeNote(
note ?? Mock.of<Note>(),
doc ?? mockWebsocketDoc(),
awareness ?? mockAwareness(),
),
);
}

View file

@ -1,19 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Mock } from 'ts-mockery';
import { WebsocketDoc } from '../websocket-doc';
/**
* Provides a partial mock for {@link WebsocketDoc}.
*/
export function mockWebsocketDoc(): WebsocketDoc {
return Mock.of<WebsocketDoc>({
on: jest.fn(),
destroy: jest.fn(),
getCurrentContent: jest.fn(),
});
}

View file

@ -1,33 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { WebsocketTransporter } from '@hedgedoc/commons';
import { EventEmitter2 } from 'eventemitter2';
import { Mock } from 'ts-mockery';
class MockMessageTransporter extends EventEmitter2 {
setupWebsocket(): void {
//intentionally left blank
}
send(): void {
//intentionally left blank
}
isSynced(): boolean {
return false;
}
disconnect(): void {
//intentionally left blank
}
}
/**
* Provides a partial mock for {@link WebsocketTransporter}.
*/
export function mockWebsocketTransporter(): WebsocketTransporter {
return Mock.from<WebsocketTransporter>(new MockMessageTransporter());
}

View file

@ -1,62 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as hedgedocRealtimeModule from '@hedgedoc/commons';
import { Mock } from 'ts-mockery';
import { RealtimeNote } from './realtime-note';
import { mockConnection } from './test-utils/mock-connection';
import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness';
import { WebsocketConnection } from './websocket-connection';
import { WebsocketDoc } from './websocket-doc';
jest.mock('@hedgedoc/commons');
describe('websocket-awareness', () => {
it('distributes content updates to other synced clients', () => {
const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]);
const mockedEncodeUpdateFunction = jest.spyOn(
hedgedocRealtimeModule,
'encodeAwarenessUpdateMessage',
);
mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate);
const mockConnection1 = mockConnection(true);
const mockConnection2 = mockConnection(false);
const mockConnection3 = mockConnection(true);
const send1 = jest.spyOn(mockConnection1, 'send');
const send2 = jest.spyOn(mockConnection2, 'send');
const send3 = jest.spyOn(mockConnection3, 'send');
const realtimeNote = Mock.of<RealtimeNote>({
getYDoc(): WebsocketDoc {
return Mock.of<WebsocketDoc>({
on() {
//mocked
},
});
},
getConnections(): WebsocketConnection[] {
return [mockConnection1, mockConnection2, mockConnection3];
},
});
const websocketAwareness = new WebsocketAwareness(realtimeNote);
const mockUpdate: ClientIdUpdate = {
added: [1],
updated: [2],
removed: [3],
};
websocketAwareness.emit('update', [mockUpdate, mockConnection1]);
expect(send1).not.toHaveBeenCalled();
expect(send2).not.toHaveBeenCalled();
expect(send3).toHaveBeenCalledWith(mockEncodedUpdate);
expect(mockedEncodeUpdateFunction).toHaveBeenCalledWith(
websocketAwareness,
[1, 2, 3],
);
websocketAwareness.destroy();
});
});

View file

@ -1,49 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeAwarenessUpdateMessage } from '@hedgedoc/commons';
import { Awareness } from 'y-protocols/awareness';
import { RealtimeNote } from './realtime-note';
export interface ClientIdUpdate {
added: number[];
updated: number[];
removed: number[];
}
/**
* This is the implementation of {@link Awareness YAwareness} which includes additional handlers for message sending and receiving.
*/
export class WebsocketAwareness extends Awareness {
constructor(private realtimeNote: RealtimeNote) {
super(realtimeNote.getYDoc());
this.setLocalState(null);
this.on('update', this.distributeAwarenessUpdate.bind(this));
}
/**
* Distributes the given awareness changes to all clients.
*
* @param added Properties that were added to the awareness state
* @param updated Properties that were updated in the awareness state
* @param removed Properties that were removed from the awareness state
* @param origin An object that is used as reference for the origin of the update
*/
private distributeAwarenessUpdate(
{ added, updated, removed }: ClientIdUpdate,
origin: unknown,
): void {
const binaryUpdate = encodeAwarenessUpdateMessage(this, [
...added,
...updated,
...removed,
]);
this.realtimeNote
.getConnections()
.filter((client) => client !== origin && client.isSynced())
.forEach((client) => client.send(binaryUpdate));
}
}

View file

@ -1,219 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as hedgedocRealtimeModule from '@hedgedoc/commons';
import { WebsocketTransporter } from '@hedgedoc/commons';
import { Mock } from 'ts-mockery';
import WebSocket from 'ws';
import * as yProtocolsAwarenessModule from 'y-protocols/awareness';
import { Note } from '../../notes/note.entity';
import { User } from '../../users/user.entity';
import * as realtimeNoteModule from './realtime-note';
import { RealtimeNote } from './realtime-note';
import { mockAwareness } from './test-utils/mock-awareness';
import { mockRealtimeNote } from './test-utils/mock-realtime-note';
import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
import { mockWebsocketTransporter } from './test-utils/mock-websocket-transporter';
import * as websocketAwarenessModule from './websocket-awareness';
import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness';
import { WebsocketConnection } from './websocket-connection';
import * as websocketDocModule from './websocket-doc';
import { WebsocketDoc } from './websocket-doc';
import SpyInstance = jest.SpyInstance;
jest.mock('@hedgedoc/commons');
describe('websocket connection', () => {
let mockedDoc: WebsocketDoc;
let mockedAwareness: WebsocketAwareness;
let mockedRealtimeNote: RealtimeNote;
let mockedWebsocket: WebSocket;
let mockedUser: User;
let mockedWebsocketTransporter: WebsocketTransporter;
let removeAwarenessSpy: SpyInstance;
beforeEach(() => {
jest.resetAllMocks();
jest.resetModules();
mockedDoc = mockWebsocketDoc();
mockedAwareness = mockAwareness();
mockedRealtimeNote = mockRealtimeNote(
Mock.of<Note>(),
mockedDoc,
mockedAwareness,
);
mockedWebsocket = Mock.of<WebSocket>({});
mockedUser = Mock.of<User>({});
mockedWebsocketTransporter = mockWebsocketTransporter();
jest
.spyOn(realtimeNoteModule, 'RealtimeNote')
.mockImplementation(() => mockedRealtimeNote);
jest
.spyOn(websocketDocModule, 'WebsocketDoc')
.mockImplementation(() => mockedDoc);
jest
.spyOn(websocketAwarenessModule, 'WebsocketAwareness')
.mockImplementation(() => mockedAwareness);
jest
.spyOn(hedgedocRealtimeModule, 'WebsocketTransporter')
.mockImplementation(() => mockedWebsocketTransporter);
removeAwarenessSpy = jest
.spyOn(yProtocolsAwarenessModule, 'removeAwarenessStates')
.mockImplementation();
});
afterAll(() => {
jest.resetAllMocks();
jest.resetModules();
});
it('sets up the websocket in the constructor', () => {
const setupWebsocketSpy = jest.spyOn(
mockedWebsocketTransporter,
'setupWebsocket',
);
new WebsocketConnection(mockedWebsocket, mockedUser, mockedRealtimeNote);
expect(setupWebsocketSpy).toHaveBeenCalledWith(mockedWebsocket);
});
it('forwards sent messages to the transporter', () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
const sendFunctionSpy = jest.spyOn(mockedWebsocketTransporter, 'send');
const sendContent = new Uint8Array();
sut.send(sendContent);
expect(sendFunctionSpy).toHaveBeenCalledWith(sendContent);
});
it('forwards disconnect calls to the transporter', () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
const disconnectFunctionSpy = jest.spyOn(
mockedWebsocketTransporter,
'disconnect',
);
sut.disconnect();
expect(disconnectFunctionSpy).toHaveBeenCalled();
});
it('forwards isSynced checks to the transporter', () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
const isSyncedFunctionSpy = jest.spyOn(
mockedWebsocketTransporter,
'isSynced',
);
expect(sut.isSynced()).toBe(false);
isSyncedFunctionSpy.mockReturnValue(true);
expect(sut.isSynced()).toBe(true);
});
it('removes the client from the note on transporter disconnect', () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient');
mockedWebsocketTransporter.emit('disconnected');
expect(removeClientSpy).toHaveBeenCalledWith(sut);
});
it('remembers the controlled awareness-ids on awareness update', () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] };
mockedAwareness.emit('update', [update, sut]);
expect(sut.getControlledAwarenessIds()).toEqual(new Set([0]));
});
it("doesn't remembers the controlled awareness-ids of other connections on awareness update", () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] };
mockedAwareness.emit('update', [update, Mock.of<WebsocketConnection>()]);
expect(sut.getControlledAwarenessIds()).toEqual(new Set([]));
});
it('removes the controlled awareness ids on transport disconnect', () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] };
mockedAwareness.emit('update', [update, sut]);
mockedWebsocketTransporter.emit('disconnected');
expect(removeAwarenessSpy).toHaveBeenCalledWith(mockedAwareness, [0], sut);
});
it('saves the correct user', () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
expect(sut.getUser()).toBe(mockedUser);
});
it('returns the correct username', () => {
const mockedUserWithUsername = Mock.of<User>({ username: 'MockUser' });
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUserWithUsername,
mockedRealtimeNote,
);
expect(sut.getUsername()).toBe('MockUser');
});
it('returns a fallback if no username has been set', () => {
const sut = new WebsocketConnection(
mockedWebsocket,
mockedUser,
mockedRealtimeNote,
);
expect(sut.getUsername()).toBe('Guest');
});
});

View file

@ -1,104 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { WebsocketTransporter } from '@hedgedoc/commons';
import { Logger } from '@nestjs/common';
import WebSocket from 'ws';
import { Awareness, removeAwarenessStates } from 'y-protocols/awareness';
import { User } from '../../users/user.entity';
import { RealtimeNote } from './realtime-note';
import { ClientIdUpdate } from './websocket-awareness';
/**
* Manages the websocket connection to a specific client.
*/
export class WebsocketConnection {
protected readonly logger = new Logger(WebsocketConnection.name);
private controlledAwarenessIds: Set<number> = new Set();
private transporter: WebsocketTransporter;
/**
* Instantiates the websocket connection wrapper for a websocket connection.
*
* @param websocket The client's raw websocket.
* @param user The user of the client
* @param realtimeNote The {@link RealtimeNote} that the client connected to.
* @throws Error if the socket is not open
*/
constructor(
websocket: WebSocket,
private user: User | null,
realtimeNote: RealtimeNote,
) {
const awareness = realtimeNote.getAwareness();
this.transporter = new WebsocketTransporter(
realtimeNote.getYDoc(),
awareness,
);
this.transporter.on('disconnected', () => {
realtimeNote.removeClient(this);
});
this.transporter.setupWebsocket(websocket);
this.bindAwarenessMessageEvents(awareness);
}
/**
* Binds all additional events that are needed for awareness processing.
*/
private bindAwarenessMessageEvents(awareness: Awareness): void {
const callback = this.updateControlledAwarenessIds.bind(this);
awareness.on('update', callback);
this.transporter.on('disconnected', () => {
awareness.off('update', callback);
removeAwarenessStates(awareness, [...this.controlledAwarenessIds], this);
});
}
private updateControlledAwarenessIds(
{ added, removed }: ClientIdUpdate,
origin: WebsocketConnection,
): void {
if (origin === this) {
added.forEach((id) => this.controlledAwarenessIds.add(id));
removed.forEach((id) => this.controlledAwarenessIds.delete(id));
}
}
/**
* Defines if the current connection has received at least one full synchronisation.
*/
public isSynced(): boolean {
return this.transporter.isSynced();
}
/**
* Sends the given content to the client.
*
* @param content The content to send
*/
public send(content: Uint8Array): void {
this.transporter.send(content);
}
/**
* Stops the connection
*/
public disconnect(): void {
this.transporter.disconnect();
}
public getControlledAwarenessIds(): ReadonlySet<number> {
return this.controlledAwarenessIds;
}
public getUser(): User | null {
return this.user;
}
public getUsername(): string {
return this.getUser()?.username ?? 'Guest';
}
}

View file

@ -1,58 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as hedgedocRealtimeModule from '@hedgedoc/commons';
import { Mock } from 'ts-mockery';
import { RealtimeNote } from './realtime-note';
import { mockConnection } from './test-utils/mock-connection';
import { WebsocketConnection } from './websocket-connection';
import { WebsocketDoc } from './websocket-doc';
jest.mock('@hedgedoc/commons');
describe('websocket-doc', () => {
it('saves the initial content', () => {
const textContent = 'textContent';
const websocketDoc = new WebsocketDoc(Mock.of<RealtimeNote>(), textContent);
expect(websocketDoc.getCurrentContent()).toBe(textContent);
});
it('distributes content updates to other synced clients', () => {
const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]);
const mockedEncodeUpdateFunction = jest.spyOn(
hedgedocRealtimeModule,
'encodeDocumentUpdateMessage',
);
mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate);
const mockConnection1 = mockConnection(true);
const mockConnection2 = mockConnection(false);
const mockConnection3 = mockConnection(true);
const send1 = jest.spyOn(mockConnection1, 'send');
const send2 = jest.spyOn(mockConnection2, 'send');
const send3 = jest.spyOn(mockConnection3, 'send');
const realtimeNote = Mock.of<RealtimeNote>({
getConnections(): WebsocketConnection[] {
return [mockConnection1, mockConnection2, mockConnection3];
},
getYDoc(): WebsocketDoc {
return websocketDoc;
},
});
const websocketDoc = new WebsocketDoc(realtimeNote, '');
const mockUpdate = new Uint8Array([4, 5, 6, 7]);
websocketDoc.emit('update', [mockUpdate, mockConnection1]);
expect(send1).not.toHaveBeenCalled();
expect(send2).not.toHaveBeenCalled();
expect(send3).toHaveBeenCalledWith(mockEncodedUpdate);
expect(mockedEncodeUpdateFunction).toHaveBeenCalledWith(mockUpdate);
websocketDoc.destroy();
});
});

View file

@ -1,72 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
encodeDocumentUpdateMessage,
MARKDOWN_CONTENT_CHANNEL_NAME,
} from '@hedgedoc/commons';
import { Doc } from 'yjs';
import { RealtimeNote } from './realtime-note';
import { WebsocketConnection } from './websocket-connection';
/**
* This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving.
*/
export class WebsocketDoc extends Doc {
/**
* Creates a new WebsocketDoc instance.
*
* The new instance is filled with the given initial content and an event listener will be registered to handle
* updates to the doc.
*
* @param realtimeNote - the {@link RealtimeNote} handling this {@link Doc YDoc}
* @param initialContent - the initial content of the {@link Doc YDoc}
*/
constructor(private realtimeNote: RealtimeNote, initialContent: string) {
super();
this.initializeContent(initialContent);
this.bindUpdateEvent();
}
/**
* Binds the event that distributes updates in the current {@link Doc y-doc} to all clients.
*/
private bindUpdateEvent(): void {
this.on('update', (update: Uint8Array, origin: WebsocketConnection) => {
const clients = this.realtimeNote
.getConnections()
.filter((client) => client !== origin && client.isSynced());
if (clients.length > 0) {
clients.forEach((client) => {
client.send(encodeDocumentUpdateMessage(update));
});
}
});
}
/**
* Sets the {@link YDoc's Doc} content to include the initialContent.
*
* This message should only be called when a new {@link RealtimeNote } is created.
*
* @param initialContent - the initial content to set the {@link Doc YDoc's} content to.
* @private
*/
private initializeContent(initialContent: string): void {
this.getText(MARKDOWN_CONTENT_CHANNEL_NAME).insert(0, initialContent);
}
/**
* Gets the current content of the note as it's currently edited in realtime.
*
* Please be aware that the return of this method may be very quickly outdated.
*
* @return The current note content.
*/
public getCurrentContent(): string {
return this.getText(MARKDOWN_CONTENT_CHANNEL_NAME).toString();
}
}

View file

@ -40,15 +40,15 @@ import { Session } from '../../users/session.entity';
import { User } from '../../users/user.entity';
import { UsersModule } from '../../users/users.module';
import { UsersService } from '../../users/users.service';
import * as websocketConnectionModule from '../realtime-note/realtime-connection';
import { RealtimeConnection } from '../realtime-note/realtime-connection';
import { RealtimeNote } from '../realtime-note/realtime-note';
import { RealtimeNoteModule } from '../realtime-note/realtime-note.module';
import { RealtimeNoteService } from '../realtime-note/realtime-note.service';
import * as websocketConnectionModule from '../realtime-note/websocket-connection';
import { WebsocketConnection } from '../realtime-note/websocket-connection';
import * as extractNoteIdFromRequestUrlModule from './utils/extract-note-id-from-request-url';
import { WebsocketGateway } from './websocket.gateway';
import SpyInstance = jest.SpyInstance;
jest.mock('@hedgedoc/commons');
describe('Websocket gateway', () => {
let gateway: WebsocketGateway;
@ -57,10 +57,10 @@ describe('Websocket gateway', () => {
let notesService: NotesService;
let realtimeNoteService: RealtimeNoteService;
let permissionsService: PermissionsService;
let mockedWebsocketConnection: WebsocketConnection;
let mockedWebsocketConnection: RealtimeConnection;
let mockedWebsocket: WebSocket;
let mockedWebsocketCloseSpy: SpyInstance;
let addClientSpy: SpyInstance;
let mockedWebsocketCloseSpy: jest.SpyInstance;
let addClientSpy: jest.SpyInstance;
const mockedValidSessionCookie = 'mockedValidSessionCookie';
const mockedSessionIdWithUser = 'mockedSessionIdWithUser';
@ -231,9 +231,9 @@ describe('Websocket gateway', () => {
.spyOn(realtimeNoteService, 'getOrCreateRealtimeNote')
.mockReturnValue(Promise.resolve(mockedRealtimeNote));
mockedWebsocketConnection = Mock.of<WebsocketConnection>();
mockedWebsocketConnection = Mock.of<RealtimeConnection>();
jest
.spyOn(websocketConnectionModule, 'WebsocketConnection')
.spyOn(websocketConnectionModule, 'RealtimeConnection')
.mockReturnValue(mockedWebsocketConnection);
mockedWebsocket = Mock.of<WebSocket>({

View file

@ -3,6 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { WebsocketTransporter } from '@hedgedoc/commons';
import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets';
import { IncomingMessage } from 'http';
import WebSocket from 'ws';
@ -13,8 +14,8 @@ import { PermissionsService } from '../../permissions/permissions.service';
import { SessionService } from '../../session/session.service';
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 { WebsocketConnection } from '../realtime-note/websocket-connection';
import { extractNoteIdFromRequestUrl } from './utils/extract-note-id-from-request-url';
/**
@ -75,13 +76,17 @@ export class WebsocketGateway implements OnGatewayConnection {
const realtimeNote =
await this.realtimeNoteService.getOrCreateRealtimeNote(note);
const connection = new WebsocketConnection(
clientSocket,
const websocketTransporter = new WebsocketTransporter();
const connection = new RealtimeConnection(
websocketTransporter,
user,
realtimeNote,
);
websocketTransporter.setWebsocket(clientSocket);
realtimeNote.addClient(connection);
websocketTransporter.sendReady();
} catch (error: unknown) {
this.logger.error(
`Error occurred while initializing: ${(error as Error).message}`,

View file

@ -11,6 +11,7 @@
"baseUrl": "./",
"incremental": true,
"strict": true,
"strictPropertyInitialization": false
"strictPropertyInitialization": false,
"resolveJsonModule": true
}
}