mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-23 03:27:05 -04:00
fix(repository): Move backend code into subdirectory
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
86584e705f
commit
bf30cbcf48
272 changed files with 87 additions and 67 deletions
|
@ -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([]);
|
||||
});
|
||||
});
|
50
backend/src/realtime/realtime-note/realtime-note-store.ts
Normal file
50
backend/src/realtime/realtime-note/realtime-note-store.ts
Normal 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()];
|
||||
}
|
||||
}
|
29
backend/src/realtime/realtime-note/realtime-note.module.ts
Normal file
29
backend/src/realtime/realtime-note/realtime-note.module.ts
Normal 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 {}
|
247
backend/src/realtime/realtime-note/realtime-note.service.spec.ts
Normal file
247
backend/src/realtime/realtime-note/realtime-note.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
122
backend/src/realtime/realtime-note/realtime-note.service.ts
Normal file
122
backend/src/realtime/realtime-note/realtime-note.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
126
backend/src/realtime/realtime-note/realtime-note.spec.ts
Normal file
126
backend/src/realtime/realtime-note/realtime-note.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
162
backend/src/realtime/realtime-note/realtime-note.ts
Normal file
162
backend/src/realtime/realtime-note/realtime-note.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
|
@ -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' })),
|
||||
});
|
||||
}
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
}
|
|
@ -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());
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
49
backend/src/realtime/realtime-note/websocket-awareness.ts
Normal file
49
backend/src/realtime/realtime-note/websocket-awareness.ts
Normal 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));
|
||||
}
|
||||
}
|
195
backend/src/realtime/realtime-note/websocket-connection.spec.ts
Normal file
195
backend/src/realtime/realtime-note/websocket-connection.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
100
backend/src/realtime/realtime-note/websocket-connection.ts
Normal file
100
backend/src/realtime/realtime-note/websocket-connection.ts
Normal 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;
|
||||
}
|
||||
}
|
56
backend/src/realtime/realtime-note/websocket-doc.spec.ts
Normal file
56
backend/src/realtime/realtime-note/websocket-doc.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
71
backend/src/realtime/realtime-note/websocket-doc.ts
Normal file
71
backend/src/realtime/realtime-note/websocket-doc.ts
Normal 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();
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue