mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-20 18:25:21 -04:00
refactor: reimplement realtime-communication
This commit refactors a lot of things that are not easy to separate. It replaces the binary protocol of y-protocols with json. It introduces event based message processing. It implements our own code mirror plugins for synchronisation of content and remote cursors Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
67cf1432b2
commit
3a06f84af1
110 changed files with 3920 additions and 2201 deletions
|
@ -58,7 +58,6 @@
|
|||
"file-type": "16.5.4",
|
||||
"joi": "17.9.1",
|
||||
"ldapauth-fork": "5.0.5",
|
||||
"lib0": "0.2.73",
|
||||
"minio": "7.0.33",
|
||||
"mysql": "2.18.1",
|
||||
"nest-router": "1.0.9",
|
||||
|
@ -75,7 +74,6 @@
|
|||
"sqlite3": "5.1.6",
|
||||
"typeorm": "0.3.7",
|
||||
"ws": "8.13.0",
|
||||
"y-protocols": "1.0.5",
|
||||
"yjs": "13.5.51"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -182,7 +182,10 @@ export class NotesService {
|
|||
*/
|
||||
async getNoteContent(note: Note): Promise<string> {
|
||||
return (
|
||||
this.realtimeNoteStore.find(note.id)?.getYDoc().getCurrentContent() ??
|
||||
this.realtimeNoteStore
|
||||
.find(note.id)
|
||||
?.getRealtimeDoc()
|
||||
.getCurrentContent() ??
|
||||
(await this.revisionsService.getLatestRevision(note)).content
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { generateRandomName } from './name-randomizer';
|
||||
|
||||
describe('name randomizer', () => {
|
||||
it('generates random names', () => {
|
||||
const firstName = generateRandomName();
|
||||
const secondName = generateRandomName();
|
||||
expect(firstName).not.toBe('');
|
||||
expect(firstName).not.toBe(secondName);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import lists from './random-words.json';
|
||||
|
||||
/**
|
||||
* Generates a random names based on an adjective and a noun.
|
||||
*
|
||||
* @return the generated name
|
||||
*/
|
||||
export function generateRandomName(): string {
|
||||
const adjective = generateRandomWord(lists.adjectives);
|
||||
const things = generateRandomWord(lists.items);
|
||||
return `${adjective} ${things}`;
|
||||
}
|
||||
|
||||
function generateRandomWord(list: string[]): string {
|
||||
const index = Math.floor(Math.random() * list.length);
|
||||
const word = list[index];
|
||||
return word.slice(0, 1).toUpperCase() + word.slice(1).toLowerCase();
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: The author of https://www.randomlists.com/
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
156
backend/src/realtime/realtime-note/realtime-connection.spec.ts
Normal file
156
backend/src/realtime/realtime-note/realtime-connection.spec.ts
Normal file
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
MessageTransporter,
|
||||
MockedBackendMessageTransporter,
|
||||
YDocSyncServerAdapter,
|
||||
} from '@hedgedoc/commons';
|
||||
import * as HedgeDocCommonsModule from '@hedgedoc/commons';
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
import { Note } from '../../notes/note.entity';
|
||||
import { User } from '../../users/user.entity';
|
||||
import * as NameRandomizerModule from './random-word-lists/name-randomizer';
|
||||
import { RealtimeConnection } from './realtime-connection';
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter';
|
||||
import * as RealtimeUserStatusModule from './realtime-user-status-adapter';
|
||||
|
||||
jest.mock('./random-word-lists/name-randomizer');
|
||||
jest.mock('./realtime-user-status-adapter');
|
||||
jest.mock(
|
||||
'@hedgedoc/commons',
|
||||
() =>
|
||||
({
|
||||
...jest.requireActual('@hedgedoc/commons'),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
YDocSyncServerAdapter: jest.fn(() =>
|
||||
Mock.of<YDocSyncServerAdapter>({
|
||||
setYDoc: jest.fn(),
|
||||
}),
|
||||
),
|
||||
} as Record<string, unknown>),
|
||||
);
|
||||
|
||||
describe('websocket connection', () => {
|
||||
let mockedRealtimeNote: RealtimeNote;
|
||||
let mockedUser: User;
|
||||
let mockedMessageTransporter: MessageTransporter;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedRealtimeNote = new RealtimeNote(Mock.of<Note>({}), '');
|
||||
mockedUser = Mock.of<User>({});
|
||||
|
||||
mockedMessageTransporter = new MockedBackendMessageTransporter('');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('returns the correct transporter', () => {
|
||||
const sut = new RealtimeConnection(
|
||||
mockedMessageTransporter,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
expect(sut.getTransporter()).toBe(mockedMessageTransporter);
|
||||
});
|
||||
|
||||
it('returns the correct realtime note', () => {
|
||||
const sut = new RealtimeConnection(
|
||||
mockedMessageTransporter,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
expect(sut.getRealtimeNote()).toBe(mockedRealtimeNote);
|
||||
});
|
||||
|
||||
it('returns the correct realtime user status', () => {
|
||||
const realtimeUserStatus = Mock.of<RealtimeUserStatusAdapter>();
|
||||
jest
|
||||
.spyOn(RealtimeUserStatusModule, 'RealtimeUserStatusAdapter')
|
||||
.mockImplementation(() => realtimeUserStatus);
|
||||
|
||||
const sut = new RealtimeConnection(
|
||||
mockedMessageTransporter,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
expect(sut.getRealtimeUserStateAdapter()).toBe(realtimeUserStatus);
|
||||
});
|
||||
|
||||
it('returns the correct sync adapter', () => {
|
||||
const yDocSyncServerAdapter = Mock.of<YDocSyncServerAdapter>({
|
||||
setYDoc: jest.fn(),
|
||||
});
|
||||
jest
|
||||
.spyOn(HedgeDocCommonsModule, 'YDocSyncServerAdapter')
|
||||
.mockImplementation(() => yDocSyncServerAdapter);
|
||||
|
||||
const sut = new RealtimeConnection(
|
||||
mockedMessageTransporter,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
expect(sut.getSyncAdapter()).toBe(yDocSyncServerAdapter);
|
||||
});
|
||||
|
||||
it('removes the client from the note on transporter disconnect', () => {
|
||||
const sut = new RealtimeConnection(
|
||||
mockedMessageTransporter,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient');
|
||||
|
||||
mockedMessageTransporter.disconnect();
|
||||
|
||||
expect(removeClientSpy).toHaveBeenCalledWith(sut);
|
||||
});
|
||||
|
||||
it('saves the correct user', () => {
|
||||
const sut = new RealtimeConnection(
|
||||
mockedMessageTransporter,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
expect(sut.getUser()).toBe(mockedUser);
|
||||
});
|
||||
|
||||
it('returns the correct username', () => {
|
||||
const mockedUserWithUsername = Mock.of<User>({ displayName: 'MockUser' });
|
||||
|
||||
const sut = new RealtimeConnection(
|
||||
mockedMessageTransporter,
|
||||
mockedUserWithUsername,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
expect(sut.getDisplayName()).toBe('MockUser');
|
||||
});
|
||||
|
||||
it('returns a fallback if no username has been set', () => {
|
||||
const randomName = 'I am a random name';
|
||||
|
||||
jest
|
||||
.spyOn(NameRandomizerModule, 'generateRandomName')
|
||||
.mockReturnValue(randomName);
|
||||
|
||||
const sut = new RealtimeConnection(
|
||||
mockedMessageTransporter,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
expect(sut.getDisplayName()).toBe(randomName);
|
||||
});
|
||||
});
|
76
backend/src/realtime/realtime-note/realtime-connection.ts
Normal file
76
backend/src/realtime/realtime-note/realtime-connection.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageTransporter, YDocSyncServerAdapter } from '@hedgedoc/commons';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { User } from '../../users/user.entity';
|
||||
import { generateRandomName } from './random-word-lists/name-randomizer';
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { RealtimeUserStatusAdapter } from './realtime-user-status-adapter';
|
||||
|
||||
/**
|
||||
* Manages the connection to a specific client.
|
||||
*/
|
||||
export class RealtimeConnection {
|
||||
protected readonly logger = new Logger(RealtimeConnection.name);
|
||||
private readonly transporter: MessageTransporter;
|
||||
private readonly yDocSyncAdapter: YDocSyncServerAdapter;
|
||||
private readonly realtimeUserStateAdapter: RealtimeUserStatusAdapter;
|
||||
|
||||
private displayName: string;
|
||||
|
||||
/**
|
||||
* Instantiates the connection wrapper.
|
||||
*
|
||||
* @param messageTransporter The message transporter that handles the communication with the client.
|
||||
* @param user The user of the client
|
||||
* @param realtimeNote The {@link RealtimeNote} that the client connected to.
|
||||
* @throws Error if the socket is not open
|
||||
*/
|
||||
constructor(
|
||||
messageTransporter: MessageTransporter,
|
||||
private user: User | null,
|
||||
private realtimeNote: RealtimeNote,
|
||||
) {
|
||||
this.displayName = user?.displayName ?? generateRandomName();
|
||||
this.transporter = messageTransporter;
|
||||
|
||||
this.transporter.on('disconnected', () => {
|
||||
realtimeNote.removeClient(this);
|
||||
});
|
||||
this.yDocSyncAdapter = new YDocSyncServerAdapter(this.transporter);
|
||||
this.yDocSyncAdapter.setYDoc(realtimeNote.getRealtimeDoc());
|
||||
this.realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
|
||||
this.user?.username ?? null,
|
||||
this.getDisplayName(),
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
public getRealtimeUserStateAdapter(): RealtimeUserStatusAdapter {
|
||||
return this.realtimeUserStateAdapter;
|
||||
}
|
||||
|
||||
public getTransporter(): MessageTransporter {
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
public getUser(): User | null {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
public getSyncAdapter(): YDocSyncServerAdapter {
|
||||
return this.yDocSyncAdapter;
|
||||
}
|
||||
|
||||
public getDisplayName(): string {
|
||||
return this.displayName;
|
||||
}
|
||||
|
||||
public getRealtimeNote(): RealtimeNote {
|
||||
return this.realtimeNote;
|
||||
}
|
||||
}
|
|
@ -9,9 +9,6 @@ import { Note } from '../../notes/note.entity';
|
|||
import * as realtimeNoteModule from './realtime-note';
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { RealtimeNoteStore } from './realtime-note-store';
|
||||
import { mockRealtimeNote } from './test-utils/mock-realtime-note';
|
||||
import { WebsocketAwareness } from './websocket-awareness';
|
||||
import { WebsocketDoc } from './websocket-doc';
|
||||
|
||||
describe('RealtimeNoteStore', () => {
|
||||
let realtimeNoteStore: RealtimeNoteStore;
|
||||
|
@ -22,22 +19,21 @@ describe('RealtimeNoteStore', () => {
|
|||
const mockedNoteId = 4711;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
|
||||
realtimeNoteStore = new RealtimeNoteStore();
|
||||
|
||||
mockedNote = Mock.of<Note>({ id: mockedNoteId });
|
||||
mockedRealtimeNote = mockRealtimeNote(
|
||||
mockedNote,
|
||||
Mock.of<WebsocketDoc>(),
|
||||
Mock.of<WebsocketAwareness>(),
|
||||
);
|
||||
mockedRealtimeNote = new RealtimeNote(mockedNote, '');
|
||||
realtimeNoteConstructorSpy = jest
|
||||
.spyOn(realtimeNoteModule, 'RealtimeNote')
|
||||
.mockReturnValue(mockedRealtimeNote);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it("can create a new realtime note if it doesn't exist yet", () => {
|
||||
expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe(
|
||||
mockedRealtimeNote,
|
||||
|
|
|
@ -14,17 +14,12 @@ import { RevisionsService } from '../../revisions/revisions.service';
|
|||
import { RealtimeNote } from './realtime-note';
|
||||
import { RealtimeNoteStore } from './realtime-note-store';
|
||||
import { RealtimeNoteService } from './realtime-note.service';
|
||||
import { mockAwareness } from './test-utils/mock-awareness';
|
||||
import { mockRealtimeNote } from './test-utils/mock-realtime-note';
|
||||
import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
|
||||
import { WebsocketDoc } from './websocket-doc';
|
||||
|
||||
describe('RealtimeNoteService', () => {
|
||||
const mockedContent = 'mockedContent';
|
||||
const mockedNoteId = 4711;
|
||||
let websocketDoc: WebsocketDoc;
|
||||
let mockedNote: Note;
|
||||
let mockedRealtimeNote: RealtimeNote;
|
||||
let note: Note;
|
||||
let realtimeNote: RealtimeNote;
|
||||
let realtimeNoteService: RealtimeNoteService;
|
||||
let revisionsService: RevisionsService;
|
||||
let realtimeNoteStore: RealtimeNoteStore;
|
||||
|
@ -46,7 +41,7 @@ describe('RealtimeNoteService', () => {
|
|||
jest
|
||||
.spyOn(revisionsService, 'getLatestRevision')
|
||||
.mockImplementation((note: Note) =>
|
||||
note === mockedNote && latestRevisionExists
|
||||
note.id === mockedNoteId && latestRevisionExists
|
||||
? Promise.resolve(
|
||||
Mock.of<Revision>({
|
||||
content: mockedContent,
|
||||
|
@ -60,13 +55,8 @@ describe('RealtimeNoteService', () => {
|
|||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
|
||||
websocketDoc = mockWebsocketDoc();
|
||||
mockedNote = Mock.of<Note>({ id: mockedNoteId });
|
||||
mockedRealtimeNote = mockRealtimeNote(
|
||||
mockedNote,
|
||||
websocketDoc,
|
||||
mockAwareness(),
|
||||
);
|
||||
note = Mock.of<Note>({ id: mockedNoteId });
|
||||
realtimeNote = new RealtimeNote(note, mockedContent);
|
||||
|
||||
revisionsService = Mock.of<RevisionsService>({
|
||||
getLatestRevision: jest.fn(),
|
||||
|
@ -108,18 +98,15 @@ describe('RealtimeNoteService', () => {
|
|||
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||
jest
|
||||
.spyOn(realtimeNoteStore, 'create')
|
||||
.mockImplementation(() => mockedRealtimeNote);
|
||||
.mockImplementation(() => realtimeNote);
|
||||
mockedAppConfig.persistInterval = 0;
|
||||
|
||||
await expect(
|
||||
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
||||
).resolves.toBe(mockedRealtimeNote);
|
||||
realtimeNoteService.getOrCreateRealtimeNote(note),
|
||||
).resolves.toBe(realtimeNote);
|
||||
|
||||
expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId);
|
||||
expect(realtimeNoteStore.create).toHaveBeenCalledWith(
|
||||
mockedNote,
|
||||
mockedContent,
|
||||
);
|
||||
expect(realtimeNoteStore.create).toHaveBeenCalledWith(note, mockedContent);
|
||||
expect(setIntervalSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -129,10 +116,10 @@ describe('RealtimeNoteService', () => {
|
|||
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||
jest
|
||||
.spyOn(realtimeNoteStore, 'create')
|
||||
.mockImplementation(() => mockedRealtimeNote);
|
||||
.mockImplementation(() => realtimeNote);
|
||||
mockedAppConfig.persistInterval = 10;
|
||||
|
||||
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
|
||||
await realtimeNoteService.getOrCreateRealtimeNote(note);
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
|
@ -146,11 +133,11 @@ describe('RealtimeNoteService', () => {
|
|||
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||
jest
|
||||
.spyOn(realtimeNoteStore, 'create')
|
||||
.mockImplementation(() => mockedRealtimeNote);
|
||||
.mockImplementation(() => realtimeNote);
|
||||
mockedAppConfig.persistInterval = 10;
|
||||
|
||||
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
|
||||
mockedRealtimeNote.emit('destroy');
|
||||
await realtimeNoteService.getOrCreateRealtimeNote(note);
|
||||
realtimeNote.emit('destroy');
|
||||
expect(deleteIntervalSpy).toHaveBeenCalled();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
@ -162,7 +149,7 @@ describe('RealtimeNoteService', () => {
|
|||
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||
|
||||
await expect(
|
||||
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
||||
realtimeNoteService.getOrCreateRealtimeNote(note),
|
||||
).rejects.toBe(`Revision for note mockedNoteId not found.`);
|
||||
expect(realtimeNoteStore.create).not.toHaveBeenCalled();
|
||||
expect(realtimeNoteStore.find).toHaveBeenCalledWith(mockedNoteId);
|
||||
|
@ -174,53 +161,46 @@ describe('RealtimeNoteService', () => {
|
|||
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||
jest
|
||||
.spyOn(realtimeNoteStore, 'create')
|
||||
.mockImplementation(() => mockedRealtimeNote);
|
||||
.mockImplementation(() => realtimeNote);
|
||||
|
||||
await expect(
|
||||
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
||||
).resolves.toBe(mockedRealtimeNote);
|
||||
realtimeNoteService.getOrCreateRealtimeNote(note),
|
||||
).resolves.toBe(realtimeNote);
|
||||
|
||||
jest
|
||||
.spyOn(realtimeNoteStore, 'find')
|
||||
.mockImplementation(() => mockedRealtimeNote);
|
||||
.mockImplementation(() => realtimeNote);
|
||||
|
||||
await expect(
|
||||
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
||||
).resolves.toBe(mockedRealtimeNote);
|
||||
realtimeNoteService.getOrCreateRealtimeNote(note),
|
||||
).resolves.toBe(realtimeNote);
|
||||
expect(realtimeNoteStore.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('saves a realtime note if it gets destroyed', async () => {
|
||||
mockGetLatestRevision(true);
|
||||
const mockedCurrentContent = 'mockedCurrentContent';
|
||||
|
||||
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||
jest
|
||||
.spyOn(realtimeNoteStore, 'create')
|
||||
.mockImplementation(() => mockedRealtimeNote);
|
||||
jest
|
||||
.spyOn(websocketDoc, 'getCurrentContent')
|
||||
.mockReturnValue(mockedCurrentContent);
|
||||
.mockImplementation(() => realtimeNote);
|
||||
|
||||
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
|
||||
await realtimeNoteService.getOrCreateRealtimeNote(note);
|
||||
|
||||
const createRevisionSpy = jest
|
||||
.spyOn(revisionsService, 'createRevision')
|
||||
.mockImplementation(() => Promise.resolve(Mock.of<Revision>()));
|
||||
|
||||
mockedRealtimeNote.emit('beforeDestroy');
|
||||
expect(createRevisionSpy).toHaveBeenCalledWith(
|
||||
mockedNote,
|
||||
mockedCurrentContent,
|
||||
);
|
||||
realtimeNote.emit('beforeDestroy');
|
||||
expect(createRevisionSpy).toHaveBeenCalledWith(note, mockedContent);
|
||||
});
|
||||
|
||||
it('destroys every realtime note on application shutdown', () => {
|
||||
jest
|
||||
.spyOn(realtimeNoteStore, 'getAllRealtimeNotes')
|
||||
.mockReturnValue([mockedRealtimeNote]);
|
||||
.mockReturnValue([realtimeNote]);
|
||||
|
||||
const destroySpy = jest.spyOn(mockedRealtimeNote, 'destroy');
|
||||
const destroySpy = jest.spyOn(realtimeNote, 'destroy');
|
||||
|
||||
realtimeNoteService.beforeApplicationShutdown();
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
|
|||
this.revisionsService
|
||||
.createRevision(
|
||||
realtimeNote.getNote(),
|
||||
realtimeNote.getYDoc().getCurrentContent(),
|
||||
realtimeNote.getRealtimeDoc().getCurrentContent(),
|
||||
)
|
||||
.catch((reason) => this.logger.error(reason));
|
||||
}
|
||||
|
|
|
@ -3,39 +3,20 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
encodeDocumentDeletedMessage,
|
||||
encodeMetadataUpdatedMessage,
|
||||
} from '@hedgedoc/commons';
|
||||
import { MessageType, RealtimeDoc } from '@hedgedoc/commons';
|
||||
import * as hedgedocCommonsModule from '@hedgedoc/commons';
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
import { Note } from '../../notes/note.entity';
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { mockAwareness } from './test-utils/mock-awareness';
|
||||
import { mockConnection } from './test-utils/mock-connection';
|
||||
import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
|
||||
import * as websocketAwarenessModule from './websocket-awareness';
|
||||
import { WebsocketAwareness } from './websocket-awareness';
|
||||
import * as websocketDocModule from './websocket-doc';
|
||||
import { WebsocketDoc } from './websocket-doc';
|
||||
import { MockConnectionBuilder } from './test-utils/mock-connection';
|
||||
|
||||
jest.mock('@hedgedoc/commons');
|
||||
|
||||
describe('realtime note', () => {
|
||||
let mockedDoc: WebsocketDoc;
|
||||
let mockedAwareness: WebsocketAwareness;
|
||||
let mockedNote: Note;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
mockedDoc = mockWebsocketDoc();
|
||||
mockedAwareness = mockAwareness();
|
||||
jest
|
||||
.spyOn(websocketDocModule, 'WebsocketDoc')
|
||||
.mockImplementation(() => mockedDoc);
|
||||
jest
|
||||
.spyOn(websocketAwarenessModule, 'WebsocketAwareness')
|
||||
.mockImplementation(() => mockedAwareness);
|
||||
|
||||
mockedNote = Mock.of<Note>({ id: 4711 });
|
||||
});
|
||||
|
||||
|
@ -51,8 +32,7 @@ describe('realtime note', () => {
|
|||
|
||||
it('can connect and disconnect clients', () => {
|
||||
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||
const client1 = mockConnection(true);
|
||||
sut.addClient(client1);
|
||||
const client1 = new MockConnectionBuilder(sut).build();
|
||||
expect(sut.getConnections()).toStrictEqual([client1]);
|
||||
expect(sut.hasConnections()).toBeTruthy();
|
||||
sut.removeClient(client1);
|
||||
|
@ -60,19 +40,22 @@ describe('realtime note', () => {
|
|||
expect(sut.hasConnections()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('creates a y-doc and y-awareness', () => {
|
||||
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||
expect(sut.getYDoc()).toBe(mockedDoc);
|
||||
expect(sut.getAwareness()).toBe(mockedAwareness);
|
||||
it('creates a y-doc', () => {
|
||||
const initialContent = 'nothing';
|
||||
const mockedDoc = new RealtimeDoc(initialContent);
|
||||
const docSpy = jest
|
||||
.spyOn(hedgedocCommonsModule, 'RealtimeDoc')
|
||||
.mockReturnValue(mockedDoc);
|
||||
const sut = new RealtimeNote(mockedNote, initialContent);
|
||||
expect(docSpy).toHaveBeenCalledWith(initialContent);
|
||||
expect(sut.getRealtimeDoc()).toBe(mockedDoc);
|
||||
});
|
||||
|
||||
it('destroys y-doc and y-awareness on self-destruction', () => {
|
||||
it('destroys y-doc on self-destruction', () => {
|
||||
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||
const docDestroy = jest.spyOn(mockedDoc, 'destroy');
|
||||
const awarenessDestroy = jest.spyOn(mockedAwareness, 'destroy');
|
||||
const docDestroy = jest.spyOn(sut.getRealtimeDoc(), 'destroy');
|
||||
sut.destroy();
|
||||
expect(docDestroy).toHaveBeenCalled();
|
||||
expect(awarenessDestroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits destroy event on destruction', async () => {
|
||||
|
@ -94,33 +77,38 @@ describe('realtime note', () => {
|
|||
|
||||
it('announcePermissionChange to all clients', () => {
|
||||
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||
const client1 = mockConnection(true);
|
||||
sut.addClient(client1);
|
||||
const client2 = mockConnection(true);
|
||||
sut.addClient(client2);
|
||||
const metadataMessage = encodeMetadataUpdatedMessage();
|
||||
|
||||
const client1 = new MockConnectionBuilder(sut).build();
|
||||
const client2 = new MockConnectionBuilder(sut).build();
|
||||
|
||||
const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage');
|
||||
const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage');
|
||||
|
||||
const metadataMessage = { type: MessageType.METADATA_UPDATED };
|
||||
sut.announcePermissionChange();
|
||||
expect(client1.send).toHaveBeenCalledWith(metadataMessage);
|
||||
expect(client2.send).toHaveBeenCalledWith(metadataMessage);
|
||||
expect(sendMessage1Spy).toHaveBeenCalledWith(metadataMessage);
|
||||
expect(sendMessage2Spy).toHaveBeenCalledWith(metadataMessage);
|
||||
sut.removeClient(client2);
|
||||
sut.announcePermissionChange();
|
||||
expect(client1.send).toHaveBeenCalledTimes(2);
|
||||
expect(client2.send).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage1Spy).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessage2Spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('announceNoteDeletion to all clients', () => {
|
||||
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||
const client1 = mockConnection(true);
|
||||
sut.addClient(client1);
|
||||
const client2 = mockConnection(true);
|
||||
sut.addClient(client2);
|
||||
const deletedMessage = encodeDocumentDeletedMessage();
|
||||
const client1 = new MockConnectionBuilder(sut).build();
|
||||
const client2 = new MockConnectionBuilder(sut).build();
|
||||
|
||||
const sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage');
|
||||
const sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage');
|
||||
|
||||
const deletedMessage = { type: MessageType.DOCUMENT_DELETED };
|
||||
sut.announceNoteDeletion();
|
||||
expect(client1.send).toHaveBeenCalledWith(deletedMessage);
|
||||
expect(client2.send).toHaveBeenCalledWith(deletedMessage);
|
||||
expect(sendMessage1Spy).toHaveBeenCalledWith(deletedMessage);
|
||||
expect(sendMessage2Spy).toHaveBeenCalledWith(deletedMessage);
|
||||
sut.removeClient(client2);
|
||||
sut.announceNoteDeletion();
|
||||
expect(client1.send).toHaveBeenCalledTimes(2);
|
||||
expect(client2.send).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage1Spy).toHaveBeenNthCalledWith(2, deletedMessage);
|
||||
expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, deletedMessage);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,52 +3,51 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
encodeDocumentDeletedMessage,
|
||||
encodeMetadataUpdatedMessage,
|
||||
} from '@hedgedoc/commons';
|
||||
import { Message, MessageType, RealtimeDoc } from '@hedgedoc/commons';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { EventEmitter2, EventMap } from 'eventemitter2';
|
||||
import { Awareness } from 'y-protocols/awareness';
|
||||
|
||||
import { Note } from '../../notes/note.entity';
|
||||
import { WebsocketAwareness } from './websocket-awareness';
|
||||
import { WebsocketConnection } from './websocket-connection';
|
||||
import { WebsocketDoc } from './websocket-doc';
|
||||
import { RealtimeConnection } from './realtime-connection';
|
||||
|
||||
export interface MapType extends EventMap {
|
||||
export interface RealtimeNoteEventMap extends EventMap {
|
||||
destroy: () => void;
|
||||
beforeDestroy: () => void;
|
||||
clientAdded: (client: RealtimeConnection) => void;
|
||||
clientRemoved: (client: RealtimeConnection) => void;
|
||||
|
||||
yDocUpdate: (update: number[], origin: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a note currently being edited by a number of clients.
|
||||
*/
|
||||
export class RealtimeNote extends EventEmitter2<MapType> {
|
||||
export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
|
||||
protected logger: Logger;
|
||||
private readonly websocketDoc: WebsocketDoc;
|
||||
private readonly websocketAwareness: WebsocketAwareness;
|
||||
private readonly clients = new Set<WebsocketConnection>();
|
||||
private readonly doc: RealtimeDoc;
|
||||
private readonly clients = new Set<RealtimeConnection>();
|
||||
private isClosing = false;
|
||||
|
||||
constructor(private readonly note: Note, initialContent: string) {
|
||||
super();
|
||||
this.logger = new Logger(`${RealtimeNote.name} ${note.id}`);
|
||||
this.websocketDoc = new WebsocketDoc(this, initialContent);
|
||||
this.websocketAwareness = new WebsocketAwareness(this);
|
||||
this.logger.debug(`New realtime session for note ${note.id} created.`);
|
||||
this.doc = new RealtimeDoc(initialContent);
|
||||
this.logger.debug(
|
||||
`New realtime session for note ${note.id} created. Length of initial content: ${initialContent.length} characters`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects a new client to the note.
|
||||
*
|
||||
* For this purpose a {@link WebsocketConnection} is created and added to the client map.
|
||||
* For this purpose a {@link RealtimeConnection} is created and added to the client map.
|
||||
*
|
||||
* @param client the websocket connection to the client
|
||||
*/
|
||||
public addClient(client: WebsocketConnection): void {
|
||||
public addClient(client: RealtimeConnection): void {
|
||||
this.clients.add(client);
|
||||
this.logger.debug(`User '${client.getUsername()}' connected`);
|
||||
this.logger.debug(`User '${client.getDisplayName()}' connected`);
|
||||
this.emit('clientAdded', client);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -56,13 +55,14 @@ export class RealtimeNote extends EventEmitter2<MapType> {
|
|||
*
|
||||
* @param {WebSocket} client The websocket client that disconnects.
|
||||
*/
|
||||
public removeClient(client: WebsocketConnection): void {
|
||||
public removeClient(client: RealtimeConnection): void {
|
||||
this.clients.delete(client);
|
||||
this.logger.debug(
|
||||
`User '${client.getUsername()}' disconnected. ${
|
||||
`User '${client.getDisplayName()}' disconnected. ${
|
||||
this.clients.size
|
||||
} clients left.`,
|
||||
);
|
||||
this.emit('clientRemoved', client);
|
||||
if (!this.hasConnections() && !this.isClosing) {
|
||||
this.destroy();
|
||||
}
|
||||
|
@ -80,9 +80,8 @@ export class RealtimeNote extends EventEmitter2<MapType> {
|
|||
this.logger.debug('Destroying realtime note.');
|
||||
this.emit('beforeDestroy');
|
||||
this.isClosing = true;
|
||||
this.websocketDoc.destroy();
|
||||
this.websocketAwareness.destroy();
|
||||
this.clients.forEach((value) => value.disconnect());
|
||||
this.doc.destroy();
|
||||
this.clients.forEach((value) => value.getTransporter().disconnect());
|
||||
this.emit('destroy');
|
||||
}
|
||||
|
||||
|
@ -96,30 +95,21 @@ export class RealtimeNote extends EventEmitter2<MapType> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns all {@link WebsocketConnection WebsocketConnections} currently hold by this note.
|
||||
* Returns all {@link RealtimeConnection WebsocketConnections} currently hold by this note.
|
||||
*
|
||||
* @return an array of {@link WebsocketConnection WebsocketConnections}
|
||||
* @return an array of {@link RealtimeConnection WebsocketConnections}
|
||||
*/
|
||||
public getConnections(): WebsocketConnection[] {
|
||||
public getConnections(): RealtimeConnection[] {
|
||||
return [...this.clients];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link Doc YDoc} of the note.
|
||||
* Get the {@link RealtimeDoc realtime note} of the note.
|
||||
*
|
||||
* @return the {@link Doc YDoc} of the note
|
||||
* @return the {@link RealtimeDoc realtime note} of the note
|
||||
*/
|
||||
public getYDoc(): WebsocketDoc {
|
||||
return this.websocketDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link Awareness YAwareness} of the note.
|
||||
*
|
||||
* @return the {@link Awareness YAwareness} of the note
|
||||
*/
|
||||
public getAwareness(): Awareness {
|
||||
return this.websocketAwareness;
|
||||
public getRealtimeDoc(): RealtimeDoc {
|
||||
return this.doc;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -135,14 +125,14 @@ export class RealtimeNote extends EventEmitter2<MapType> {
|
|||
* Announce to all clients that the permissions of the note have been changed.
|
||||
*/
|
||||
public announcePermissionChange(): void {
|
||||
this.sendToAllClients(encodeMetadataUpdatedMessage());
|
||||
this.sendToAllClients({ type: MessageType.METADATA_UPDATED });
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce to all clients that the note has been deleted.
|
||||
*/
|
||||
public announceNoteDeletion(): void {
|
||||
this.sendToAllClients(encodeDocumentDeletedMessage());
|
||||
this.sendToAllClients({ type: MessageType.DOCUMENT_DELETED });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -150,9 +140,9 @@ export class RealtimeNote extends EventEmitter2<MapType> {
|
|||
*
|
||||
* @param {Uint8Array} content The binary message to broadcast
|
||||
*/
|
||||
private sendToAllClients(content: Uint8Array): void {
|
||||
private sendToAllClients(content: Message<MessageType>): void {
|
||||
this.getConnections().forEach((connection) => {
|
||||
connection.send(content);
|
||||
connection.getTransporter().sendMessage(content);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Message, MessageTransporter, MessageType } from '@hedgedoc/commons';
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
import { Note } from '../../notes/note.entity';
|
||||
import { RealtimeConnection } from './realtime-connection';
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { MockConnectionBuilder } from './test-utils/mock-connection';
|
||||
|
||||
type SendMessageSpy = jest.SpyInstance<
|
||||
void,
|
||||
[Required<MessageTransporter['sendMessage']>]
|
||||
>;
|
||||
|
||||
describe('realtime user status adapter', () => {
|
||||
let client1: RealtimeConnection;
|
||||
let client2: RealtimeConnection;
|
||||
let client3: RealtimeConnection;
|
||||
let client4: RealtimeConnection;
|
||||
|
||||
let sendMessage1Spy: SendMessageSpy;
|
||||
let sendMessage2Spy: SendMessageSpy;
|
||||
let sendMessage3Spy: SendMessageSpy;
|
||||
let sendMessage4Spy: SendMessageSpy;
|
||||
|
||||
let realtimeNote: RealtimeNote;
|
||||
|
||||
const username1 = 'mock1';
|
||||
const username2 = 'mock2';
|
||||
const username3 = 'mock3';
|
||||
const username4 = 'mock4';
|
||||
|
||||
beforeEach(() => {
|
||||
realtimeNote = new RealtimeNote(
|
||||
Mock.of<Note>({ id: 9876 }),
|
||||
'mockedContent',
|
||||
);
|
||||
client1 = new MockConnectionBuilder(realtimeNote)
|
||||
.withRealtimeUserState()
|
||||
.withUsername(username1)
|
||||
.build();
|
||||
client2 = new MockConnectionBuilder(realtimeNote)
|
||||
.withRealtimeUserState()
|
||||
.withUsername(username2)
|
||||
.build();
|
||||
client3 = new MockConnectionBuilder(realtimeNote)
|
||||
.withRealtimeUserState()
|
||||
.withUsername(username3)
|
||||
.build();
|
||||
client4 = new MockConnectionBuilder(realtimeNote)
|
||||
.withRealtimeUserState()
|
||||
.withUsername(username4)
|
||||
.build();
|
||||
|
||||
sendMessage1Spy = jest.spyOn(client1.getTransporter(), 'sendMessage');
|
||||
sendMessage2Spy = jest.spyOn(client2.getTransporter(), 'sendMessage');
|
||||
sendMessage3Spy = jest.spyOn(client3.getTransporter(), 'sendMessage');
|
||||
sendMessage4Spy = jest.spyOn(client4.getTransporter(), 'sendMessage');
|
||||
|
||||
client1.getTransporter().sendReady();
|
||||
client2.getTransporter().sendReady();
|
||||
client3.getTransporter().sendReady();
|
||||
//client 4 shouldn't be ready on purpose
|
||||
});
|
||||
|
||||
it('can answer a state request', () => {
|
||||
expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
client1.getTransporter().emit(MessageType.REALTIME_USER_STATE_REQUEST);
|
||||
|
||||
const expectedMessage1: Message<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
type: MessageType.REALTIME_USER_STATE_SET,
|
||||
payload: [
|
||||
{
|
||||
active: true,
|
||||
cursor: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
styleIndex: 1,
|
||||
username: username2,
|
||||
displayName: username2,
|
||||
},
|
||||
{
|
||||
active: true,
|
||||
cursor: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
styleIndex: 2,
|
||||
username: username3,
|
||||
displayName: username3,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(sendMessage1Spy).toHaveBeenNthCalledWith(1, expectedMessage1);
|
||||
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('can save an cursor update', () => {
|
||||
expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
const newFrom = Math.floor(Math.random() * 100);
|
||||
const newTo = Math.floor(Math.random() * 100);
|
||||
|
||||
client1.getTransporter().emit(MessageType.REALTIME_USER_SINGLE_UPDATE, {
|
||||
type: MessageType.REALTIME_USER_SINGLE_UPDATE,
|
||||
payload: {
|
||||
from: newFrom,
|
||||
to: newTo,
|
||||
},
|
||||
});
|
||||
|
||||
const expectedMessage2: Message<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
type: MessageType.REALTIME_USER_STATE_SET,
|
||||
payload: [
|
||||
{
|
||||
active: true,
|
||||
cursor: {
|
||||
from: newFrom,
|
||||
to: newTo,
|
||||
},
|
||||
styleIndex: 0,
|
||||
username: username1,
|
||||
displayName: username1,
|
||||
},
|
||||
{
|
||||
active: true,
|
||||
cursor: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
styleIndex: 2,
|
||||
username: username3,
|
||||
displayName: username3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const expectedMessage3: Message<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
type: MessageType.REALTIME_USER_STATE_SET,
|
||||
payload: [
|
||||
{
|
||||
active: true,
|
||||
cursor: {
|
||||
from: newFrom,
|
||||
to: newTo,
|
||||
},
|
||||
styleIndex: 0,
|
||||
username: username1,
|
||||
displayName: username1,
|
||||
},
|
||||
{
|
||||
active: true,
|
||||
cursor: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
styleIndex: 1,
|
||||
username: username2,
|
||||
displayName: username2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage2Spy).toHaveBeenNthCalledWith(1, expectedMessage2);
|
||||
expect(sendMessage3Spy).toHaveBeenNthCalledWith(1, expectedMessage3);
|
||||
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('will inform other clients about removed client', () => {
|
||||
expect(sendMessage1Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage3Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
client2.getTransporter().disconnect();
|
||||
|
||||
const expectedMessage1: Message<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
type: MessageType.REALTIME_USER_STATE_SET,
|
||||
payload: [
|
||||
{
|
||||
active: true,
|
||||
cursor: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
styleIndex: 2,
|
||||
username: username3,
|
||||
displayName: username3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const expectedMessage3: Message<MessageType.REALTIME_USER_STATE_SET> = {
|
||||
type: MessageType.REALTIME_USER_STATE_SET,
|
||||
payload: [
|
||||
{
|
||||
active: true,
|
||||
cursor: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
styleIndex: 0,
|
||||
username: username1,
|
||||
displayName: username1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(sendMessage1Spy).toHaveBeenNthCalledWith(1, expectedMessage1);
|
||||
expect(sendMessage2Spy).toHaveBeenCalledTimes(0);
|
||||
expect(sendMessage3Spy).toHaveBeenNthCalledWith(1, expectedMessage3);
|
||||
expect(sendMessage4Spy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MessageType, RealtimeUser } from '@hedgedoc/commons';
|
||||
import { Listener } from 'eventemitter2';
|
||||
|
||||
import { RealtimeConnection } from './realtime-connection';
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
|
||||
/**
|
||||
* Saves the current realtime status of a specific client and sends updates of changes to other clients.
|
||||
*/
|
||||
export class RealtimeUserStatusAdapter {
|
||||
private readonly realtimeUser: RealtimeUser;
|
||||
|
||||
constructor(
|
||||
username: string | null,
|
||||
displayName: string,
|
||||
private connection: RealtimeConnection,
|
||||
) {
|
||||
this.realtimeUser = this.createInitialRealtimeUserState(
|
||||
username,
|
||||
displayName,
|
||||
connection.getRealtimeNote(),
|
||||
);
|
||||
this.bindRealtimeUserStateEvents(connection);
|
||||
}
|
||||
|
||||
private createInitialRealtimeUserState(
|
||||
username: string | null,
|
||||
displayName: string,
|
||||
realtimeNote: RealtimeNote,
|
||||
): RealtimeUser {
|
||||
return {
|
||||
username: username,
|
||||
displayName: displayName,
|
||||
active: true,
|
||||
styleIndex: this.findLeastUsedStyleIndex(
|
||||
this.createStyleIndexToCountMap(realtimeNote),
|
||||
),
|
||||
cursor: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private bindRealtimeUserStateEvents(connection: RealtimeConnection): void {
|
||||
const realtimeNote = connection.getRealtimeNote();
|
||||
const transporterMessagesListener = connection.getTransporter().on(
|
||||
MessageType.REALTIME_USER_SINGLE_UPDATE,
|
||||
(message) => {
|
||||
this.realtimeUser.cursor = message.payload;
|
||||
this.sendRealtimeUserStatusUpdateEvent(connection);
|
||||
},
|
||||
{ objectify: true },
|
||||
) as Listener;
|
||||
|
||||
const transporterRequestMessageListener = connection.getTransporter().on(
|
||||
MessageType.REALTIME_USER_STATE_REQUEST,
|
||||
() => {
|
||||
this.sendCompleteStateToClient(connection);
|
||||
},
|
||||
{ objectify: true },
|
||||
) as Listener;
|
||||
|
||||
const clientRemoveListener = realtimeNote.on(
|
||||
'clientRemoved',
|
||||
(client: RealtimeConnection) => {
|
||||
if (client === connection) {
|
||||
this.sendRealtimeUserStatusUpdateEvent(connection);
|
||||
}
|
||||
},
|
||||
{
|
||||
objectify: true,
|
||||
},
|
||||
) as Listener;
|
||||
|
||||
connection.getTransporter().on('disconnected', () => {
|
||||
transporterMessagesListener.off();
|
||||
transporterRequestMessageListener.off();
|
||||
clientRemoveListener.off();
|
||||
});
|
||||
}
|
||||
|
||||
private sendRealtimeUserStatusUpdateEvent(
|
||||
exceptClient: RealtimeConnection,
|
||||
): void {
|
||||
this.collectAllConnectionsExcept(exceptClient).forEach(
|
||||
this.sendCompleteStateToClient.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
private sendCompleteStateToClient(client: RealtimeConnection): void {
|
||||
const payload = this.collectAllConnectionsExcept(client).map(
|
||||
(client) => client.getRealtimeUserStateAdapter().realtimeUser,
|
||||
);
|
||||
|
||||
client.getTransporter().sendMessage({
|
||||
type: MessageType.REALTIME_USER_STATE_SET,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
private collectAllConnectionsExcept(
|
||||
exceptClient: RealtimeConnection,
|
||||
): RealtimeConnection[] {
|
||||
return this.connection
|
||||
.getRealtimeNote()
|
||||
.getConnections()
|
||||
.filter(
|
||||
(client) =>
|
||||
client !== exceptClient && client.getTransporter().isReady(),
|
||||
);
|
||||
}
|
||||
|
||||
private findLeastUsedStyleIndex(map: Map<number, number>): number {
|
||||
let leastUsedStyleIndex = 0;
|
||||
let leastUsedStyleIndexCount = map.get(0) ?? 0;
|
||||
for (let styleIndex = 0; styleIndex < 8; styleIndex++) {
|
||||
const count = map.get(styleIndex) ?? 0;
|
||||
if (count < leastUsedStyleIndexCount) {
|
||||
leastUsedStyleIndexCount = count;
|
||||
leastUsedStyleIndex = styleIndex;
|
||||
}
|
||||
}
|
||||
return leastUsedStyleIndex;
|
||||
}
|
||||
|
||||
private createStyleIndexToCountMap(
|
||||
realtimeNote: RealtimeNote,
|
||||
): Map<number, number> {
|
||||
return realtimeNote
|
||||
.getConnections()
|
||||
.map(
|
||||
(connection) =>
|
||||
connection.getRealtimeUserStateAdapter().realtimeUser.styleIndex,
|
||||
)
|
||||
.reduce((map, styleIndex) => {
|
||||
const count = (map.get(styleIndex) ?? 0) + 1;
|
||||
map.set(styleIndex, count);
|
||||
return map;
|
||||
}, new Map<number, number>());
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Observable } from 'lib0/observable';
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
import { WebsocketAwareness } from '../websocket-awareness';
|
||||
|
||||
class MockAwareness extends Observable<string> {
|
||||
destroy(): void {
|
||||
//intentionally left blank
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a partial mock for {@link WebsocketAwareness}.
|
||||
*/
|
||||
export function mockAwareness(): WebsocketAwareness {
|
||||
return Mock.from<WebsocketAwareness>(new MockAwareness());
|
||||
}
|
|
@ -3,21 +3,61 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
MockedBackendMessageTransporter,
|
||||
YDocSyncServerAdapter,
|
||||
} from '@hedgedoc/commons';
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { WebsocketConnection } from '../websocket-connection';
|
||||
import { RealtimeConnection } from '../realtime-connection';
|
||||
import { RealtimeNote } from '../realtime-note';
|
||||
import { RealtimeUserStatusAdapter } from '../realtime-user-status-adapter';
|
||||
|
||||
/**
|
||||
* Provides a partial mock for {@link WebsocketConnection}.
|
||||
*
|
||||
* @param synced Defines the return value for the `isSynced` function.
|
||||
*/
|
||||
export function mockConnection(synced: boolean): WebsocketConnection {
|
||||
return Mock.of<WebsocketConnection>({
|
||||
isSynced: jest.fn(() => synced),
|
||||
send: jest.fn(),
|
||||
getUser: jest.fn(() => Mock.of<User>({ username: 'mockedUser' })),
|
||||
getUsername: jest.fn(() => 'mocked user'),
|
||||
});
|
||||
export class MockConnectionBuilder {
|
||||
private username = 'mock';
|
||||
private includeRealtimeUserState = false;
|
||||
|
||||
constructor(private readonly realtimeNote: RealtimeNote) {}
|
||||
|
||||
public withUsername(username: string): this {
|
||||
this.username = username;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withRealtimeUserState(): this {
|
||||
this.includeRealtimeUserState = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): RealtimeConnection {
|
||||
const transporter = new MockedBackendMessageTransporter('');
|
||||
let realtimeUserStateAdapter: RealtimeUserStatusAdapter =
|
||||
Mock.of<RealtimeUserStatusAdapter>();
|
||||
|
||||
const connection = Mock.of<RealtimeConnection>({
|
||||
getUser: jest.fn(() => Mock.of<User>({ username: this.username })),
|
||||
getDisplayName: jest.fn(() => this.username),
|
||||
getSyncAdapter: jest.fn(() => Mock.of<YDocSyncServerAdapter>({})),
|
||||
getTransporter: jest.fn(() => transporter),
|
||||
getRealtimeUserStateAdapter: () => realtimeUserStateAdapter,
|
||||
getRealtimeNote: () => this.realtimeNote,
|
||||
});
|
||||
|
||||
transporter.on('disconnected', () =>
|
||||
this.realtimeNote.removeClient(connection),
|
||||
);
|
||||
|
||||
if (this.includeRealtimeUserState) {
|
||||
realtimeUserStateAdapter = new RealtimeUserStatusAdapter(
|
||||
this.username,
|
||||
this.username,
|
||||
connection,
|
||||
);
|
||||
}
|
||||
|
||||
this.realtimeNote.addClient(connection);
|
||||
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { EventEmitter2 } from 'eventemitter2';
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
import { Note } from '../../../notes/note.entity';
|
||||
import { MapType, RealtimeNote } from '../realtime-note';
|
||||
import { WebsocketAwareness } from '../websocket-awareness';
|
||||
import { WebsocketDoc } from '../websocket-doc';
|
||||
import { mockAwareness } from './mock-awareness';
|
||||
import { mockWebsocketDoc } from './mock-websocket-doc';
|
||||
|
||||
class MockRealtimeNote extends EventEmitter2<MapType> {
|
||||
constructor(
|
||||
private note: Note,
|
||||
private doc: WebsocketDoc,
|
||||
private awareness: WebsocketAwareness,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public getNote(): Note {
|
||||
return this.note;
|
||||
}
|
||||
|
||||
public getYDoc(): WebsocketDoc {
|
||||
return this.doc;
|
||||
}
|
||||
|
||||
public getAwareness(): WebsocketAwareness {
|
||||
return this.awareness;
|
||||
}
|
||||
|
||||
public removeClient(): void {
|
||||
//left blank for mock
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
//left blank for mock
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a partial mock for {@link RealtimeNote}
|
||||
* @param doc Defines the return value for `getYDoc`
|
||||
* @param awareness Defines the return value for `getAwareness`
|
||||
*/
|
||||
export function mockRealtimeNote(
|
||||
note?: Note,
|
||||
doc?: WebsocketDoc,
|
||||
awareness?: WebsocketAwareness,
|
||||
): RealtimeNote {
|
||||
return Mock.from<RealtimeNote>(
|
||||
new MockRealtimeNote(
|
||||
note ?? Mock.of<Note>(),
|
||||
doc ?? mockWebsocketDoc(),
|
||||
awareness ?? mockAwareness(),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
import { WebsocketDoc } from '../websocket-doc';
|
||||
|
||||
/**
|
||||
* Provides a partial mock for {@link WebsocketDoc}.
|
||||
*/
|
||||
export function mockWebsocketDoc(): WebsocketDoc {
|
||||
return Mock.of<WebsocketDoc>({
|
||||
on: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
getCurrentContent: jest.fn(),
|
||||
});
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { WebsocketTransporter } from '@hedgedoc/commons';
|
||||
import { EventEmitter2 } from 'eventemitter2';
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
class MockMessageTransporter extends EventEmitter2 {
|
||||
setupWebsocket(): void {
|
||||
//intentionally left blank
|
||||
}
|
||||
|
||||
send(): void {
|
||||
//intentionally left blank
|
||||
}
|
||||
|
||||
isSynced(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
//intentionally left blank
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a partial mock for {@link WebsocketTransporter}.
|
||||
*/
|
||||
export function mockWebsocketTransporter(): WebsocketTransporter {
|
||||
return Mock.from<WebsocketTransporter>(new MockMessageTransporter());
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import * as hedgedocRealtimeModule from '@hedgedoc/commons';
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { mockConnection } from './test-utils/mock-connection';
|
||||
import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness';
|
||||
import { WebsocketConnection } from './websocket-connection';
|
||||
import { WebsocketDoc } from './websocket-doc';
|
||||
|
||||
jest.mock('@hedgedoc/commons');
|
||||
|
||||
describe('websocket-awareness', () => {
|
||||
it('distributes content updates to other synced clients', () => {
|
||||
const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]);
|
||||
const mockedEncodeUpdateFunction = jest.spyOn(
|
||||
hedgedocRealtimeModule,
|
||||
'encodeAwarenessUpdateMessage',
|
||||
);
|
||||
mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate);
|
||||
|
||||
const mockConnection1 = mockConnection(true);
|
||||
const mockConnection2 = mockConnection(false);
|
||||
const mockConnection3 = mockConnection(true);
|
||||
const send1 = jest.spyOn(mockConnection1, 'send');
|
||||
const send2 = jest.spyOn(mockConnection2, 'send');
|
||||
const send3 = jest.spyOn(mockConnection3, 'send');
|
||||
|
||||
const realtimeNote = Mock.of<RealtimeNote>({
|
||||
getYDoc(): WebsocketDoc {
|
||||
return Mock.of<WebsocketDoc>({
|
||||
on() {
|
||||
//mocked
|
||||
},
|
||||
});
|
||||
},
|
||||
getConnections(): WebsocketConnection[] {
|
||||
return [mockConnection1, mockConnection2, mockConnection3];
|
||||
},
|
||||
});
|
||||
|
||||
const websocketAwareness = new WebsocketAwareness(realtimeNote);
|
||||
const mockUpdate: ClientIdUpdate = {
|
||||
added: [1],
|
||||
updated: [2],
|
||||
removed: [3],
|
||||
};
|
||||
websocketAwareness.emit('update', [mockUpdate, mockConnection1]);
|
||||
expect(send1).not.toHaveBeenCalled();
|
||||
expect(send2).not.toHaveBeenCalled();
|
||||
expect(send3).toHaveBeenCalledWith(mockEncodedUpdate);
|
||||
expect(mockedEncodeUpdateFunction).toHaveBeenCalledWith(
|
||||
websocketAwareness,
|
||||
[1, 2, 3],
|
||||
);
|
||||
websocketAwareness.destroy();
|
||||
});
|
||||
});
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { encodeAwarenessUpdateMessage } from '@hedgedoc/commons';
|
||||
import { Awareness } from 'y-protocols/awareness';
|
||||
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
|
||||
export interface ClientIdUpdate {
|
||||
added: number[];
|
||||
updated: number[];
|
||||
removed: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the implementation of {@link Awareness YAwareness} which includes additional handlers for message sending and receiving.
|
||||
*/
|
||||
export class WebsocketAwareness extends Awareness {
|
||||
constructor(private realtimeNote: RealtimeNote) {
|
||||
super(realtimeNote.getYDoc());
|
||||
this.setLocalState(null);
|
||||
this.on('update', this.distributeAwarenessUpdate.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Distributes the given awareness changes to all clients.
|
||||
*
|
||||
* @param added Properties that were added to the awareness state
|
||||
* @param updated Properties that were updated in the awareness state
|
||||
* @param removed Properties that were removed from the awareness state
|
||||
* @param origin An object that is used as reference for the origin of the update
|
||||
*/
|
||||
private distributeAwarenessUpdate(
|
||||
{ added, updated, removed }: ClientIdUpdate,
|
||||
origin: unknown,
|
||||
): void {
|
||||
const binaryUpdate = encodeAwarenessUpdateMessage(this, [
|
||||
...added,
|
||||
...updated,
|
||||
...removed,
|
||||
]);
|
||||
this.realtimeNote
|
||||
.getConnections()
|
||||
.filter((client) => client !== origin && client.isSynced())
|
||||
.forEach((client) => client.send(binaryUpdate));
|
||||
}
|
||||
}
|
|
@ -1,219 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import * as hedgedocRealtimeModule from '@hedgedoc/commons';
|
||||
import { WebsocketTransporter } from '@hedgedoc/commons';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import WebSocket from 'ws';
|
||||
import * as yProtocolsAwarenessModule from 'y-protocols/awareness';
|
||||
|
||||
import { Note } from '../../notes/note.entity';
|
||||
import { User } from '../../users/user.entity';
|
||||
import * as realtimeNoteModule from './realtime-note';
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { mockAwareness } from './test-utils/mock-awareness';
|
||||
import { mockRealtimeNote } from './test-utils/mock-realtime-note';
|
||||
import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
|
||||
import { mockWebsocketTransporter } from './test-utils/mock-websocket-transporter';
|
||||
import * as websocketAwarenessModule from './websocket-awareness';
|
||||
import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness';
|
||||
import { WebsocketConnection } from './websocket-connection';
|
||||
import * as websocketDocModule from './websocket-doc';
|
||||
import { WebsocketDoc } from './websocket-doc';
|
||||
|
||||
import SpyInstance = jest.SpyInstance;
|
||||
|
||||
jest.mock('@hedgedoc/commons');
|
||||
|
||||
describe('websocket connection', () => {
|
||||
let mockedDoc: WebsocketDoc;
|
||||
let mockedAwareness: WebsocketAwareness;
|
||||
let mockedRealtimeNote: RealtimeNote;
|
||||
let mockedWebsocket: WebSocket;
|
||||
let mockedUser: User;
|
||||
let mockedWebsocketTransporter: WebsocketTransporter;
|
||||
let removeAwarenessSpy: SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
mockedDoc = mockWebsocketDoc();
|
||||
mockedAwareness = mockAwareness();
|
||||
mockedRealtimeNote = mockRealtimeNote(
|
||||
Mock.of<Note>(),
|
||||
mockedDoc,
|
||||
mockedAwareness,
|
||||
);
|
||||
mockedWebsocket = Mock.of<WebSocket>({});
|
||||
mockedUser = Mock.of<User>({});
|
||||
mockedWebsocketTransporter = mockWebsocketTransporter();
|
||||
|
||||
jest
|
||||
.spyOn(realtimeNoteModule, 'RealtimeNote')
|
||||
.mockImplementation(() => mockedRealtimeNote);
|
||||
jest
|
||||
.spyOn(websocketDocModule, 'WebsocketDoc')
|
||||
.mockImplementation(() => mockedDoc);
|
||||
jest
|
||||
.spyOn(websocketAwarenessModule, 'WebsocketAwareness')
|
||||
.mockImplementation(() => mockedAwareness);
|
||||
jest
|
||||
.spyOn(hedgedocRealtimeModule, 'WebsocketTransporter')
|
||||
.mockImplementation(() => mockedWebsocketTransporter);
|
||||
|
||||
removeAwarenessSpy = jest
|
||||
.spyOn(yProtocolsAwarenessModule, 'removeAwarenessStates')
|
||||
.mockImplementation();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('sets up the websocket in the constructor', () => {
|
||||
const setupWebsocketSpy = jest.spyOn(
|
||||
mockedWebsocketTransporter,
|
||||
'setupWebsocket',
|
||||
);
|
||||
|
||||
new WebsocketConnection(mockedWebsocket, mockedUser, mockedRealtimeNote);
|
||||
|
||||
expect(setupWebsocketSpy).toHaveBeenCalledWith(mockedWebsocket);
|
||||
});
|
||||
|
||||
it('forwards sent messages to the transporter', () => {
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
const sendFunctionSpy = jest.spyOn(mockedWebsocketTransporter, 'send');
|
||||
const sendContent = new Uint8Array();
|
||||
sut.send(sendContent);
|
||||
expect(sendFunctionSpy).toHaveBeenCalledWith(sendContent);
|
||||
});
|
||||
|
||||
it('forwards disconnect calls to the transporter', () => {
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
const disconnectFunctionSpy = jest.spyOn(
|
||||
mockedWebsocketTransporter,
|
||||
'disconnect',
|
||||
);
|
||||
sut.disconnect();
|
||||
expect(disconnectFunctionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards isSynced checks to the transporter', () => {
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
const isSyncedFunctionSpy = jest.spyOn(
|
||||
mockedWebsocketTransporter,
|
||||
'isSynced',
|
||||
);
|
||||
|
||||
expect(sut.isSynced()).toBe(false);
|
||||
|
||||
isSyncedFunctionSpy.mockReturnValue(true);
|
||||
expect(sut.isSynced()).toBe(true);
|
||||
});
|
||||
|
||||
it('removes the client from the note on transporter disconnect', () => {
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
const removeClientSpy = jest.spyOn(mockedRealtimeNote, 'removeClient');
|
||||
|
||||
mockedWebsocketTransporter.emit('disconnected');
|
||||
|
||||
expect(removeClientSpy).toHaveBeenCalledWith(sut);
|
||||
});
|
||||
|
||||
it('remembers the controlled awareness-ids on awareness update', () => {
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] };
|
||||
mockedAwareness.emit('update', [update, sut]);
|
||||
|
||||
expect(sut.getControlledAwarenessIds()).toEqual(new Set([0]));
|
||||
});
|
||||
|
||||
it("doesn't remembers the controlled awareness-ids of other connections on awareness update", () => {
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] };
|
||||
mockedAwareness.emit('update', [update, Mock.of<WebsocketConnection>()]);
|
||||
|
||||
expect(sut.getControlledAwarenessIds()).toEqual(new Set([]));
|
||||
});
|
||||
|
||||
it('removes the controlled awareness ids on transport disconnect', () => {
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
const update: ClientIdUpdate = { added: [0], removed: [1], updated: [2] };
|
||||
mockedAwareness.emit('update', [update, sut]);
|
||||
|
||||
mockedWebsocketTransporter.emit('disconnected');
|
||||
|
||||
expect(removeAwarenessSpy).toHaveBeenCalledWith(mockedAwareness, [0], sut);
|
||||
});
|
||||
|
||||
it('saves the correct user', () => {
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
expect(sut.getUser()).toBe(mockedUser);
|
||||
});
|
||||
|
||||
it('returns the correct username', () => {
|
||||
const mockedUserWithUsername = Mock.of<User>({ username: 'MockUser' });
|
||||
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUserWithUsername,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
expect(sut.getUsername()).toBe('MockUser');
|
||||
});
|
||||
|
||||
it('returns a fallback if no username has been set', () => {
|
||||
const sut = new WebsocketConnection(
|
||||
mockedWebsocket,
|
||||
mockedUser,
|
||||
mockedRealtimeNote,
|
||||
);
|
||||
|
||||
expect(sut.getUsername()).toBe('Guest');
|
||||
});
|
||||
});
|
|
@ -1,104 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { WebsocketTransporter } from '@hedgedoc/commons';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import WebSocket from 'ws';
|
||||
import { Awareness, removeAwarenessStates } from 'y-protocols/awareness';
|
||||
|
||||
import { User } from '../../users/user.entity';
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { ClientIdUpdate } from './websocket-awareness';
|
||||
|
||||
/**
|
||||
* Manages the websocket connection to a specific client.
|
||||
*/
|
||||
export class WebsocketConnection {
|
||||
protected readonly logger = new Logger(WebsocketConnection.name);
|
||||
private controlledAwarenessIds: Set<number> = new Set();
|
||||
private transporter: WebsocketTransporter;
|
||||
|
||||
/**
|
||||
* Instantiates the websocket connection wrapper for a websocket connection.
|
||||
*
|
||||
* @param websocket The client's raw websocket.
|
||||
* @param user The user of the client
|
||||
* @param realtimeNote The {@link RealtimeNote} that the client connected to.
|
||||
* @throws Error if the socket is not open
|
||||
*/
|
||||
constructor(
|
||||
websocket: WebSocket,
|
||||
private user: User | null,
|
||||
realtimeNote: RealtimeNote,
|
||||
) {
|
||||
const awareness = realtimeNote.getAwareness();
|
||||
this.transporter = new WebsocketTransporter(
|
||||
realtimeNote.getYDoc(),
|
||||
awareness,
|
||||
);
|
||||
this.transporter.on('disconnected', () => {
|
||||
realtimeNote.removeClient(this);
|
||||
});
|
||||
this.transporter.setupWebsocket(websocket);
|
||||
this.bindAwarenessMessageEvents(awareness);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds all additional events that are needed for awareness processing.
|
||||
*/
|
||||
private bindAwarenessMessageEvents(awareness: Awareness): void {
|
||||
const callback = this.updateControlledAwarenessIds.bind(this);
|
||||
awareness.on('update', callback);
|
||||
this.transporter.on('disconnected', () => {
|
||||
awareness.off('update', callback);
|
||||
removeAwarenessStates(awareness, [...this.controlledAwarenessIds], this);
|
||||
});
|
||||
}
|
||||
|
||||
private updateControlledAwarenessIds(
|
||||
{ added, removed }: ClientIdUpdate,
|
||||
origin: WebsocketConnection,
|
||||
): void {
|
||||
if (origin === this) {
|
||||
added.forEach((id) => this.controlledAwarenessIds.add(id));
|
||||
removed.forEach((id) => this.controlledAwarenessIds.delete(id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines if the current connection has received at least one full synchronisation.
|
||||
*/
|
||||
public isSynced(): boolean {
|
||||
return this.transporter.isSynced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the given content to the client.
|
||||
*
|
||||
* @param content The content to send
|
||||
*/
|
||||
public send(content: Uint8Array): void {
|
||||
this.transporter.send(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the connection
|
||||
*/
|
||||
public disconnect(): void {
|
||||
this.transporter.disconnect();
|
||||
}
|
||||
|
||||
public getControlledAwarenessIds(): ReadonlySet<number> {
|
||||
return this.controlledAwarenessIds;
|
||||
}
|
||||
|
||||
public getUser(): User | null {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
public getUsername(): string {
|
||||
return this.getUser()?.username ?? 'Guest';
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import * as hedgedocRealtimeModule from '@hedgedoc/commons';
|
||||
import { Mock } from 'ts-mockery';
|
||||
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { mockConnection } from './test-utils/mock-connection';
|
||||
import { WebsocketConnection } from './websocket-connection';
|
||||
import { WebsocketDoc } from './websocket-doc';
|
||||
|
||||
jest.mock('@hedgedoc/commons');
|
||||
|
||||
describe('websocket-doc', () => {
|
||||
it('saves the initial content', () => {
|
||||
const textContent = 'textContent';
|
||||
const websocketDoc = new WebsocketDoc(Mock.of<RealtimeNote>(), textContent);
|
||||
|
||||
expect(websocketDoc.getCurrentContent()).toBe(textContent);
|
||||
});
|
||||
|
||||
it('distributes content updates to other synced clients', () => {
|
||||
const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]);
|
||||
const mockedEncodeUpdateFunction = jest.spyOn(
|
||||
hedgedocRealtimeModule,
|
||||
'encodeDocumentUpdateMessage',
|
||||
);
|
||||
mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate);
|
||||
|
||||
const mockConnection1 = mockConnection(true);
|
||||
const mockConnection2 = mockConnection(false);
|
||||
const mockConnection3 = mockConnection(true);
|
||||
|
||||
const send1 = jest.spyOn(mockConnection1, 'send');
|
||||
const send2 = jest.spyOn(mockConnection2, 'send');
|
||||
const send3 = jest.spyOn(mockConnection3, 'send');
|
||||
|
||||
const realtimeNote = Mock.of<RealtimeNote>({
|
||||
getConnections(): WebsocketConnection[] {
|
||||
return [mockConnection1, mockConnection2, mockConnection3];
|
||||
},
|
||||
getYDoc(): WebsocketDoc {
|
||||
return websocketDoc;
|
||||
},
|
||||
});
|
||||
|
||||
const websocketDoc = new WebsocketDoc(realtimeNote, '');
|
||||
const mockUpdate = new Uint8Array([4, 5, 6, 7]);
|
||||
websocketDoc.emit('update', [mockUpdate, mockConnection1]);
|
||||
expect(send1).not.toHaveBeenCalled();
|
||||
expect(send2).not.toHaveBeenCalled();
|
||||
expect(send3).toHaveBeenCalledWith(mockEncodedUpdate);
|
||||
expect(mockedEncodeUpdateFunction).toHaveBeenCalledWith(mockUpdate);
|
||||
websocketDoc.destroy();
|
||||
});
|
||||
});
|
|
@ -1,72 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
encodeDocumentUpdateMessage,
|
||||
MARKDOWN_CONTENT_CHANNEL_NAME,
|
||||
} from '@hedgedoc/commons';
|
||||
import { Doc } from 'yjs';
|
||||
|
||||
import { RealtimeNote } from './realtime-note';
|
||||
import { WebsocketConnection } from './websocket-connection';
|
||||
|
||||
/**
|
||||
* This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving.
|
||||
*/
|
||||
export class WebsocketDoc extends Doc {
|
||||
/**
|
||||
* Creates a new WebsocketDoc instance.
|
||||
*
|
||||
* The new instance is filled with the given initial content and an event listener will be registered to handle
|
||||
* updates to the doc.
|
||||
*
|
||||
* @param realtimeNote - the {@link RealtimeNote} handling this {@link Doc YDoc}
|
||||
* @param initialContent - the initial content of the {@link Doc YDoc}
|
||||
*/
|
||||
constructor(private realtimeNote: RealtimeNote, initialContent: string) {
|
||||
super();
|
||||
this.initializeContent(initialContent);
|
||||
this.bindUpdateEvent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the event that distributes updates in the current {@link Doc y-doc} to all clients.
|
||||
*/
|
||||
private bindUpdateEvent(): void {
|
||||
this.on('update', (update: Uint8Array, origin: WebsocketConnection) => {
|
||||
const clients = this.realtimeNote
|
||||
.getConnections()
|
||||
.filter((client) => client !== origin && client.isSynced());
|
||||
if (clients.length > 0) {
|
||||
clients.forEach((client) => {
|
||||
client.send(encodeDocumentUpdateMessage(update));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link YDoc's Doc} content to include the initialContent.
|
||||
*
|
||||
* This message should only be called when a new {@link RealtimeNote } is created.
|
||||
*
|
||||
* @param initialContent - the initial content to set the {@link Doc YDoc's} content to.
|
||||
* @private
|
||||
*/
|
||||
private initializeContent(initialContent: string): void {
|
||||
this.getText(MARKDOWN_CONTENT_CHANNEL_NAME).insert(0, initialContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current content of the note as it's currently edited in realtime.
|
||||
*
|
||||
* Please be aware that the return of this method may be very quickly outdated.
|
||||
*
|
||||
* @return The current note content.
|
||||
*/
|
||||
public getCurrentContent(): string {
|
||||
return this.getText(MARKDOWN_CONTENT_CHANNEL_NAME).toString();
|
||||
}
|
||||
}
|
|
@ -40,15 +40,15 @@ import { Session } from '../../users/session.entity';
|
|||
import { User } from '../../users/user.entity';
|
||||
import { UsersModule } from '../../users/users.module';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import * as websocketConnectionModule from '../realtime-note/realtime-connection';
|
||||
import { RealtimeConnection } from '../realtime-note/realtime-connection';
|
||||
import { RealtimeNote } from '../realtime-note/realtime-note';
|
||||
import { RealtimeNoteModule } from '../realtime-note/realtime-note.module';
|
||||
import { RealtimeNoteService } from '../realtime-note/realtime-note.service';
|
||||
import * as websocketConnectionModule from '../realtime-note/websocket-connection';
|
||||
import { WebsocketConnection } from '../realtime-note/websocket-connection';
|
||||
import * as extractNoteIdFromRequestUrlModule from './utils/extract-note-id-from-request-url';
|
||||
import { WebsocketGateway } from './websocket.gateway';
|
||||
|
||||
import SpyInstance = jest.SpyInstance;
|
||||
jest.mock('@hedgedoc/commons');
|
||||
|
||||
describe('Websocket gateway', () => {
|
||||
let gateway: WebsocketGateway;
|
||||
|
@ -57,10 +57,10 @@ describe('Websocket gateway', () => {
|
|||
let notesService: NotesService;
|
||||
let realtimeNoteService: RealtimeNoteService;
|
||||
let permissionsService: PermissionsService;
|
||||
let mockedWebsocketConnection: WebsocketConnection;
|
||||
let mockedWebsocketConnection: RealtimeConnection;
|
||||
let mockedWebsocket: WebSocket;
|
||||
let mockedWebsocketCloseSpy: SpyInstance;
|
||||
let addClientSpy: SpyInstance;
|
||||
let mockedWebsocketCloseSpy: jest.SpyInstance;
|
||||
let addClientSpy: jest.SpyInstance;
|
||||
|
||||
const mockedValidSessionCookie = 'mockedValidSessionCookie';
|
||||
const mockedSessionIdWithUser = 'mockedSessionIdWithUser';
|
||||
|
@ -231,9 +231,9 @@ describe('Websocket gateway', () => {
|
|||
.spyOn(realtimeNoteService, 'getOrCreateRealtimeNote')
|
||||
.mockReturnValue(Promise.resolve(mockedRealtimeNote));
|
||||
|
||||
mockedWebsocketConnection = Mock.of<WebsocketConnection>();
|
||||
mockedWebsocketConnection = Mock.of<RealtimeConnection>();
|
||||
jest
|
||||
.spyOn(websocketConnectionModule, 'WebsocketConnection')
|
||||
.spyOn(websocketConnectionModule, 'RealtimeConnection')
|
||||
.mockReturnValue(mockedWebsocketConnection);
|
||||
|
||||
mockedWebsocket = Mock.of<WebSocket>({
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { WebsocketTransporter } from '@hedgedoc/commons';
|
||||
import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets';
|
||||
import { IncomingMessage } from 'http';
|
||||
import WebSocket from 'ws';
|
||||
|
@ -13,8 +14,8 @@ import { PermissionsService } from '../../permissions/permissions.service';
|
|||
import { SessionService } from '../../session/session.service';
|
||||
import { User } from '../../users/user.entity';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { RealtimeConnection } from '../realtime-note/realtime-connection';
|
||||
import { RealtimeNoteService } from '../realtime-note/realtime-note.service';
|
||||
import { WebsocketConnection } from '../realtime-note/websocket-connection';
|
||||
import { extractNoteIdFromRequestUrl } from './utils/extract-note-id-from-request-url';
|
||||
|
||||
/**
|
||||
|
@ -75,13 +76,17 @@ export class WebsocketGateway implements OnGatewayConnection {
|
|||
const realtimeNote =
|
||||
await this.realtimeNoteService.getOrCreateRealtimeNote(note);
|
||||
|
||||
const connection = new WebsocketConnection(
|
||||
clientSocket,
|
||||
const websocketTransporter = new WebsocketTransporter();
|
||||
const connection = new RealtimeConnection(
|
||||
websocketTransporter,
|
||||
user,
|
||||
realtimeNote,
|
||||
);
|
||||
websocketTransporter.setWebsocket(clientSocket);
|
||||
|
||||
realtimeNote.addClient(connection);
|
||||
|
||||
websocketTransporter.sendReady();
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(
|
||||
`Error occurred while initializing: ${(error as Error).message}`,
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false
|
||||
"strictPropertyInitialization": false,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue