refactor: reimplement realtime-communication

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

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

View file

@ -1,26 +0,0 @@
diff --git a/package.json b/package.json
index 5f953f00544710a638dc502b30841d39193f6d3f..6c31784d1b1f32ee8f21106011c4e6ef526f1560 100644
--- a/package.json
+++ b/package.json
@@ -47,18 +47,21 @@
"./sync.js": "./sync.js",
"./dist/sync.cjs": "./dist/sync.cjs",
"./sync": {
+ "types": "./sync.d.ts",
"import": "./sync.js",
"require": "./dist/sync.cjs"
},
"./awareness.js": "./awareness.js",
"./dist/awareness.cjs": "./dist/awareness.cjs",
"./awareness": {
+ "types": "./awareness.d.ts",
"import": "./awareness.js",
"require": "./dist/awareness.cjs"
},
"./auth.js": "./auth.js",
"./dist/auth.cjs": "./dist/auth.cjs",
"./auth": {
+ "types": "./auth.d.ts",
"import": "./auth.js",
"require": "./dist/auth.cjs"
}

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,52 +3,51 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { import { Message, MessageType, RealtimeDoc } from '@hedgedoc/commons';
encodeDocumentDeletedMessage,
encodeMetadataUpdatedMessage,
} from '@hedgedoc/commons';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { EventEmitter2, EventMap } from 'eventemitter2'; import { EventEmitter2, EventMap } from 'eventemitter2';
import { Awareness } from 'y-protocols/awareness';
import { Note } from '../../notes/note.entity'; import { Note } from '../../notes/note.entity';
import { WebsocketAwareness } from './websocket-awareness'; import { RealtimeConnection } from './realtime-connection';
import { WebsocketConnection } from './websocket-connection';
import { WebsocketDoc } from './websocket-doc';
export interface MapType extends EventMap { export interface RealtimeNoteEventMap extends EventMap {
destroy: () => void; destroy: () => void;
beforeDestroy: () => 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. * 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; protected logger: Logger;
private readonly websocketDoc: WebsocketDoc; private readonly doc: RealtimeDoc;
private readonly websocketAwareness: WebsocketAwareness; private readonly clients = new Set<RealtimeConnection>();
private readonly clients = new Set<WebsocketConnection>();
private isClosing = false; private isClosing = false;
constructor(private readonly note: Note, initialContent: string) { constructor(private readonly note: Note, initialContent: string) {
super(); super();
this.logger = new Logger(`${RealtimeNote.name} ${note.id}`); this.logger = new Logger(`${RealtimeNote.name} ${note.id}`);
this.websocketDoc = new WebsocketDoc(this, initialContent); this.doc = new RealtimeDoc(initialContent);
this.websocketAwareness = new WebsocketAwareness(this); this.logger.debug(
this.logger.debug(`New realtime session for note ${note.id} created.`); `New realtime session for note ${note.id} created. Length of initial content: ${initialContent.length} characters`,
);
} }
/** /**
* Connects a new client to the note. * 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 * @param client the websocket connection to the client
*/ */
public addClient(client: WebsocketConnection): void { public addClient(client: RealtimeConnection): void {
this.clients.add(client); 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. * @param {WebSocket} client The websocket client that disconnects.
*/ */
public removeClient(client: WebsocketConnection): void { public removeClient(client: RealtimeConnection): void {
this.clients.delete(client); this.clients.delete(client);
this.logger.debug( this.logger.debug(
`User '${client.getUsername()}' disconnected. ${ `User '${client.getDisplayName()}' disconnected. ${
this.clients.size this.clients.size
} clients left.`, } clients left.`,
); );
this.emit('clientRemoved', client);
if (!this.hasConnections() && !this.isClosing) { if (!this.hasConnections() && !this.isClosing) {
this.destroy(); this.destroy();
} }
@ -80,9 +80,8 @@ export class RealtimeNote extends EventEmitter2<MapType> {
this.logger.debug('Destroying realtime note.'); this.logger.debug('Destroying realtime note.');
this.emit('beforeDestroy'); this.emit('beforeDestroy');
this.isClosing = true; this.isClosing = true;
this.websocketDoc.destroy(); this.doc.destroy();
this.websocketAwareness.destroy(); this.clients.forEach((value) => value.getTransporter().disconnect());
this.clients.forEach((value) => value.disconnect());
this.emit('destroy'); 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]; 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 { public getRealtimeDoc(): RealtimeDoc {
return this.websocketDoc; return this.doc;
}
/**
* Get the {@link Awareness YAwareness} of the note.
*
* @return the {@link Awareness YAwareness} of the note
*/
public getAwareness(): Awareness {
return this.websocketAwareness;
} }
/** /**
@ -135,14 +125,14 @@ export class RealtimeNote extends EventEmitter2<MapType> {
* Announce to all clients that the permissions of the note have been changed. * Announce to all clients that the permissions of the note have been changed.
*/ */
public announcePermissionChange(): void { public announcePermissionChange(): void {
this.sendToAllClients(encodeMetadataUpdatedMessage()); this.sendToAllClients({ type: MessageType.METADATA_UPDATED });
} }
/** /**
* Announce to all clients that the note has been deleted. * Announce to all clients that the note has been deleted.
*/ */
public announceNoteDeletion(): void { 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 * @param {Uint8Array} content The binary message to broadcast
*/ */
private sendToAllClients(content: Uint8Array): void { private sendToAllClients(content: Message<MessageType>): void {
this.getConnections().forEach((connection) => { this.getConnections().forEach((connection) => {
connection.send(content); connection.getTransporter().sendMessage(content);
}); });
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,9 @@
"README.md", "README.md",
"dist/**" "dist/**"
], ],
"browserslist": [
"node> 12"
],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/hedgedoc/hedgedoc.git" "url": "https://github.com/hedgedoc/hedgedoc.git"
@ -37,9 +40,7 @@
"dependencies": { "dependencies": {
"eventemitter2": "6.4.9", "eventemitter2": "6.4.9",
"isomorphic-ws": "5.0.0", "isomorphic-ws": "5.0.0",
"lib0": "0.2.73",
"ws": "8.13.0", "ws": "8.13.0",
"y-protocols": "1.0.5",
"yjs": "13.5.51" "yjs": "13.5.51"
}, },
"devDependencies": { "devDependencies": {

View file

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

View file

@ -1,7 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent'

View file

@ -1,28 +1,14 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
export { MessageType } from './messages/message-type.enum.js' export * from './message-transporters/mocked-backend-message-transporter.js'
export { ConnectionKeepAliveHandler } from './connection-keep-alive-handler.js' export * from './message-transporters/message.js'
export { YDocMessageTransporter } from './y-doc-message-transporter.js' export * from './message-transporters/message-transporter.js'
export { export * from './message-transporters/realtime-user.js'
applyAwarenessUpdateMessage, export * from './message-transporters/websocket-transporter.js'
encodeAwarenessUpdateMessage
} from './messages/awareness-update-message.js'
export {
applyDocumentUpdateMessage,
encodeDocumentUpdateMessage
} from './messages/document-update-message.js'
export { encodeCompleteAwarenessStateRequestMessage } from './messages/complete-awareness-state-request-message.js'
export { encodeCompleteDocumentStateRequestMessage } from './messages/complete-document-state-request-message.js'
export { encodeCompleteDocumentStateAnswerMessage } from './messages/complete-document-state-answer-message.js'
export { encodeDocumentDeletedMessage } from './messages/document-deleted-message.js'
export { encodeMetadataUpdatedMessage } from './messages/metadata-updated-message.js'
export { encodeServerVersionUpdatedMessage } from './messages/server-version-updated-message.js'
export { WebsocketTransporter } from './websocket-transporter.js'
export { parseUrl } from './utils/parse-url.js' export { parseUrl } from './utils/parse-url.js'
export { export {
@ -30,8 +16,10 @@ export {
WrongProtocolError WrongProtocolError
} from './utils/errors.js' } from './utils/errors.js'
export type { MessageTransporterEvents } from './y-doc-message-transporter.js' export * from './y-doc-sync/y-doc-sync-client-adapter.js'
export * from './y-doc-sync/y-doc-sync-server-adapter.js'
export * from './y-doc-sync/y-doc-sync-adapter.js'
export { waitForOtherPromisesToFinish } from './utils/wait-for-other-promises-to-finish.js' export { waitForOtherPromisesToFinish } from './utils/wait-for-other-promises-to-finish.js'
export { MARKDOWN_CONTENT_CHANNEL_NAME } from './constants/markdown-content-channel-name.js' export { RealtimeDoc } from './y-doc-sync/realtime-doc'

View file

@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Message, MessagePayloads, MessageType } from './message.js'
import { EventEmitter2, Listener } from 'eventemitter2'
export type MessageEvents = MessageType | 'connected' | 'disconnected'
type MessageEventPayloadMap = {
[E in MessageEvents]: E extends keyof MessagePayloads
? (message: Message<E>) => void
: () => void
}
export enum ConnectionState {
DISCONNECT,
CONNECTING,
CONNECTED
}
/**
* Base class for event based message communication.
*/
export abstract class MessageTransporter extends EventEmitter2<MessageEventPayloadMap> {
private readyMessageReceived = false
public abstract sendMessage<M extends MessageType>(content: Message<M>): void
protected receiveMessage<L extends MessageType>(message: Message<L>): void {
if (message.type === MessageType.READY) {
this.readyMessageReceived = true
}
this.emit(message.type, message)
}
public sendReady(): void {
this.sendMessage({
type: MessageType.READY
})
}
public abstract disconnect(): void
public abstract getConnectionState(): ConnectionState
protected onConnected(): void {
this.emit('connected')
}
protected onDisconnecting(): void {
this.readyMessageReceived = false
this.emit('disconnected')
}
/**
* Indicates if the message transporter is connected and can send/receive messages.
*/
public isConnected(): boolean {
return this.getConnectionState() === ConnectionState.CONNECTED
}
/**
* Indicates if the message transporter has receives a {@link MessageType.READY ready message} yet.
*/
public isReady(): boolean {
return this.readyMessageReceived
}
/**
* Executes the given callback whenever the message transporter receives a ready message.
* If the messenger has already received a ready message then the callback will be executed immediately.
*
* @param callback The callback to execute when ready
* @return The event listener that waits for ready messages
*/
public doAsSoonAsReady(callback: () => void): Listener {
if (this.readyMessageReceived) {
callback()
}
return this.on(MessageType.READY, callback, {
objectify: true
}) as Listener
}
/**
* Executes the given callback whenever the message transporter has established a connection.
* If the messenger is already connected then the callback will be executed immediately.
*
* @param callback The callback to execute when connected
* @return The event listener that waits for connection events
*/
public doAsSoonAsConnected(callback: () => void): Listener {
if (this.isConnected()) {
callback()
}
return this.on('connected', callback, {
objectify: true
}) as Listener
}
}

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { RealtimeUser, RemoteCursor } from './realtime-user.js'
export enum MessageType {
NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST',
NOTE_CONTENT_UPDATE = 'NOTE_CONTENT_UPDATE',
PING = 'PING',
PONG = 'PONG',
METADATA_UPDATED = 'METADATA_UPDATED',
DOCUMENT_DELETED = 'DOCUMENT_DELETED',
SERVER_VERSION_UPDATED = 'SERVER_VERSION_UPDATED',
REALTIME_USER_STATE_SET = 'REALTIME_USER_STATE_SET',
REALTIME_USER_SINGLE_UPDATE = 'REALTIME_USER_SINGLE_UPDATE',
REALTIME_USER_STATE_REQUEST = 'REALTIME_USER_STATE_REQUEST',
READY = 'READY'
}
export interface MessagePayloads {
[MessageType.NOTE_CONTENT_STATE_REQUEST]: number[]
[MessageType.NOTE_CONTENT_UPDATE]: number[]
[MessageType.REALTIME_USER_STATE_SET]: RealtimeUser[]
[MessageType.REALTIME_USER_SINGLE_UPDATE]: RemoteCursor
}
export type Message<T extends MessageType> = T extends keyof MessagePayloads
? {
type: T
payload: MessagePayloads[T]
}
: {
type: T
}

View file

@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { RealtimeDoc } from '../y-doc-sync/realtime-doc.js'
import { ConnectionState, MessageTransporter } from './message-transporter.js'
import { Message, MessageType } from './message.js'
import { Doc, encodeStateAsUpdate } from 'yjs'
/**
* A mocked connection that doesn't send or receive any data and is instantly ready.
* The only exception is the note content state request that is answered with the given initial content.
*/
export class MockedBackendMessageTransporter extends MessageTransporter {
private readonly doc: Doc
private connected = true
constructor(initialContent: string) {
super()
this.doc = new RealtimeDoc(initialContent)
this.onConnected()
}
disconnect(): void {
if (!this.connected) {
return
}
this.connected = false
this.onDisconnecting()
}
sendReady() {
this.receiveMessage({
type: MessageType.READY
})
}
sendMessage<M extends MessageType>(content: Message<M>) {
if (content.type === MessageType.NOTE_CONTENT_STATE_REQUEST) {
setTimeout(() => {
const payload = Array.from(
encodeStateAsUpdate(this.doc, new Uint8Array(content.payload))
)
this.receiveMessage({ type: MessageType.NOTE_CONTENT_UPDATE, payload })
}, 10)
}
}
getConnectionState(): ConnectionState {
return this.connected
? ConnectionState.CONNECTED
: ConnectionState.DISCONNECT
}
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface RealtimeUser {
displayName: string
username: string | null
active: boolean
styleIndex: number
cursor: RemoteCursor
}
export interface RemoteCursor {
from: number
to?: number
}

View file

@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ConnectionState, MessageTransporter } from './message-transporter.js'
import { Message, MessageType } from './message.js'
import WebSocket, { CloseEvent, ErrorEvent, MessageEvent } from 'isomorphic-ws'
export class WebsocketTransporter extends MessageTransporter {
private websocket: WebSocket | undefined
private messageCallback: undefined | ((event: MessageEvent) => void)
private errorCallback: undefined | ((event: ErrorEvent) => void)
private closeCallback: undefined | ((event: CloseEvent) => void)
constructor() {
super()
}
public setWebsocket(websocket: WebSocket) {
if (
websocket.readyState === WebSocket.CLOSED ||
websocket.readyState === WebSocket.CLOSING
) {
throw new Error('Websocket must be open')
}
this.undbindEventsFromPreviousWebsocket()
this.websocket = websocket
this.bindWebsocketEvents(websocket)
if (this.websocket.readyState === WebSocket.OPEN) {
this.onConnected()
} else {
this.websocket.addEventListener('open', this.onConnected.bind(this))
}
}
private undbindEventsFromPreviousWebsocket() {
if (this.websocket) {
if (this.messageCallback) {
this.websocket.removeEventListener('message', this.messageCallback)
}
if (this.errorCallback) {
this.websocket.removeEventListener('error', this.errorCallback)
}
if (this.closeCallback) {
this.websocket.removeEventListener('close', this.closeCallback)
}
}
}
private bindWebsocketEvents(websocket: WebSocket) {
this.messageCallback = this.processMessageEvent.bind(this)
this.errorCallback = this.disconnect.bind(this)
this.closeCallback = this.onDisconnecting.bind(this)
websocket.addEventListener('message', this.messageCallback)
websocket.addEventListener('error', this.errorCallback)
websocket.addEventListener('close', this.closeCallback)
}
private processMessageEvent(event: MessageEvent): void {
if (typeof event.data !== 'string') {
return
}
const message = JSON.parse(event.data) as Message<MessageType>
this.receiveMessage(message)
}
public disconnect(): void {
this.websocket?.close()
}
public sendMessage(content: Message<MessageType>): void {
if (this.websocket?.readyState !== WebSocket.OPEN) {
throw new Error("Can't send message over non-open socket")
}
try {
this.websocket.send(JSON.stringify(content))
} catch (error: unknown) {
this.disconnect()
throw error
}
}
public getConnectionState(): ConnectionState {
if (this.websocket?.readyState === WebSocket.OPEN) {
return ConnectionState.CONNECTED
} else if (this.websocket?.readyState === WebSocket.CONNECTING) {
return ConnectionState.CONNECTING
} else {
return ConnectionState.DISCONNECT
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
ConnectionState,
MessageTransporter
} from '../message-transporters/message-transporter.js'
import { Message, MessageType } from '../message-transporters/message.js'
/**
* Message transporter for testing purposes that redirects message to another in memory connection message transporter instance.
*/
export class InMemoryConnectionMessageTransporter extends MessageTransporter {
private otherSide: InMemoryConnectionMessageTransporter | undefined
constructor(private name: string) {
super()
}
public connect(other: InMemoryConnectionMessageTransporter): void {
this.otherSide = other
other.otherSide = this
this.onConnected()
other.onConnected()
}
public disconnect(): void {
this.onDisconnecting()
if (this.otherSide) {
this.otherSide.onDisconnecting()
this.otherSide.otherSide = undefined
this.otherSide = undefined
}
}
sendMessage(content: Message<MessageType>): void {
if (this.otherSide === undefined) {
throw new Error('Disconnected')
}
console.debug(`${this.name}`, 'Sending', content)
this.otherSide?.receiveMessage(content)
}
getConnectionState(): ConnectionState {
return this.otherSide !== undefined
? ConnectionState.CONNECTED
: ConnectionState.DISCONNECT
}
}

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { RealtimeDoc } from './realtime-doc.js'
import { describe, expect, it } from '@jest/globals'
describe('websocket-doc', () => {
it('saves the initial content', () => {
const textContent = 'textContent'
const websocketDoc = new RealtimeDoc(textContent)
expect(websocketDoc.getCurrentContent()).toBe(textContent)
})
})

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Doc } from 'yjs'
import { Text as YText } from 'yjs'
const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent'
/**
* This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving.
*/
export class RealtimeDoc extends Doc {
/**
* Creates a new instance.
*
* The new instance is filled with the given initial content.
*
* @param initialContent - the initial content of the {@link Doc YDoc}
*/
constructor(initialContent?: string) {
super()
if (initialContent) {
this.getMarkdownContentChannel().insert(0, initialContent)
}
}
/**
* Extracts the {@link YText text channel} that contains the markdown code.
*
* @return The markdown channel
*/
public getMarkdownContentChannel(): YText {
return this.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
}
/**
* 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.getMarkdownContentChannel().toString()
}
}

View file

@ -0,0 +1,162 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Message, MessageType } from '../message-transporters/message.js'
import { InMemoryConnectionMessageTransporter } from './in-memory-connection-message.transporter.js'
import { RealtimeDoc } from './realtime-doc.js'
import { YDocSyncClientAdapter } from './y-doc-sync-client-adapter.js'
import { YDocSyncServerAdapter } from './y-doc-sync-server-adapter.js'
import { describe, expect, it } from '@jest/globals'
describe('message transporter', () => {
it('server client communication', async () => {
const docServer: RealtimeDoc = new RealtimeDoc('This is a test note')
const docClient1: RealtimeDoc = new RealtimeDoc()
const docClient2: RealtimeDoc = new RealtimeDoc()
const textServer = docServer.getMarkdownContentChannel()
const textClient1 = docClient1.getMarkdownContentChannel()
const textClient2 = docClient2.getMarkdownContentChannel()
textServer.observe(() =>
console.debug('textServer', new Date(), textServer.toString())
)
textClient1.observe(() =>
console.debug('textClient1', new Date(), textClient1.toString())
)
textClient2.observe(() =>
console.debug('textClient2', new Date(), textClient2.toString())
)
const transporterServerTo1 = new InMemoryConnectionMessageTransporter('s>1')
const transporterServerTo2 = new InMemoryConnectionMessageTransporter('s>2')
const transporterClient1 = new InMemoryConnectionMessageTransporter('1>s')
const transporterClient2 = new InMemoryConnectionMessageTransporter('2>s')
transporterServerTo1.on(MessageType.NOTE_CONTENT_UPDATE, () =>
console.debug('Received NOTE_CONTENT_UPDATE from client 1 to server')
)
transporterServerTo2.on(MessageType.NOTE_CONTENT_UPDATE, () =>
console.debug('Received NOTE_CONTENT_UPDATE from client 2 to server')
)
transporterClient1.on(MessageType.NOTE_CONTENT_UPDATE, () =>
console.debug('Received NOTE_CONTENT_UPDATE from server to client 1')
)
transporterClient2.on(MessageType.NOTE_CONTENT_UPDATE, () =>
console.debug('Received NOTE_CONTENT_UPDATE from server to client 2')
)
transporterServerTo1.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
console.debug('Received NOTE_CONTENT_REQUEST from client 1 to server')
)
transporterServerTo2.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
console.debug('Received NOTE_CONTENT_REQUEST from client 2 to server')
)
transporterClient1.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
console.debug('Received NOTE_CONTENT_REQUEST from server to client 1')
)
transporterClient2.on(MessageType.NOTE_CONTENT_STATE_REQUEST, () =>
console.debug('Received NOTE_CONTENT_REQUEST from server to client 2')
)
transporterClient1.on('connected', () => console.debug('1>s is connected'))
transporterClient2.on('connected', () => console.debug('2>s is connected'))
transporterServerTo1.on('connected', () =>
console.debug('s>1 is connected')
)
transporterServerTo2.on('connected', () =>
console.debug('s>2 is connected')
)
docServer.on('update', (update: Uint8Array, origin: unknown) => {
const message: Message<MessageType.NOTE_CONTENT_UPDATE> = {
type: MessageType.NOTE_CONTENT_UPDATE,
payload: Array.from(update)
}
if (origin !== transporterServerTo1) {
console.debug('YDoc on Server updated. Sending to Client 1')
transporterServerTo1.sendMessage(message)
}
if (origin !== transporterServerTo2) {
console.debug('YDoc on Server updated. Sending to Client 2')
transporterServerTo2.sendMessage(message)
}
})
docClient1.on('update', (update: Uint8Array, origin: unknown) => {
if (origin !== transporterClient1) {
console.debug('YDoc on client 1 updated. Sending to Server')
}
})
docClient2.on('update', (update: Uint8Array, origin: unknown) => {
if (origin !== transporterClient2) {
console.debug('YDoc on client 2 updated. Sending to Server')
}
})
const yDocSyncAdapter1 = new YDocSyncClientAdapter(transporterClient1)
yDocSyncAdapter1.setYDoc(docClient1)
const yDocSyncAdapter2 = new YDocSyncClientAdapter(transporterClient2)
yDocSyncAdapter2.setYDoc(docClient2)
const yDocSyncAdapterServerTo1 = new YDocSyncServerAdapter(
transporterServerTo1
)
yDocSyncAdapterServerTo1.setYDoc(docServer)
const yDocSyncAdapterServerTo2 = new YDocSyncServerAdapter(
transporterServerTo2
)
yDocSyncAdapterServerTo2.setYDoc(docServer)
const waitForClient1Sync = new Promise<void>((resolve) => {
yDocSyncAdapter1.doAsSoonAsSynced(() => {
console.debug('client 1 received the first sync')
resolve()
})
})
const waitForClient2Sync = new Promise<void>((resolve) => {
yDocSyncAdapter2.doAsSoonAsSynced(() => {
console.debug('client 2 received the first sync')
resolve()
})
})
const waitForServerTo11Sync = new Promise<void>((resolve) => {
yDocSyncAdapterServerTo1.doAsSoonAsSynced(() => {
console.debug('server 1 received the first sync')
resolve()
})
})
const waitForServerTo21Sync = new Promise<void>((resolve) => {
yDocSyncAdapterServerTo2.doAsSoonAsSynced(() => {
console.debug('server 2 received the first sync')
resolve()
})
})
transporterClient1.connect(transporterServerTo1)
transporterClient2.connect(transporterServerTo2)
yDocSyncAdapter1.requestDocumentState()
yDocSyncAdapter2.requestDocumentState()
await Promise.all([
waitForClient1Sync,
waitForClient2Sync,
waitForServerTo11Sync,
waitForServerTo21Sync
])
textClient1.insert(0, 'test2')
textClient1.insert(0, 'test3')
textClient2.insert(0, 'test4')
expect(textServer.toString()).toBe('test4test3test2This is a test note')
expect(textClient1.toString()).toBe('test4test3test2This is a test note')
expect(textClient2.toString()).toBe('test4test3test2This is a test note')
docServer.destroy()
docClient1.destroy()
docClient2.destroy()
})
})

View file

@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageTransporter } from '../message-transporters/message-transporter.js'
import { Message, MessageType } from '../message-transporters/message.js'
import { Listener } from 'eventemitter2'
import { EventEmitter2 } from 'eventemitter2'
import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'
type EventMap = Record<'synced' | 'desynced', () => void>
/**
* Sends and processes messages that are used to first-synchronize and update a {@link Doc y-doc}.
*/
export abstract class YDocSyncAdapter {
public readonly eventEmitter = new EventEmitter2<EventMap>()
protected doc: Doc | undefined
private destroyYDocUpdateCallback: undefined | (() => void)
private destroyEventListenerCallback: undefined | (() => void)
private synced = false
constructor(protected readonly messageTransporter: MessageTransporter) {
this.bindDocumentSyncMessageEvents()
}
/**
* Executes the given callback as soon as the sync adapter has synchronized the y-doc.
* If the y-doc has already been synchronized then the callback is executed immediately.
*
* @param callback the callback to execute
* @return The event listener that waits for the sync event
*/
public doAsSoonAsSynced(callback: () => void): Listener {
if (this.isSynced()) {
callback()
}
return this.eventEmitter.on('synced', callback, {
objectify: true
}) as Listener
}
public getMessageTransporter(): MessageTransporter {
return this.messageTransporter
}
public isSynced(): boolean {
return this.synced
}
/**
* Sets the {@link Doc y-doc} that should be synchronized.
*
* @param doc the doc to synchronize.
*/
public setYDoc(doc: Doc | undefined): void {
this.doc = doc
this.destroyYDocUpdateCallback?.()
if (!doc) {
return
}
const yDocUpdateCallback = this.processDocUpdate.bind(this)
doc.on('update', yDocUpdateCallback)
this.destroyYDocUpdateCallback = () => doc.off('update', yDocUpdateCallback)
this.eventEmitter.emit('desynced')
}
public destroy(): void {
this.destroyYDocUpdateCallback?.()
this.destroyEventListenerCallback?.()
}
protected bindDocumentSyncMessageEvents(): void {
const stateRequestListener = this.messageTransporter.on(
MessageType.NOTE_CONTENT_STATE_REQUEST,
(payload) => {
if (this.doc) {
this.messageTransporter.sendMessage({
type: MessageType.NOTE_CONTENT_UPDATE,
payload: Array.from(
encodeStateAsUpdate(this.doc, new Uint8Array(payload.payload))
)
})
}
},
{ objectify: true }
) as Listener
const disconnectedListener = this.messageTransporter.on(
'disconnected',
() => {
this.synced = false
this.eventEmitter.emit('desynced')
this.destroy()
},
{ objectify: true }
) as Listener
const noteContentUpdateListener = this.messageTransporter.on(
MessageType.NOTE_CONTENT_UPDATE,
(payload) => {
if (this.doc) {
applyUpdate(this.doc, new Uint8Array(payload.payload), this)
}
},
{ objectify: true }
) as Listener
this.destroyEventListenerCallback = () => {
stateRequestListener.off()
disconnectedListener.off()
noteContentUpdateListener.off()
}
}
private processDocUpdate(update: Uint8Array, origin: unknown): void {
if (!this.isSynced() || origin === this) {
return
}
const message: Message<MessageType.NOTE_CONTENT_UPDATE> = {
type: MessageType.NOTE_CONTENT_UPDATE,
payload: Array.from(update)
}
this.messageTransporter.sendMessage(message)
}
protected markAsSynced(): void {
if (this.synced) {
return
}
this.synced = true
this.eventEmitter.emit('synced')
}
public requestDocumentState(): void {
if (this.doc) {
this.messageTransporter.sendMessage({
type: MessageType.NOTE_CONTENT_STATE_REQUEST,
payload: Array.from(encodeStateVector(this.doc))
})
}
}
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageType } from '../message-transporters/message.js'
import { YDocSyncAdapter } from './y-doc-sync-adapter.js'
export class YDocSyncClientAdapter extends YDocSyncAdapter {
protected bindDocumentSyncMessageEvents() {
super.bindDocumentSyncMessageEvents()
this.messageTransporter.on(MessageType.NOTE_CONTENT_UPDATE, () => {
this.markAsSynced()
})
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MessageTransporter } from '../message-transporters/message-transporter.js'
import { YDocSyncAdapter } from './y-doc-sync-adapter.js'
export class YDocSyncServerAdapter extends YDocSyncAdapter {
constructor(readonly messageTransporter: MessageTransporter) {
super(messageTransporter)
this.markAsSynced()
}
}

View file

@ -583,6 +583,9 @@
"text": "You were redirected to the history page, because the note you just edited was deleted." "text": "You were redirected to the history page, because the note you just edited was deleted."
} }
}, },
"realtime": {
"reconnect": "Reconnecting to HedgeDoc…"
},
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"editor": { "editor": {

View file

@ -121,8 +121,6 @@
"vega-lite": "5.6.1", "vega-lite": "5.6.1",
"words-count": "2.0.2", "words-count": "2.0.2",
"ws": "8.13.0", "ws": "8.13.0",
"y-codemirror.next": "0.3.2",
"y-protocols": "1.0.5",
"yjs": "13.5.51" "yjs": "13.5.51"
}, },
"devDependencies": { "devDependencies": {

View file

@ -20,6 +20,7 @@ import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-ent
import { Sidebar } from './sidebar/sidebar' import { Sidebar } from './sidebar/sidebar'
import { Splitter } from './splitter/splitter' import { Splitter } from './splitter/splitter'
import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props' import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
import { RealtimeConnectionModal } from './websocket-connection-modal/realtime-connection-modal'
import equal from 'fast-deep-equal' import equal from 'fast-deep-equal'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -79,7 +80,6 @@ export const EditorPageContent: React.FC = () => {
) )
useApplyDarkMode() useApplyDarkMode()
useUpdateLocalHistoryEntry() useUpdateLocalHistoryEntry()
const setRendererToScrollSource = useCallback(() => { const setRendererToScrollSource = useCallback(() => {
@ -129,6 +129,7 @@ export const EditorPageContent: React.FC = () => {
<CommunicatorImageLightbox /> <CommunicatorImageLightbox />
<HeadMetaProperties /> <HeadMetaProperties />
<MotdModal /> <MotdModal />
<RealtimeConnectionModal />
<div className={'d-flex flex-column vh-100'}> <div className={'d-flex flex-column vh-100'}>
<AppBar mode={AppBarMode.EDITOR} /> <AppBar mode={AppBarMode.EDITOR} />
<div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}> <div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}>

View file

@ -0,0 +1,90 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ChangeSpec, Transaction } from '@codemirror/state'
import { Annotation } from '@codemirror/state'
import type { EditorView, PluginValue } from '@codemirror/view'
import type { ViewUpdate } from '@codemirror/view'
import type { Text as YText } from 'yjs'
import type { Transaction as YTransaction, YTextEvent } from 'yjs'
const syncAnnotation = Annotation.define()
/**
* Synchronizes the content of a codemirror with a {@link YText y.js text channel}.
*/
export class YTextSyncViewPlugin implements PluginValue {
private readonly observer: YTextSyncViewPlugin['onYTextUpdate']
private firstUpdate = true
constructor(private view: EditorView, private readonly yText: YText, pluginLoaded: () => void) {
this.observer = this.onYTextUpdate.bind(this)
this.yText.observe(this.observer)
pluginLoaded()
}
private onYTextUpdate(event: YTextEvent, transaction: YTransaction): void {
if (transaction.origin === this) {
return
}
this.view.dispatch({ changes: this.calculateChanges(event), annotations: [syncAnnotation.of(this)] })
}
private calculateChanges(event: YTextEvent): ChangeSpec[] {
const [changes] = event.delta.reduce(
([changes, position], delta) => {
if (delta.insert !== undefined && typeof delta.insert === 'string') {
return [[...changes, { from: position, to: position, insert: delta.insert }], position]
} else if (delta.delete !== undefined) {
return [[...changes, { from: position, to: position + delta.delete, insert: '' }], position + delta.delete]
} else if (delta.retain !== undefined) {
return [changes, position + delta.retain]
} else {
return [changes, position]
}
},
[[], 0] as [ChangeSpec[], number]
)
return this.addDeleteAllChanges(changes)
}
private addDeleteAllChanges(changes: ChangeSpec[]): ChangeSpec[] {
if (this.firstUpdate) {
this.firstUpdate = false
return [{ from: 0, to: this.view.state.doc.length, insert: '' }, ...changes]
} else {
return changes
}
}
public update(update: ViewUpdate): void {
if (!update.docChanged) {
return
}
update.transactions
.filter((transaction) => transaction.annotation(syncAnnotation) !== this)
.forEach((transaction) => this.applyTransaction(transaction))
}
private applyTransaction(transaction: Transaction): void {
this.yText.doc?.transact(() => {
let positionAdjustment = 0
transaction.changes.iterChanges((fromA, toA, fromB, toB, insert) => {
const insertText = insert.sliceString(0, insert.length, '\n')
if (fromA !== toA) {
this.yText.delete(fromA + positionAdjustment, toA - fromA)
}
if (insertText.length > 0) {
this.yText.insert(fromA + positionAdjustment, insertText)
}
positionAdjustment += insertText.length - (toA - fromA)
})
}, this)
}
public destroy(): void {
this.yText.unobserve(this.observer)
}
}

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import styles from './cursor-colors.module.scss'
export const createCursorCssClass = (styleIndex: number): string => {
return styles[`cursor-${Math.max(Math.min(styleIndex, 7), 0)}`]
}

View file

@ -0,0 +1,37 @@
/*!
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.cursor-0 {
--color: #780c0c;
}
.cursor-1 {
--color: #ff1111;
}
.cursor-2 {
--color: #1149ff;
}
.cursor-3 {
--color: #11ff39;
}
.cursor-4 {
--color: #cb11ff;
}
.cursor-5 {
--color: #ffff00;
}
.cursor-6 {
--color: #00fff2;
}
.cursor-7 {
--color: #ff8000;
}

View file

@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createCursorCssClass } from './create-cursor-css-class'
import { RemoteCursorMarker } from './remote-cursor-marker'
import styles from './style.module.scss'
import type { Extension, Transaction } from '@codemirror/state'
import { EditorSelection, StateEffect, StateField } from '@codemirror/state'
import type { ViewUpdate } from '@codemirror/view'
import { layer, RectangleMarker } from '@codemirror/view'
import { Optional } from '@mrdrogdrog/optional'
import equal from 'fast-deep-equal'
export interface RemoteCursor {
displayName: string
from: number
to?: number
styleIndex: number
}
/**
* Used to provide a new set of {@link RemoteCursor remote cursors} to a codemirror state.
*/
export const remoteCursorUpdateEffect = StateEffect.define<RemoteCursor[]>()
/**
* Saves the currently visible {@link RemoteCursor remote cursors}
* and saves new cursors if a transaction with an {@link remoteCursorUpdateEffect update effect} has been dispatched.
*/
export const remoteCursorStateField = StateField.define<RemoteCursor[]>({
compare(a: RemoteCursor[], b: RemoteCursor[]): boolean {
return equal(a, b)
},
create(): RemoteCursor[] {
return []
},
update(currentValue: RemoteCursor[], transaction: Transaction): RemoteCursor[] {
return Optional.ofNullable(transaction.effects.find((effect) => effect.is(remoteCursorUpdateEffect)))
.map((remoteCursor) => remoteCursor.value as RemoteCursor[])
.orElse(currentValue)
}
})
/**
* Checks if the given {@link ViewUpdate view update} should trigger a rerender of remote cursor components.
* @param update The update to check
*/
const isRemoteCursorUpdate = (update: ViewUpdate): boolean => {
const effect = update.transactions
.flatMap((transaction) => transaction.effects)
.filter((effect) => effect.is(remoteCursorUpdateEffect))
return update.docChanged || update.viewportChanged || effect.length > 0
}
/**
* Creates the codemirror extension that renders the remote cursor selection layer.
* @return The created codemirror extension
*/
export const createCursorLayer = (): Extension =>
layer({
above: true,
class: styles.cursorLayer,
update: isRemoteCursorUpdate,
markers: (view) => {
return view.state.field(remoteCursorStateField).flatMap((remoteCursor) => {
const selectionRange = EditorSelection.cursor(remoteCursor.from)
return RemoteCursorMarker.createCursor(view, selectionRange, remoteCursor.displayName, remoteCursor.styleIndex)
})
}
})
/**
* Creates the codemirror extension that renders the blinking remote cursor layer.
* @return The created codemirror extension
*/
export const createSelectionLayer = (): Extension =>
layer({
above: false,
class: styles.selectionLayer,
update: isRemoteCursorUpdate,
markers: (view) => {
return view.state
.field(remoteCursorStateField)
.filter((remoteCursor) => remoteCursor.to !== undefined && remoteCursor.from !== remoteCursor.to)
.flatMap((remoteCursor) => {
const selectionRange = EditorSelection.range(remoteCursor.from, remoteCursor.to as number)
return RectangleMarker.forRange(
view,
`${styles.cursor} ${createCursorCssClass(remoteCursor.styleIndex)}`,
selectionRange
)
})
}
})

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RemoteCursor } from './cursor-layers-extensions'
import { remoteCursorUpdateEffect } from './cursor-layers-extensions'
import type { EditorView, PluginValue } from '@codemirror/view'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
/**
* Listens for remote cursor state messages from the backend and dispatches them into the codemirror.
*/
export class ReceiveRemoteCursorViewPlugin implements PluginValue {
private readonly listener: Listener
constructor(view: EditorView, messageTransporter: MessageTransporter) {
this.listener = messageTransporter.on(
MessageType.REALTIME_USER_STATE_SET,
({ payload }) => {
const cursors: RemoteCursor[] = payload.map((user) => ({
from: user.cursor.from,
to: user.cursor.to,
displayName: user.displayName,
styleIndex: user.styleIndex
}))
view.dispatch({
effects: [remoteCursorUpdateEffect.of(cursors)]
})
},
{ objectify: true }
) as Listener
}
destroy() {
this.listener.off()
}
}

View file

@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createCursorCssClass } from './create-cursor-css-class'
import styles from './style.module.scss'
import type { SelectionRange } from '@codemirror/state'
import type { LayerMarker, EditorView, Rect } from '@codemirror/view'
import { Direction } from '@codemirror/view'
/**
* Renders a blinking cursor to indicate the cursor of another user.
*/
export class RemoteCursorMarker implements LayerMarker {
constructor(
private left: number,
private top: number,
private height: number,
private name: string,
private styleIndex: number
) {}
draw(): HTMLElement {
const elt = document.createElement('div')
this.adjust(elt)
return elt
}
update(elt: HTMLElement): boolean {
this.adjust(elt)
return true
}
adjust(element: HTMLElement) {
element.style.left = `${this.left}px`
element.style.top = `${this.top}px`
element.style.height = `${this.height}px`
element.style.setProperty('--name', `"${this.name}"`)
element.className = `${styles.cursor} ${createCursorCssClass(this.styleIndex)}`
}
eq(other: RemoteCursorMarker): boolean {
return (
this.left === other.left && this.top === other.top && this.height === other.height && this.name === other.name
)
}
public static createCursor(
view: EditorView,
position: SelectionRange,
displayName: string,
styleIndex: number
): RemoteCursorMarker[] {
const absolutePosition = this.calculateAbsoluteCursorPosition(position, view)
if (!absolutePosition || styleIndex < 0) {
return []
}
const rect = view.scrollDOM.getBoundingClientRect()
const left = view.textDirection == Direction.LTR ? rect.left : rect.right - view.scrollDOM.clientWidth
const baseLeft = left - view.scrollDOM.scrollLeft
const baseTop = rect.top - view.scrollDOM.scrollTop
return [
new RemoteCursorMarker(
absolutePosition.left - baseLeft,
absolutePosition.top - baseTop,
absolutePosition.bottom - absolutePosition.top,
displayName,
styleIndex
)
]
}
private static calculateAbsoluteCursorPosition(position: SelectionRange, view: EditorView): Rect | null {
const cappedPositionHead = Math.max(0, Math.min(view.state.doc.length, position.head))
return view.coordsAtPos(cappedPositionHead, position.assoc || 1)
}
}

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { SelectionRange } from '@codemirror/state'
import type { EditorView, PluginValue, ViewUpdate } from '@codemirror/view'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
/**
* Sends the main cursor of a codemirror to the backend using a given {@link MessageTransporter}.
*/
export class SendCursorViewPlugin implements PluginValue {
private lastCursor: SelectionRange | undefined
private listener: Listener
constructor(private view: EditorView, private messageTransporter: MessageTransporter) {
this.listener = messageTransporter.doAsSoonAsReady(() => {
this.sendCursor(this.lastCursor)
})
}
destroy() {
this.listener.off()
}
update(update: ViewUpdate) {
if (!update.selectionSet && !update.focusChanged && !update.docChanged) {
return
}
this.sendCursor(update.state.selection.main)
}
private sendCursor(currentCursor: SelectionRange | undefined) {
if (
!this.messageTransporter.isReady() ||
currentCursor === undefined ||
(this.lastCursor?.to === currentCursor?.to && this.lastCursor?.from === currentCursor?.from)
) {
return
}
this.lastCursor = currentCursor
this.messageTransporter.sendMessage({
type: MessageType.REALTIME_USER_SINGLE_UPDATE,
payload: {
from: currentCursor.from ?? 0,
to: currentCursor?.to
}
})
}
}

View file

@ -0,0 +1,40 @@
/*!
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.cursorLayer {
--color: #868686;
.cursor {
border-left: 2px solid var(--color);
box-sizing: content-box;
&:hover {
&:before {
opacity: 1
}
}
&:before {
content: var(--name);
font-size: 0.8em;
background: var(--color);
position: absolute;
top: -1.2em;
right: 2px;
color: white;
padding: 2px 5px;
height: 20px;
opacity: 0;
transition: opacity 0.1s;
white-space: nowrap;
}
}
}
.selectionLayer {
--color: #868686;
.cursor {
background-color: var(--color);
opacity: 0.5;
}
}

View file

@ -4,31 +4,30 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useBaseUrl, ORIGIN } from '../../../hooks/common/use-base-url' import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url'
import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state' import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute' import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name' import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name'
import type { ScrollProps } from '../synced-scroll/scroll-props' import type { ScrollProps } from '../synced-scroll/scroll-props'
import styles from './extended-codemirror/codemirror.module.scss' import styles from './extended-codemirror/codemirror.module.scss'
import { useCodeMirrorFileInsertExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-insert-extension' import { useCodeMirrorFileInsertExtension } from './hooks/codemirror-extensions/use-code-mirror-file-insert-extension'
import { useCodeMirrorScrollWatchExtension } from './hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension' import { useCodeMirrorRemoteCursorsExtension } from './hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions'
import { useCodeMirrorSpellCheckExtension } from './hooks/code-mirror-extensions/use-code-mirror-spell-check-extension' import { useCodeMirrorScrollWatchExtension } from './hooks/codemirror-extensions/use-code-mirror-scroll-watch-extension'
import { useCodeMirrorSpellCheckExtension } from './hooks/codemirror-extensions/use-code-mirror-spell-check-extension'
import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer' import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer'
import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension' import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension'
import { useApplyScrollState } from './hooks/use-apply-scroll-state' import { useApplyScrollState } from './hooks/use-apply-scroll-state'
import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback' import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
import { useUpdateCodeMirrorReference } from './hooks/use-update-code-mirror-reference' import { useUpdateCodeMirrorReference } from './hooks/use-update-code-mirror-reference'
import { useAwareness } from './hooks/yjs/use-awareness'
import { useBindYTextToRedux } from './hooks/yjs/use-bind-y-text-to-redux' import { useBindYTextToRedux } from './hooks/yjs/use-bind-y-text-to-redux'
import { useCodeMirrorYjsExtension } from './hooks/yjs/use-code-mirror-yjs-extension' import { useCodeMirrorYjsExtension } from './hooks/yjs/use-code-mirror-yjs-extension'
import { useInsertNoteContentIntoYTextInMockModeEffect } from './hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect'
import { useIsConnectionSynced } from './hooks/yjs/use-is-connection-synced'
import { useMarkdownContentYText } from './hooks/yjs/use-markdown-content-y-text' import { useMarkdownContentYText } from './hooks/yjs/use-markdown-content-y-text'
import { useOnFirstEditorUpdateExtension } from './hooks/yjs/use-on-first-editor-update-extension'
import { useOnMetadataUpdated } from './hooks/yjs/use-on-metadata-updated' import { useOnMetadataUpdated } from './hooks/yjs/use-on-metadata-updated'
import { useOnNoteDeleted } from './hooks/yjs/use-on-note-deleted' import { useOnNoteDeleted } from './hooks/yjs/use-on-note-deleted'
import { useWebsocketConnection } from './hooks/yjs/use-websocket-connection' import { useRealtimeConnection } from './hooks/yjs/use-realtime-connection'
import { useReceiveRealtimeUsers } from './hooks/yjs/use-receive-realtime-users'
import { useYDoc } from './hooks/yjs/use-y-doc' import { useYDoc } from './hooks/yjs/use-y-doc'
import { useYDocSyncClientAdapter } from './hooks/yjs/use-y-doc-sync-client-adapter'
import { useLinter } from './linter/linter' import { useLinter } from './linter/linter'
import { MaxLengthWarning } from './max-length-warning/max-length-warning' import { MaxLengthWarning } from './max-length-warning/max-length-warning'
import { StatusBar } from './status-bar/status-bar' import { StatusBar } from './status-bar/status-bar'
@ -40,9 +39,11 @@ import { lintGutter } from '@codemirror/lint'
import { oneDark } from '@codemirror/theme-one-dark' import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import ReactCodeMirror from '@uiw/react-codemirror' import ReactCodeMirror from '@uiw/react-codemirror'
import React, { useMemo } from 'react' import React, { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export type EditorPaneProps = ScrollProps
/** /**
* Renders the text editor pane of the editor. * Renders the text editor pane of the editor.
* The used editor is {@link ReactCodeMirror code mirror}. * The used editor is {@link ReactCodeMirror code mirror}.
@ -52,41 +53,41 @@ import { useTranslation } from 'react-i18next'
* @param onMakeScrollSource The callback to request to become the scroll source. * @param onMakeScrollSource The callback to request to become the scroll source.
* @external {ReactCodeMirror} https://npmjs.com/@uiw/react-codemirror * @external {ReactCodeMirror} https://npmjs.com/@uiw/react-codemirror
*/ */
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => { export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
useApplyScrollState(scrollState) useApplyScrollState(scrollState)
const messageTransporter = useRealtimeConnection()
const yDoc = useYDoc(messageTransporter)
const yText = useMarkdownContentYText(yDoc)
const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll) const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll)
const tablePasteExtensions = useCodeMirrorTablePasteExtension() const tablePasteExtensions = useCodeMirrorTablePasteExtension()
const fileInsertExtension = useCodeMirrorFileInsertExtension() const fileInsertExtension = useCodeMirrorFileInsertExtension()
const spellCheckExtension = useCodeMirrorSpellCheckExtension() const spellCheckExtension = useCodeMirrorSpellCheckExtension()
const cursorActivityExtension = useCursorActivityCallback() const cursorActivityExtension = useCursorActivityCallback()
const updateViewContextExtension = useUpdateCodeMirrorReference() const updateViewContextExtension = useUpdateCodeMirrorReference()
const yDoc = useYDoc() const remoteCursorsExtension = useCodeMirrorRemoteCursorsExtension(messageTransporter)
const awareness = useAwareness(yDoc)
const yText = useMarkdownContentYText(yDoc)
const websocketConnection = useWebsocketConnection(yDoc, awareness)
const connectionSynced = useIsConnectionSynced(websocketConnection)
useBindYTextToRedux(yText)
useOnMetadataUpdated(websocketConnection)
useOnNoteDeleted(websocketConnection)
const yjsExtension = useCodeMirrorYjsExtension(yText, awareness) const linterExtension = useLinter()
const [firstEditorUpdateExtension, firstUpdateHappened] = useOnFirstEditorUpdateExtension()
useInsertNoteContentIntoYTextInMockModeEffect(firstUpdateHappened, websocketConnection) const syncAdapter = useYDocSyncClientAdapter(messageTransporter, yDoc)
const linter = useLinter() const yjsExtension = useCodeMirrorYjsExtension(yText, syncAdapter)
useOnMetadataUpdated(messageTransporter)
useOnNoteDeleted(messageTransporter)
useBindYTextToRedux(yText)
useReceiveRealtimeUsers(messageTransporter)
const extensions = useMemo( const extensions = useMemo(
() => [ () => [
linter, linterExtension,
lintGutter(), lintGutter(),
markdown({ markdown({
base: markdownLanguage, base: markdownLanguage,
codeLanguages: (input) => findLanguageByCodeBlockName(languages, input) codeLanguages: (input) => findLanguageByCodeBlockName(languages, input)
}), }),
remoteCursorsExtension,
EditorView.lineWrapping, EditorView.lineWrapping,
editorScrollExtension, editorScrollExtension,
tablePasteExtensions, tablePasteExtensions,
@ -95,34 +96,40 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
cursorActivityExtension, cursorActivityExtension,
updateViewContextExtension, updateViewContextExtension,
yjsExtension, yjsExtension,
firstEditorUpdateExtension,
spellCheckExtension spellCheckExtension
], ],
[ [
linter, linterExtension,
remoteCursorsExtension,
editorScrollExtension, editorScrollExtension,
tablePasteExtensions, tablePasteExtensions,
fileInsertExtension, fileInsertExtension,
cursorActivityExtension, cursorActivityExtension,
updateViewContextExtension, updateViewContextExtension,
yjsExtension, yjsExtension,
firstEditorUpdateExtension,
spellCheckExtension spellCheckExtension
] ]
) )
useOnImageUploadFromRenderer() useOnImageUploadFromRenderer()
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
const codeMirrorClassName = useMemo( const codeMirrorClassName = useMemo(
() => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`, () => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`,
[ligaturesEnabled] [ligaturesEnabled]
) )
const { t } = useTranslation() const { t } = useTranslation()
const darkModeActivated = useDarkModeState() const darkModeActivated = useDarkModeState()
const editorOrigin = useBaseUrl(ORIGIN.EDITOR) const editorOrigin = useBaseUrl(ORIGIN.EDITOR)
const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced)
useEffect(() => {
const listener = messageTransporter.doAsSoonAsConnected(() => messageTransporter.sendReady())
return () => {
listener.off()
}
}, [messageTransporter])
return ( return (
<div <div
@ -130,11 +137,11 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
onTouchStart={onMakeScrollSource} onTouchStart={onMakeScrollSource}
onMouseEnter={onMakeScrollSource} onMouseEnter={onMakeScrollSource}
{...cypressId('editor-pane')} {...cypressId('editor-pane')}
{...cypressAttribute('editor-ready', String(firstUpdateHappened && connectionSynced))}> {...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced))}>
<MaxLengthWarning /> <MaxLengthWarning />
<ToolBar /> <ToolBar />
<ReactCodeMirror <ReactCodeMirror
editable={firstUpdateHappened && connectionSynced} editable={updateViewContextExtension !== null && isSynced}
placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''} placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''}
extensions={extensions} extensions={extensions}
width={'100%'} width={'100%'}

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
createCursorLayer,
createSelectionLayer,
remoteCursorStateField
} from '../../codemirror-extensions/remote-cursors/cursor-layers-extensions'
import { ReceiveRemoteCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin'
import { SendCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/send-cursor-view-plugin'
import type { Extension } from '@codemirror/state'
import { ViewPlugin } from '@codemirror/view'
import type { MessageTransporter } from '@hedgedoc/commons'
import { useMemo } from 'react'
/**
* Bundles all extensions that are needed for the remote cursor display.
* @return The created codemirror extensions
*/
export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension =>
useMemo(
() => [
remoteCursorStateField.extension,
createCursorLayer(),
createSelectionLayer(),
ViewPlugin.define((view) => new ReceiveRemoteCursorViewPlugin(view, messageTransporter)),
ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter))
],
[messageTransporter]
)

View file

@ -1,38 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MARKDOWN_CONTENT_CHANNEL_NAME, YDocMessageTransporter } from '@hedgedoc/commons'
import type { Awareness } from 'y-protocols/awareness'
import type { Doc } from 'yjs'
/**
* A mocked connection that doesn't send or receive any data and is instantly ready.
*/
export class MockConnection extends YDocMessageTransporter {
constructor(doc: Doc, awareness: Awareness) {
super(doc, awareness)
this.onOpen()
this.emit('ready')
}
/**
* Simulates a complete sync from the server by inserting the given content at position 0 of the editor yText channel.
*
* @param content The content to insert
*/
public simulateFirstSync(content: string): void {
const yText = this.doc.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
yText.insert(0, content)
super.markAsSynced()
}
disconnect(): void {
//Intentionally left empty because this is a mocked connection
}
send(): void {
//Intentionally left empty because this is a mocked connection
}
}

View file

@ -1,89 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { addOnlineUser, removeOnlineUser } from '../../../../../redux/realtime/methods'
import { ActiveIndicatorStatus } from '../../../../../redux/realtime/types'
import { Logger } from '../../../../../utils/logger'
import { useEffect, useMemo } from 'react'
import { Awareness } from 'y-protocols/awareness'
import type { Doc } from 'yjs'
const ownAwarenessClientId = -1
interface UserAwarenessState {
user: {
name: string
color: string
}
}
// TODO: [mrdrogdrog] move this code to the server for the initial color setting.
const userColors = [
{ color: '#30bced', light: '#30bced33' },
{ color: '#6eeb83', light: '#6eeb8333' },
{ color: '#ffbc42', light: '#ffbc4233' },
{ color: '#ecd444', light: '#ecd44433' },
{ color: '#ee6352', light: '#ee635233' },
{ color: '#9ac2c9', light: '#9ac2c933' },
{ color: '#8acb88', light: '#8acb8833' },
{ color: '#1be7ff', light: '#1be7ff33' }
]
const logger = new Logger('useAwareness')
/**
* Creates an {@link Awareness awareness}, sets the own values (like name, color, etc.) for other clients and writes state changes into the global application state.
*
* @param yDoc The {@link Doc yjs document} that handles the communication.
* @return The created {@link Awareness awareness}
*/
export const useAwareness = (yDoc: Doc): Awareness => {
const ownUsername = useApplicationState((state) => state.user?.username)
const awareness = useMemo(() => new Awareness(yDoc), [yDoc])
useEffect(() => {
const userColor = userColors[Math.floor(Math.random() * 8)]
if (ownUsername !== undefined) {
awareness.setLocalStateField('user', {
name: ownUsername,
color: userColor.color,
colorLight: userColor.light
})
addOnlineUser(ownAwarenessClientId, {
active: ActiveIndicatorStatus.ACTIVE,
color: userColor.color,
username: ownUsername
})
}
const awarenessCallback = ({ added, removed }: { added: number[]; removed: number[] }): void => {
added.forEach((addedId) => {
const state = awareness.getStates().get(addedId) as UserAwarenessState | undefined
if (!state) {
logger.debug('Could not find state for user')
return
}
logger.debug(`added awareness ${addedId}`, state.user)
addOnlineUser(addedId, {
active: ActiveIndicatorStatus.ACTIVE,
color: state.user.color,
username: state.user.name
})
})
removed.forEach((removedId) => {
logger.debug(`remove awareness ${removedId}`)
removeOnlineUser(removedId)
})
}
awareness.on('change', awarenessCallback)
return () => {
awareness.off('change', awarenessCallback)
removeOnlineUser(ownAwarenessClientId)
}
}, [awareness, ownUsername])
return awareness
}

View file

@ -12,8 +12,11 @@ import type { YText } from 'yjs/dist/src/types/YText'
* *
* @param yText The source text * @param yText The source text
*/ */
export const useBindYTextToRedux = (yText: YText): void => { export const useBindYTextToRedux = (yText: YText | undefined): void => {
useEffect(() => { useEffect(() => {
if (!yText) {
return
}
const yTextCallback = () => setNoteContent(yText.toString()) const yTextCallback = () => setNoteContent(yText.toString())
yText.observe(yTextCallback) yText.observe(yTextCallback)
return () => yText.unobserve(yTextCallback) return () => yText.unobserve(yTextCallback)

View file

@ -3,19 +3,35 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { YTextSyncViewPlugin } from '../../codemirror-extensions/document-sync/y-text-sync-view-plugin'
import type { Extension } from '@codemirror/state' import type { Extension } from '@codemirror/state'
import { useMemo } from 'react' import { ViewPlugin } from '@codemirror/view'
import { yCollab } from 'y-codemirror.next' import type { YDocSyncClientAdapter } from '@hedgedoc/commons'
import type { Awareness } from 'y-protocols/awareness' import { useEffect, useMemo, useState } from 'react'
import type { YText } from 'yjs/dist/src/types/YText' import type { Text as YText } from 'yjs'
/** /**
* Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext} and {@link Awareness awareness}. * Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext}.
* *
* @param yText The source and target for the editor content * @param yText The source and target for the editor content
* @param awareness Contains cursor positions and names from other clients that will be shown * @param syncAdapter The sync adapter that processes the communication for content synchronisation.
* @return the created extension * @return the created extension
*/ */
export const useCodeMirrorYjsExtension = (yText: YText, awareness: Awareness): Extension => { export const useCodeMirrorYjsExtension = (yText: YText | undefined, syncAdapter: YDocSyncClientAdapter): Extension => {
return useMemo(() => yCollab(yText, awareness), [awareness, yText]) const [editorReady, setEditorReady] = useState(false)
const synchronized = useApplicationState((state) => state.realtimeStatus.isSynced)
const connected = useApplicationState((state) => state.realtimeStatus.isConnected)
useEffect(() => {
if (editorReady && connected && !synchronized && yText) {
syncAdapter.requestDocumentState()
}
}, [connected, editorReady, syncAdapter, synchronized, yText])
return useMemo(
() =>
yText ? [ViewPlugin.define((view) => new YTextSyncViewPlugin(view, yText, () => setEditorReady(true)))] : [],
[yText]
)
} }

View file

@ -1,34 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getGlobalState } from '../../../../../redux'
import { isMockMode } from '../../../../../utils/test-modes'
import { MockConnection } from './mock-connection'
import type { YDocMessageTransporter } from '@hedgedoc/commons'
import { useEffect } from 'react'
/**
* When in mock mode this effect inserts the current markdown content into the yDoc of the given connection to simulate a sync from the server.
* This should happen only one time because after that the editor writes its changes into the yText which writes it into the redux.
*
* Usually the CodeMirror gets its content from yjs sync via websocket. But in mock mode this connection isn't available.
* That's why this hook inserts the current markdown content, that is currently saved in the global application state
* and was saved there by the {@link NoteLoadingBoundary note loading boundary}, into the y-text to write it into the codemirror.
* This has to be done AFTER the CodeMirror sync extension (yCollab) has been loaded because the extension reacts only to updates of the yText
* and doesn't write the existing content into the editor when being loaded.
*
* @param connection The connection into whose yDoc the content should be written
* @param firstUpdateHappened Defines if the first update already happened
*/
export const useInsertNoteContentIntoYTextInMockModeEffect = (
firstUpdateHappened: boolean,
connection: YDocMessageTransporter
): void => {
useEffect(() => {
if (firstUpdateHappened && isMockMode && connection instanceof MockConnection) {
connection.simulateFirstSync(getGlobalState().noteDetails.markdownContent.plain)
}
}, [firstUpdateHappened, connection])
}

View file

@ -1,30 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { YDocMessageTransporter } from '@hedgedoc/commons'
import { useEffect, useState } from 'react'
/**
* Checks if the given message transporter has received at least one full synchronisation.
*
* @param connection The connection whose sync status should be checked
* @return If at least one full synchronisation is occurred.
*/
export const useIsConnectionSynced = (connection: YDocMessageTransporter): boolean => {
const [editorEnabled, setEditorEnabled] = useState<boolean>(false)
useEffect(() => {
const enableEditor = () => setEditorEnabled(true)
const disableEditor = () => setEditorEnabled(false)
connection.on('synced', enableEditor)
connection.on('disconnected', disableEditor)
return () => {
connection.off('synced', enableEditor)
connection.off('disconnected', disableEditor)
}
}, [connection])
return editorEnabled
}

View file

@ -3,9 +3,8 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { MARKDOWN_CONTENT_CHANNEL_NAME } from '@hedgedoc/commons' import type { RealtimeDoc } from '@hedgedoc/commons'
import { useMemo } from 'react' import { useMemo } from 'react'
import type { Doc } from 'yjs'
import type { Text as YText } from 'yjs' import type { Text as YText } from 'yjs'
/** /**
@ -14,6 +13,6 @@ import type { Text as YText } from 'yjs'
* @param yDoc The yjs document from which the yText should be extracted * @param yDoc The yjs document from which the yText should be extracted
* @return the extracted yText channel * @return the extracted yText channel
*/ */
export const useMarkdownContentYText = (yDoc: Doc): YText => { export const useMarkdownContentYText = (yDoc: RealtimeDoc | undefined): YText | undefined => {
return useMemo(() => yDoc.getText(MARKDOWN_CONTENT_CHANNEL_NAME), [yDoc]) return useMemo(() => yDoc?.getMarkdownContentChannel(), [yDoc])
} }

View file

@ -1,19 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { useMemo, useState } from 'react'
/**
* Provides an extension that checks when the code mirror, that loads the extension, has its first update.
*
* @return The extension that listens for editor updates and a boolean that defines if the first update already happened
*/
export const useOnFirstEditorUpdateExtension = (): [Extension, boolean] => {
const [firstUpdateHappened, setFirstUpdateHappened] = useState<boolean>(false)
const extension = useMemo(() => EditorView.updateListener.of(() => setFirstUpdateHappened(true)), [])
return [extension, firstUpdateHappened]
}

View file

@ -4,24 +4,23 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { updateMetadata } from '../../../../../redux/note-details/methods' import { updateMetadata } from '../../../../../redux/note-details/methods'
import type { YDocMessageTransporter } from '@hedgedoc/commons' import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons' import { MessageType } from '@hedgedoc/commons'
import { useCallback, useEffect } from 'react' import type { Listener } from 'eventemitter2'
import { useEffect } from 'react'
/** /**
* Hook that updates the metadata if the server announced an update of the metadata. * Hook that updates the metadata if the server announced an update of the metadata.
* *
* @param websocketConnection The websocket connection that emits the metadata changed event * @param websocketConnection The websocket connection that emits the metadata changed event
*/ */
export const useOnMetadataUpdated = (websocketConnection: YDocMessageTransporter): void => { export const useOnMetadataUpdated = (websocketConnection: MessageTransporter): void => {
const updateMetadataHandler = useCallback(async () => {
await updateMetadata()
}, [])
useEffect(() => { useEffect(() => {
websocketConnection.on(String(MessageType.METADATA_UPDATED), () => void updateMetadataHandler()) const listener = websocketConnection.on(MessageType.METADATA_UPDATED, () => void updateMetadata(), {
objectify: true
}) as Listener
return () => { return () => {
websocketConnection.off(String(MessageType.METADATA_UPDATED), () => void updateMetadataHandler()) listener.off()
} }
}) }, [websocketConnection])
} }

View file

@ -6,8 +6,9 @@
import { useApplicationState } from '../../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { Logger } from '../../../../../utils/logger' import { Logger } from '../../../../../utils/logger'
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary' import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
import type { YDocMessageTransporter } from '@hedgedoc/commons' import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons' import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
@ -18,7 +19,7 @@ const logger = new Logger('UseOnNoteDeleted')
* *
* @param websocketConnection The websocket connection that emits the deletion event * @param websocketConnection The websocket connection that emits the deletion event
*/ */
export const useOnNoteDeleted = (websocketConnection: YDocMessageTransporter): void => { export const useOnNoteDeleted = (websocketConnection: MessageTransporter): void => {
const router = useRouter() const router = useRouter()
const noteTitle = useApplicationState((state) => state.noteDetails.title) const noteTitle = useApplicationState((state) => state.noteDetails.title)
const { dispatchUiNotification } = useUiNotifications() const { dispatchUiNotification } = useUiNotifications()
@ -35,9 +36,11 @@ export const useOnNoteDeleted = (websocketConnection: YDocMessageTransporter): v
}, [router, noteTitle, dispatchUiNotification]) }, [router, noteTitle, dispatchUiNotification])
useEffect(() => { useEffect(() => {
websocketConnection.on(String(MessageType.DOCUMENT_DELETED), noteDeletedHandler) const listener = websocketConnection.on(MessageType.DOCUMENT_DELETED, noteDeletedHandler, {
objectify: true
}) as Listener
return () => { return () => {
websocketConnection.off(String(MessageType.DOCUMENT_DELETED), noteDeletedHandler) listener.off()
} }
}) }, [noteDeletedHandler, websocketConnection])
} }

View file

@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { getGlobalState } from '../../../../../redux'
import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods'
import { Logger } from '../../../../../utils/logger'
import { isMockMode } from '../../../../../utils/test-modes'
import { useWebsocketUrl } from './use-websocket-url'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MockedBackendMessageTransporter, WebsocketTransporter } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
import WebSocket from 'isomorphic-ws'
import { useCallback, useEffect, useMemo, useRef } from 'react'
const logger = new Logger('websocket connection')
const WEBSOCKET_RECONNECT_INTERVAL = 3000
/**
* Creates a {@link WebsocketTransporter websocket message transporter} that handles the realtime communication with the backend.
*
* @return the created connection handler
*/
export const useRealtimeConnection = (): MessageTransporter => {
const websocketUrl = useWebsocketUrl()
const messageTransporter = useMemo(() => {
if (isMockMode) {
logger.debug('Creating Loopback connection...')
return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain)
} else {
logger.debug('Creating Websocket connection...')
return new WebsocketTransporter()
}
}, [])
const establishWebsocketConnection = useCallback(() => {
if (messageTransporter instanceof WebsocketTransporter && websocketUrl) {
logger.debug(`Connecting to ${websocketUrl.toString()}`)
const socket = new WebSocket(websocketUrl)
socket.addEventListener('error', () => {
setTimeout(() => {
establishWebsocketConnection()
}, WEBSOCKET_RECONNECT_INTERVAL)
})
socket.addEventListener('open', () => {
messageTransporter.setWebsocket(socket)
})
}
}, [messageTransporter, websocketUrl])
const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
const firstConnect = useRef(true)
const reconnectTimeout = useRef<number | undefined>(undefined)
useEffect(() => {
if (isConnected) {
return
}
if (firstConnect.current) {
establishWebsocketConnection()
firstConnect.current = false
} else {
reconnectTimeout.current = window.setTimeout(() => {
establishWebsocketConnection()
}, WEBSOCKET_RECONNECT_INTERVAL)
}
}, [establishWebsocketConnection, isConnected, messageTransporter])
useEffect(() => {
const readyListener = messageTransporter.doAsSoonAsReady(() => {
const timerId = reconnectTimeout.current
if (timerId !== undefined) {
window.clearTimeout(timerId)
}
reconnectTimeout.current = undefined
})
messageTransporter.on('connected', () => logger.debug(`Connected`))
messageTransporter.on('disconnected', () => logger.debug(`Disconnected`))
return () => {
const interval = reconnectTimeout.current
interval && window.clearTimeout(interval)
readyListener.off()
}
}, [messageTransporter])
useEffect(() => {
const disconnectCallback = () => messageTransporter.disconnect()
window.addEventListener('beforeunload', disconnectCallback)
return () => window.removeEventListener('beforeunload', disconnectCallback)
}, [messageTransporter])
useEffect(() => {
const connectedListener = messageTransporter.doAsSoonAsConnected(() => setRealtimeConnectionState(true))
const disconnectedListener = messageTransporter.on('disconnected', () => setRealtimeConnectionState(false), {
objectify: true
}) as Listener
return () => {
connectedListener.off()
disconnectedListener.off()
}
}, [messageTransporter])
return messageTransporter
}

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
import { setRealtimeUsers } from '../../../../../redux/realtime/methods'
import type { MessageTransporter } from '@hedgedoc/commons'
import { MessageType } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
import { useEffect } from 'react'
/**
* Waits for remote cursor updates that are sent from the backend and saves them in the global application state.
*
* @param messageTransporter the {@link MessageTransporter} that should be used to receive the remote cursor updates
*/
export const useReceiveRealtimeUsers = (messageTransporter: MessageTransporter): void => {
const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
useEffect(() => {
const listener = messageTransporter.on(
MessageType.REALTIME_USER_STATE_SET,
(payload) => setRealtimeUsers(payload.payload),
{ objectify: true }
) as Listener
return () => {
listener.off()
}
}, [messageTransporter])
useEffect(() => {
if (isConnected) {
messageTransporter.sendMessage({ type: MessageType.REALTIME_USER_STATE_REQUEST })
}
}, [isConnected, messageTransporter])
}

View file

@ -1,39 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isMockMode } from '../../../../../utils/test-modes'
import { MockConnection } from './mock-connection'
import { useWebsocketUrl } from './use-websocket-url'
import { WebsocketConnection } from './websocket-connection'
import type { YDocMessageTransporter } from '@hedgedoc/commons'
import { useEffect, useMemo } from 'react'
import type { Awareness } from 'y-protocols/awareness'
import type { Doc } from 'yjs'
/**
* Creates a {@link WebsocketConnection websocket connection handler } that handles the realtime communication with the backend.
*
* @param yDoc The {@link Doc y-doc} that should be synchronized with the backend
* @param awareness The {@link Awareness awareness} that should be synchronized with the backend.
* @return the created connection handler
*/
export const useWebsocketConnection = (yDoc: Doc, awareness: Awareness): YDocMessageTransporter => {
const websocketUrl = useWebsocketUrl()
const websocketConnection: YDocMessageTransporter = useMemo(() => {
return isMockMode ? new MockConnection(yDoc, awareness) : new WebsocketConnection(websocketUrl, yDoc, awareness)
}, [awareness, websocketUrl, yDoc])
useEffect(() => {
const disconnectCallback = () => websocketConnection.disconnect()
window.addEventListener('beforeunload', disconnectCallback)
return () => {
window.removeEventListener('beforeunload', disconnectCallback)
disconnectCallback()
}
}, [websocketConnection])
return websocketConnection
}

View file

@ -13,7 +13,7 @@ const LOCAL_FALLBACK_URL = 'ws://localhost:8080/realtime/'
/** /**
* Provides the URL for the realtime endpoint. * Provides the URL for the realtime endpoint.
*/ */
export const useWebsocketUrl = (): URL => { export const useWebsocketUrl = (): URL | undefined => {
const noteId = useApplicationState((state) => state.noteDetails.id) const noteId = useApplicationState((state) => state.noteDetails.id)
const baseUrl = useBaseUrl() const baseUrl = useBaseUrl()
@ -33,6 +33,9 @@ export const useWebsocketUrl = (): URL => {
}, [baseUrl]) }, [baseUrl])
return useMemo(() => { return useMemo(() => {
if (noteId === '') {
return
}
const url = new URL(websocketUrl) const url = new URL(websocketUrl)
url.search = `?noteId=${noteId}` url.search = `?noteId=${noteId}`
return url return url

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { setRealtimeSyncedState } from '../../../../../redux/realtime/methods'
import { Logger } from '../../../../../utils/logger'
import type { MessageTransporter } from '@hedgedoc/commons'
import { YDocSyncClientAdapter } from '@hedgedoc/commons'
import type { Listener } from 'eventemitter2'
import { useEffect, useMemo } from 'react'
import type { Doc } from 'yjs'
const logger = new Logger('useYDocSyncClient')
/**
* Creates a {@link YDocSyncClientAdapter} and mirrors its sync state to the global application state.
*
* @param messageTransporter The {@link MessageTransporter message transporter} that sends and receives messages for the synchronisation
* @param yDoc The {@link Doc y-doc} that should be synchronized
* @return the created adapter
*/
export const useYDocSyncClientAdapter = (
messageTransporter: MessageTransporter,
yDoc: Doc | undefined
): YDocSyncClientAdapter => {
const syncAdapter = useMemo(() => new YDocSyncClientAdapter(messageTransporter), [messageTransporter])
useEffect(() => {
syncAdapter.setYDoc(yDoc)
}, [syncAdapter, yDoc])
useEffect(() => {
const onceSyncedListener = syncAdapter.doAsSoonAsSynced(() => {
logger.debug('YDoc synced')
setRealtimeSyncedState(true)
})
const desyncedListener = syncAdapter.eventEmitter.on(
'desynced',
() => {
logger.debug('YDoc de-synced')
setRealtimeSyncedState(false)
},
{
objectify: true
}
) as Listener
return () => {
onceSyncedListener.off()
desyncedListener.off()
}
}, [messageTransporter, syncAdapter])
return syncAdapter
}

View file

@ -3,16 +3,28 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useEffect, useMemo } from 'react' import type { MessageTransporter } from '@hedgedoc/commons'
import { Doc } from 'yjs' import { RealtimeDoc } from '@hedgedoc/commons'
import { useEffect, useState } from 'react'
/** /**
* Creates a new {@link Doc y-doc}. * Creates a new {@link RealtimeDoc y-doc}.
* *
* @return The created {@link Doc y-doc} * @return The created {@link RealtimeDoc y-doc}
*/ */
export const useYDoc = (): Doc => { export const useYDoc = (messageTransporter: MessageTransporter): RealtimeDoc | undefined => {
const yDoc = useMemo(() => new Doc(), []) const [yDoc, setYDoc] = useState<RealtimeDoc>()
useEffect(() => () => yDoc.destroy(), [yDoc])
useEffect(() => {
messageTransporter.doAsSoonAsConnected(() => {
setYDoc(new RealtimeDoc())
})
messageTransporter.on('disconnected', () => {
setYDoc(undefined)
})
}, [messageTransporter])
useEffect(() => () => yDoc?.destroy(), [yDoc])
return yDoc return yDoc
} }

View file

@ -1,59 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
encodeAwarenessUpdateMessage,
encodeCompleteAwarenessStateRequestMessage,
encodeDocumentUpdateMessage,
WebsocketTransporter
} from '@hedgedoc/commons'
import WebSocket from 'isomorphic-ws'
import type { Awareness } from 'y-protocols/awareness'
import type { Doc } from 'yjs'
/**
* Handles the communication with the realtime endpoint of the backend and synchronizes the given y-doc and awareness with other clients.
*/
export class WebsocketConnection extends WebsocketTransporter {
constructor(url: URL, doc: Doc, awareness: Awareness) {
super(doc, awareness)
this.bindYDocEvents(doc)
this.bindAwarenessMessageEvents(awareness)
const websocket = new WebSocket(url)
this.setupWebsocket(websocket)
}
private bindAwarenessMessageEvents(awareness: Awareness) {
const updateCallback = (
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: unknown
) => {
if (origin !== this) {
this.send(encodeAwarenessUpdateMessage(awareness, [...added, ...updated, ...removed]))
}
}
this.on('disconnected', () => {
awareness.off('update', updateCallback)
awareness.destroy()
})
this.on('ready', () => {
awareness.on('update', updateCallback)
})
this.on('synced', () => {
this.send(encodeCompleteAwarenessStateRequestMessage())
this.send(encodeAwarenessUpdateMessage(awareness, [awareness.doc.clientID]))
})
}
private bindYDocEvents(doc: Doc): void {
doc.on('destroy', () => this.disconnect())
doc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin !== this && this.isSynced() && this.isWebSocketOpen()) {
this.send(encodeDocumentUpdateMessage(update))
}
})
}
}

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { resetRealtimeStatus } from '../../redux/realtime/methods'
import { LoadingScreen } from '../application-loader/loading-screen/loading-screen'
import type { PropsWithChildren } from 'react'
import React, { Fragment, useEffect, useState } from 'react'
/**
* Resets the realtime status in the global application state to its initial state before loading the given child elements.
*
* @param children The children to load after the reset
*/
export const ResetRealtimeStateBoundary: React.FC<PropsWithChildren> = ({ children }) => {
const [globalStateInitialized, setGlobalStateInitialized] = useState(false)
useEffect(() => {
resetRealtimeStatus()
setGlobalStateInitialized(true)
}, [])
if (!globalStateInitialized) {
return <LoadingScreen />
} else {
return <Fragment>{children}</Fragment>
}
}

View file

@ -5,7 +5,7 @@
*/ */
.user-line-color-indicator { .user-line-color-indicator {
border-left: 3px solid; border-left: 3px solid var(--color);
min-height: 30px; min-height: 30px;
height: 100%; height: 100%;
flex: 0 0 3px; flex: 0 0 3px;

View file

@ -3,16 +3,16 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username' import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username'
import { createCursorCssClass } from '../../editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class'
import { ActiveIndicator } from '../users-online-sidebar-menu/active-indicator' import { ActiveIndicator } from '../users-online-sidebar-menu/active-indicator'
import styles from './user-line.module.scss' import styles from './user-line.module.scss'
import React from 'react' import React from 'react'
export interface UserLineProps { export interface UserLineProps {
username: string | null username: string | null
color: string active: boolean
status: ActiveIndicatorStatus color: number
} }
/** /**
@ -22,19 +22,20 @@ export interface UserLineProps {
* @param color The color of the user's edits. * @param color The color of the user's edits.
* @param status The user's current online status. * @param status The user's current online status.
*/ */
export const UserLine: React.FC<UserLineProps> = ({ username, color, status }) => { export const UserLine: React.FC<UserLineProps> = ({ username, active, color }) => {
return ( return (
<div className={'d-flex align-items-center h-100 w-100'}> <div className={'d-flex align-items-center h-100 w-100'}>
<div <div
className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']}`} className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']} ${createCursorCssClass(
style={{ borderLeftColor: color }} color
)}`}
/> />
<UserAvatarForUsername <UserAvatarForUsername
username={username} username={username}
additionalClasses={'flex-fill overflow-hidden px-2 text-nowrap w-100'} additionalClasses={'flex-fill overflow-hidden px-2 text-nowrap w-100'}
/> />
<div className={styles['active-indicator-container']}> <div className={styles['active-indicator-container']}>
<ActiveIndicator status={status} /> <ActiveIndicator active={active} />
</div> </div>
</div> </div>
) )

View file

@ -3,12 +3,11 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
import styles from './active-indicator.module.scss' import styles from './active-indicator.module.scss'
import React from 'react' import React from 'react'
export interface ActiveIndicatorProps { export interface ActiveIndicatorProps {
status: ActiveIndicatorStatus active: boolean
} }
/** /**
@ -16,6 +15,6 @@ export interface ActiveIndicatorProps {
* *
* @param status The state of the indicator to render * @param status The state of the indicator to render
*/ */
export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ status }) => { export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ active }) => {
return <span className={`${styles['activeIndicator']} ${status}`} /> return <span className={`${styles['activeIndicator']} ${active ? styles.active : styles.inactive}`} />
} }

View file

@ -31,21 +31,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
selectedMenuId selectedMenuId
}) => { }) => {
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
const onlineUsers = useApplicationState((state) => state.realtime.users) const realtimeUsers = useApplicationState((state) => state.realtimeStatus.onlineUsers)
useTranslation() useTranslation()
useEffect(() => { useEffect(() => {
const value = `${Object.keys(onlineUsers).length}` buttonRef.current?.style.setProperty('--users-online', `"${realtimeUsers.length}"`)
buttonRef.current?.style.setProperty('--users-online', `"${value}"`) }, [realtimeUsers])
}, [onlineUsers])
const hide = selectedMenuId !== DocumentSidebarMenuSelection.NONE && selectedMenuId !== menuId const hide = selectedMenuId !== DocumentSidebarMenuSelection.NONE && selectedMenuId !== menuId
const expand = selectedMenuId === menuId const expand = selectedMenuId === menuId
const onClickHandler = useCallback(() => onClick(menuId), [menuId, onClick]) const onClickHandler = useCallback(() => onClick(menuId), [menuId, onClick])
const onlineUserElements = useMemo(() => { const onlineUserElements = useMemo(() => {
const entries = Object.entries(onlineUsers) if (realtimeUsers.length === 0) {
if (entries.length === 0) {
return ( return (
<SidebarButton> <SidebarButton>
<span className={'ms-3'}> <span className={'ms-3'}>
@ -54,15 +52,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
</SidebarButton> </SidebarButton>
) )
} else { } else {
return entries.map(([clientId, onlineUser]) => { return realtimeUsers.map((realtimeUser) => {
return ( return (
<SidebarButton key={clientId}> <SidebarButton key={realtimeUser.styleIndex}>
<UserLine username={onlineUser.username} color={onlineUser.color} status={onlineUser.active} /> <UserLine
username={realtimeUser.displayName}
color={realtimeUser.styleIndex}
active={realtimeUser.active}
/>
</SidebarButton> </SidebarButton>
) )
}) })
} }
}, [onlineUsers]) }, [realtimeUsers])
return ( return (
<Fragment> <Fragment>

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { WaitSpinner } from '../../common/wait-spinner/wait-spinner'
import React from 'react'
import { Col, Container, Modal, Row } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
/**
* Modal with a spinner that is only shown while reconnecting to the realtime backend
*/
export const RealtimeConnectionModal: React.FC = () => {
const isConnected = useApplicationState((state) => state.realtimeStatus.isSynced)
useTranslation()
return (
<Modal show={!isConnected}>
<Modal.Body>
<Container className={'text-center'}>
<Row className={'mb-4'}>
<Col xs={12}>
<WaitSpinner size={5}></WaitSpinner>
</Col>
</Row>
<Row>
<Col xs={12}>
<span>
<Trans i18nKey={'realtime.reconnect'}></Trans>
</span>
</Col>
</Row>
</Container>
</Modal.Body>
</Modal>
)
}

Some files were not shown because too many files have changed in this diff Show more