fix(repository): Move backend code into subdirectory

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-10-02 20:10:32 +02:00 committed by David Mehren
parent 86584e705f
commit bf30cbcf48
272 changed files with 87 additions and 67 deletions

View file

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Mock } from 'ts-mockery';
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;
let mockedNote: Note;
let mockedRealtimeNote: RealtimeNote;
let realtimeNoteConstructorSpy: jest.SpyInstance;
const mockedContent = 'mockedContent';
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>(),
);
realtimeNoteConstructorSpy = jest
.spyOn(realtimeNoteModule, 'RealtimeNote')
.mockReturnValue(mockedRealtimeNote);
});
it("can create a new realtime note if it doesn't exist yet", () => {
expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe(
mockedRealtimeNote,
);
expect(realtimeNoteConstructorSpy).toHaveBeenCalledWith(
mockedNote,
mockedContent,
);
expect(realtimeNoteStore.find(mockedNoteId)).toBe(mockedRealtimeNote);
expect(realtimeNoteStore.getAllRealtimeNotes()).toStrictEqual([
mockedRealtimeNote,
]);
});
it('throws if a realtime note has already been created for the given note', () => {
expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe(
mockedRealtimeNote,
);
expect(() => realtimeNoteStore.create(mockedNote, mockedContent)).toThrow();
});
it('deletes a note if it gets destroyed', () => {
expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe(
mockedRealtimeNote,
);
mockedRealtimeNote.emit('destroy');
expect(realtimeNoteStore.find(mockedNoteId)).toBe(undefined);
expect(realtimeNoteStore.getAllRealtimeNotes()).toStrictEqual([]);
});
});

View file

@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Note } from '../../notes/note.entity';
import { RealtimeNote } from './realtime-note';
@Injectable()
export class RealtimeNoteStore {
private noteIdToRealtimeNote = new Map<number, RealtimeNote>();
/**
* Creates a new {@link RealtimeNote} for the given {@link Note} and memorizes it.
*
* @param note The note for which the realtime note should be created
* @param initialContent The initial content for the realtime note
* @throws Error if there is already an realtime note for the given note.
* @return The created realtime note
*/
public create(note: Note, initialContent: string): RealtimeNote {
if (this.noteIdToRealtimeNote.has(note.id)) {
throw new Error(`Realtime note for note ${note.id} already exists.`);
}
const realtimeNote = new RealtimeNote(note, initialContent);
realtimeNote.on('destroy', () => {
this.noteIdToRealtimeNote.delete(note.id);
});
this.noteIdToRealtimeNote.set(note.id, realtimeNote);
return realtimeNote;
}
/**
* Retrieves a {@link RealtimeNote} that is linked to the given {@link Note} id.
* @param noteId The id of the {@link Note}
* @return A {@link RealtimeNote} or {@code undefined} if no instance is existing.
*/
public find(noteId: number): RealtimeNote | undefined {
return this.noteIdToRealtimeNote.get(noteId);
}
/**
* Returns all registered {@link RealtimeNote realtime notes}.
*/
public getAllRealtimeNotes(): RealtimeNote[] {
return [...this.noteIdToRealtimeNote.values()];
}
}

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { LoggerModule } from '../../logger/logger.module';
import { PermissionsModule } from '../../permissions/permissions.module';
import { RevisionsModule } from '../../revisions/revisions.module';
import { SessionModule } from '../../session/session.module';
import { UsersModule } from '../../users/users.module';
import { RealtimeNoteStore } from './realtime-note-store';
import { RealtimeNoteService } from './realtime-note.service';
@Module({
imports: [
LoggerModule,
UsersModule,
PermissionsModule,
SessionModule,
RevisionsModule,
ScheduleModule.forRoot(),
],
exports: [RealtimeNoteService, RealtimeNoteStore],
providers: [RealtimeNoteService, RealtimeNoteStore],
})
export class RealtimeNoteModule {}

View file

@ -0,0 +1,247 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { SchedulerRegistry } from '@nestjs/schedule';
import { Mock } from 'ts-mockery';
import { AppConfig } from '../../config/app.config';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { Note } from '../../notes/note.entity';
import { Revision } from '../../revisions/revision.entity';
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 { waitForOtherPromisesToFinish } from './test-utils/wait-for-other-promises-to-finish';
import { WebsocketDoc } from './websocket-doc';
describe('RealtimeNoteService', () => {
const mockedContent = 'mockedContent';
const mockedNoteId = 4711;
let websocketDoc: WebsocketDoc;
let mockedNote: Note;
let mockedRealtimeNote: RealtimeNote;
let realtimeNoteService: RealtimeNoteService;
let revisionsService: RevisionsService;
let realtimeNoteStore: RealtimeNoteStore;
let consoleLoggerService: ConsoleLoggerService;
let mockedAppConfig: AppConfig;
let addIntervalSpy: jest.SpyInstance;
let setIntervalSpy: jest.SpyInstance;
let clearIntervalSpy: jest.SpyInstance;
let deleteIntervalSpy: jest.SpyInstance;
function mockGetLatestRevision(latestRevisionExists: boolean) {
jest
.spyOn(revisionsService, 'getLatestRevision')
.mockImplementation((note: Note) =>
note === mockedNote && latestRevisionExists
? Promise.resolve(
Mock.of<Revision>({
content: mockedContent,
}),
)
: Promise.reject('Revision for note mockedNoteId not found.'),
);
}
beforeEach(async () => {
jest.resetAllMocks();
jest.resetModules();
websocketDoc = mockWebsocketDoc();
mockedNote = Mock.of<Note>({ id: mockedNoteId });
mockedRealtimeNote = mockRealtimeNote(
mockedNote,
websocketDoc,
mockAwareness(),
);
revisionsService = Mock.of<RevisionsService>({
getLatestRevision: jest.fn(),
createRevision: jest.fn(),
});
consoleLoggerService = Mock.of<ConsoleLoggerService>({
error: jest.fn(),
});
realtimeNoteStore = Mock.of<RealtimeNoteStore>({
find: jest.fn(),
create: jest.fn(),
getAllRealtimeNotes: jest.fn(),
});
mockedAppConfig = Mock.of<AppConfig>({ persistInterval: 0 });
const schedulerRegistry = Mock.of<SchedulerRegistry>({
addInterval: jest.fn(),
deleteInterval: jest.fn(),
});
addIntervalSpy = jest.spyOn(schedulerRegistry, 'addInterval');
deleteIntervalSpy = jest.spyOn(schedulerRegistry, 'deleteInterval');
setIntervalSpy = jest.spyOn(global, 'setInterval');
clearIntervalSpy = jest.spyOn(global, 'clearInterval');
realtimeNoteService = new RealtimeNoteService(
revisionsService,
consoleLoggerService,
realtimeNoteStore,
schedulerRegistry,
mockedAppConfig,
);
});
it("creates a new realtime note if it doesn't exist yet", async () => {
mockGetLatestRevision(true);
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
mockedAppConfig.persistInterval = 0;
await expect(
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
).resolves.toBe(mockedRealtimeNote);
expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId);
expect(realtimeNoteStore.create).toHaveBeenCalledWith(
mockedNote,
mockedContent,
);
expect(setIntervalSpy).not.toHaveBeenCalled();
});
describe('with periodic timer', () => {
it('starts a timer if config has set an interval', async () => {
mockGetLatestRevision(true);
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
mockedAppConfig.persistInterval = 10;
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
expect(setIntervalSpy).toHaveBeenCalledWith(
expect.any(Function),
1000 * 60 * 10,
);
expect(addIntervalSpy).toHaveBeenCalled();
});
it('stops the timer if the realtime note gets destroyed', async () => {
mockGetLatestRevision(true);
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
mockedAppConfig.persistInterval = 10;
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
mockedRealtimeNote.emit('destroy');
expect(deleteIntervalSpy).toHaveBeenCalled();
expect(clearIntervalSpy).toHaveBeenCalled();
});
});
it("fails if the requested note doesn't exist", async () => {
mockGetLatestRevision(false);
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
await expect(
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
).rejects.toBe(`Revision for note mockedNoteId not found.`);
expect(realtimeNoteStore.create).not.toHaveBeenCalled();
expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId);
});
it("doesn't create a new realtime note if there is already one", async () => {
mockGetLatestRevision(true);
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
await expect(
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
).resolves.toBe(mockedRealtimeNote);
jest
.spyOn(realtimeNoteStore, 'find')
.mockImplementation(() => mockedRealtimeNote);
await expect(
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
).resolves.toBe(mockedRealtimeNote);
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);
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
const createRevisionSpy = jest
.spyOn(revisionsService, 'createRevision')
.mockImplementation(() => Promise.resolve(Mock.of<Revision>()));
mockedRealtimeNote.emit('beforeDestroy');
expect(createRevisionSpy).toHaveBeenCalledWith(
mockedNote,
mockedCurrentContent,
);
});
it('logs errors that occur during saving on destroy', async () => {
mockGetLatestRevision(true);
const mockedCurrentContent = 'mockedCurrentContent';
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
jest
.spyOn(realtimeNoteStore, 'create')
.mockImplementation(() => mockedRealtimeNote);
jest
.spyOn(websocketDoc, 'getCurrentContent')
.mockReturnValue(mockedCurrentContent);
jest
.spyOn(revisionsService, 'createRevision')
.mockImplementation(() => Promise.reject('mocked error'));
const logSpy = jest.spyOn(consoleLoggerService, 'error');
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
mockedRealtimeNote.emit('beforeDestroy');
await waitForOtherPromisesToFinish();
expect(logSpy).toHaveBeenCalled();
});
it('destroys every realtime note on application shutdown', () => {
jest
.spyOn(realtimeNoteStore, 'getAllRealtimeNotes')
.mockReturnValue([mockedRealtimeNote]);
const destroySpy = jest.spyOn(mockedRealtimeNote, 'destroy');
realtimeNoteService.beforeApplicationShutdown();
expect(destroySpy).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,122 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Optional } from '@mrdrogdrog/optional';
import { BeforeApplicationShutdown, Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { SchedulerRegistry } from '@nestjs/schedule';
import appConfiguration, { AppConfig } from '../../config/app.config';
import { NoteEvent } from '../../events';
import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { Note } from '../../notes/note.entity';
import { RevisionsService } from '../../revisions/revisions.service';
import { RealtimeNote } from './realtime-note';
import { RealtimeNoteStore } from './realtime-note-store';
@Injectable()
export class RealtimeNoteService implements BeforeApplicationShutdown {
constructor(
private revisionsService: RevisionsService,
private readonly logger: ConsoleLoggerService,
private realtimeNoteStore: RealtimeNoteStore,
private schedulerRegistry: SchedulerRegistry,
@Inject(appConfiguration.KEY)
private appConfig: AppConfig,
) {}
beforeApplicationShutdown(): void {
this.realtimeNoteStore
.getAllRealtimeNotes()
.forEach((realtimeNote) => realtimeNote.destroy());
}
/**
* Reads the current content from the given {@link RealtimeNote} and creates a new {@link Revision} for the linked {@link Note}.
*
* @param realtimeNote The realtime note for which a revision should be created
*/
public saveRealtimeNote(realtimeNote: RealtimeNote): void {
this.revisionsService
.createRevision(
realtimeNote.getNote(),
realtimeNote.getYDoc().getCurrentContent(),
)
.catch((reason) => this.logger.error(reason));
}
/**
* Creates or reuses a {@link RealtimeNote} that is handling the real time editing of the {@link Note} which is identified by the given note id.
* @param note The {@link Note} for which a {@link RealtimeNote realtime note} should be retrieved.
* @throws NotInDBError if note doesn't exist or has no revisions.
* @return A {@link RealtimeNote} that is linked to the given note.
*/
public async getOrCreateRealtimeNote(note: Note): Promise<RealtimeNote> {
return (
this.realtimeNoteStore.find(note.id) ??
(await this.createNewRealtimeNote(note))
);
}
/**
* Creates a new {@link RealtimeNote} for the given {@link Note}.
*
* @param note The note for which the realtime note should be created
* @throws NotInDBError if note doesn't exist or has no revisions.
* @return The created realtime note
*/
private async createNewRealtimeNote(note: Note): Promise<RealtimeNote> {
const initialContent = (await this.revisionsService.getLatestRevision(note))
.content;
const realtimeNote = this.realtimeNoteStore.create(note, initialContent);
realtimeNote.on('beforeDestroy', () => {
this.saveRealtimeNote(realtimeNote);
});
this.startPersistTimer(realtimeNote);
return realtimeNote;
}
/**
* Starts a timer that persists the realtime note in a periodic interval depending on the {@link AppConfig}.
* @param realtimeNote The realtime note for which the timer should be started
*/
private startPersistTimer(realtimeNote: RealtimeNote): void {
Optional.of(this.appConfig.persistInterval)
.filter((value) => value > 0)
.ifPresent((persistInterval) => {
const intervalId = setInterval(
this.saveRealtimeNote.bind(this, realtimeNote),
persistInterval * 60 * 1000,
);
this.schedulerRegistry.addInterval(
`periodic-persist-${realtimeNote.getNote().id}`,
intervalId,
);
realtimeNote.on('destroy', () => {
clearInterval(intervalId);
this.schedulerRegistry.deleteInterval(
`periodic-persist-${realtimeNote.getNote().id}`,
);
});
});
}
@OnEvent(NoteEvent.PERMISSION_CHANGE)
public handleNotePermissionChanged(noteId: Note['id']): void {
const realtimeNote = this.realtimeNoteStore.find(noteId);
if (realtimeNote) {
realtimeNote.announcePermissionChange();
}
}
@OnEvent(NoteEvent.DELETION)
public handleNoteDeleted(noteId: Note['id']): void {
const realtimeNote = this.realtimeNoteStore.find(noteId);
if (realtimeNote) {
realtimeNote.announceNoteDeletion();
}
}
}

View file

@ -0,0 +1,126 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
encodeDocumentDeletedMessage,
encodeMetadataUpdatedMessage,
} from '@hedgedoc/realtime';
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';
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 });
});
afterAll(() => {
jest.resetAllMocks();
jest.resetModules();
});
it('can return the given note', () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
expect(sut.getNote()).toBe(mockedNote);
});
it('can connect and disconnect clients', () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
const client1 = mockConnection(true);
sut.addClient(client1);
expect(sut.getConnections()).toStrictEqual([client1]);
expect(sut.hasConnections()).toBeTruthy();
sut.removeClient(client1);
expect(sut.getConnections()).toStrictEqual([]);
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('destroys y-doc and y-awareness on self-destruction', () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
const docDestroy = jest.spyOn(mockedDoc, 'destroy');
const awarenessDestroy = jest.spyOn(mockedAwareness, 'destroy');
sut.destroy();
expect(docDestroy).toHaveBeenCalled();
expect(awarenessDestroy).toHaveBeenCalled();
});
it('emits destroy event on destruction', async () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
const destroyPromise = new Promise<void>((resolve) => {
sut.once('destroy', () => {
resolve();
});
});
sut.destroy();
await expect(destroyPromise).resolves.not.toThrow();
});
it("doesn't destroy a destroyed note", () => {
const sut = new RealtimeNote(mockedNote, 'nothing');
sut.destroy();
expect(() => sut.destroy()).toThrow();
});
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();
sut.announcePermissionChange();
expect(client1.send).toHaveBeenCalledWith(metadataMessage);
expect(client2.send).toHaveBeenCalledWith(metadataMessage);
sut.removeClient(client2);
sut.announcePermissionChange();
expect(client1.send).toHaveBeenCalledTimes(2);
expect(client2.send).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();
sut.announceNoteDeletion();
expect(client1.send).toHaveBeenCalledWith(deletedMessage);
expect(client2.send).toHaveBeenCalledWith(deletedMessage);
sut.removeClient(client2);
sut.announceNoteDeletion();
expect(client1.send).toHaveBeenCalledTimes(2);
expect(client2.send).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,162 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
encodeDocumentDeletedMessage,
encodeMetadataUpdatedMessage,
} from '@hedgedoc/realtime';
import { Logger } from '@nestjs/common';
import { EventEmitter } from 'events';
import TypedEventEmitter, { EventMap } from 'typed-emitter';
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';
export type RealtimeNoteEvents = {
beforeDestroy: () => void;
destroy: () => void;
};
type TypedEventEmitterConstructor<T extends EventMap> =
new () => TypedEventEmitter<T>;
/**
* Represents a note currently being edited by a number of clients.
*/
export class RealtimeNote extends (EventEmitter as TypedEventEmitterConstructor<RealtimeNoteEvents>) {
protected logger: Logger;
private readonly websocketDoc: WebsocketDoc;
private readonly websocketAwareness: WebsocketAwareness;
private readonly clients = new Set<WebsocketConnection>();
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.`);
}
/**
* Connects a new client to the note.
*
* For this purpose a {@link WebsocketConnection} is created and added to the client map.
*
* @param client the websocket connection to the client
*/
public addClient(client: WebsocketConnection): void {
this.clients.add(client);
this.logger.debug(`User '${client.getUser().username}' connected`);
}
/**
* Disconnects the given websocket client while cleaning-up if it was the last user in the realtime note.
*
* @param {WebSocket} client The websocket client that disconnects.
*/
public removeClient(client: WebsocketConnection): void {
this.clients.delete(client);
this.logger.debug(
`User '${client.getUser().username}' disconnected. ${
this.clients.size
} clients left.`,
);
if (!this.hasConnections() && !this.isClosing) {
this.destroy();
}
}
/**
* Destroys the current realtime note by deleting the y-js doc and disconnecting all clients.
*
* @throws Error if note has already been destroyed
*/
public destroy(): void {
if (this.isClosing) {
throw new Error('Note already destroyed');
}
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.emit('destroy');
}
/**
* Checks if there's still clients connected to this note.
*
* @return {@code true} if there a still clinets connected, otherwise {@code false}
*/
public hasConnections(): boolean {
return this.clients.size !== 0;
}
/**
* Returns all {@link WebsocketConnection WebsocketConnections} currently hold by this note.
*
* @return an array of {@link WebsocketConnection WebsocketConnections}
*/
public getConnections(): WebsocketConnection[] {
return [...this.clients];
}
/**
* Get the {@link Doc YDoc} of the note.
*
* @return the {@link Doc YDoc} 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;
}
/**
* Get the {@link Note note} that is edited.
*
* @return the {@link Note note}
*/
public getNote(): Note {
return this.note;
}
/**
* Announce to all clients that the permissions of the note have been changed.
*/
public announcePermissionChange(): void {
this.sendToAllClients(encodeMetadataUpdatedMessage());
}
/**
* Announce to all clients that the note has been deleted.
*/
public announceNoteDeletion(): void {
this.sendToAllClients(encodeDocumentDeletedMessage());
}
/**
* Broadcasts the given content to all connected clients.
*
* @param {Uint8Array} content The binary message to broadcast
*/
private sendToAllClients(content: Uint8Array): void {
this.getConnections().forEach((connection) => {
connection.send(content);
});
}
}

View file

@ -0,0 +1,22 @@
/*
* 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

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Mock } from 'ts-mockery';
import { User } from '../../../users/user.entity';
import { WebsocketConnection } from '../websocket-connection';
/**
* 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' })),
});
}

View file

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EventEmitter } from 'events';
import { Mock } from 'ts-mockery';
import TypedEmitter from 'typed-emitter';
import { Note } from '../../../notes/note.entity';
import { RealtimeNote, RealtimeNoteEvents } 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 (EventEmitter as new () => TypedEmitter<RealtimeNoteEvents>) {
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

@ -0,0 +1,19 @@
/*
* 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

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { WebsocketTransporter } from '@hedgedoc/realtime';
import { MessageTransporterEvents } from '@hedgedoc/realtime/dist/mjs/y-doc-message-transporter';
import { EventEmitter } from 'events';
import { Mock } from 'ts-mockery';
import TypedEmitter from 'typed-emitter';
class MockMessageTransporter extends (EventEmitter as new () => TypedEmitter<MessageTransporterEvents>) {
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

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Waits until all other pending promises are processed.
*/
export async function waitForOtherPromisesToFinish(): Promise<void> {
return await new Promise((resolve) => process.nextTick(resolve));
}

View file

@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as hedgedocRealtimeModule from '@hedgedoc/realtime';
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';
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

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeAwarenessUpdateMessage } from '@hedgedoc/realtime';
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

@ -0,0 +1,195 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as hedgedocRealtimeModule from '@hedgedoc/realtime';
import { WebsocketTransporter } from '@hedgedoc/realtime';
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;
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);
});
});

View file

@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { WebsocketTransporter } from '@hedgedoc/realtime';
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,
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 {
return this.user;
}
}

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as hedgedocRealtimeModule from '@hedgedoc/realtime';
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';
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

@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { encodeDocumentUpdateMessage } from '@hedgedoc/realtime';
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 {
private static readonly channelName = 'markdownContent';
/**
* 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(WebsocketDoc.channelName).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(WebsocketDoc.channelName).toString();
}
}