From ce29cc0a2e60d2f23dd44e27f290acda0d1f76ae Mon Sep 17 00:00:00 2001
From: Tilman Vatteroth <git@tilmanvatteroth.de>
Date: Sat, 2 Apr 2022 23:45:46 +0200
Subject: [PATCH] feat: add base implementation for realtime communication

Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
Co-authored-by: Erik Michelson <github@erik.michelson.eu>
Co-authored-by: Philip Molares <philip.molares@udo.edu>
Co-authored-by: Tilman Vatteroth <git@tilmanvatteroth.de>
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
---
 .reuse/dep5                                   |   4 +
 codecov.yml                                   |   3 +-
 package.json                                  |  14 +-
 public/intro.md                               |   3 +
 public/motd.md                                |   2 +
 src/app-init.ts                               |   5 +-
 src/app.module.ts                             |   2 +
 src/history/history.service.spec.ts           |   8 +-
 src/main.ts                                   |   2 +-
 src/media/media.service.spec.ts               |   2 +
 src/notes/alias.service.spec.ts               |  10 +-
 src/notes/notes.module.ts                     |   2 +
 src/notes/notes.service.spec.ts               |  10 +-
 src/notes/notes.service.ts                    |  10 +-
 src/permissions/permissions.service.spec.ts   |   8 +-
 .../realtime-note/realtime-note.module.ts     |  26 ++
 .../realtime-note.service.spec.ts             | 103 +++++
 .../realtime-note/realtime-note.service.ts    |  57 +++
 .../realtime-note/realtime-note.spec.ts       |  79 ++++
 src/realtime/realtime-note/realtime-note.ts   | 121 ++++++
 .../test-utils/mock-awareness.ts              |  22 ++
 .../test-utils/mock-connection.ts             |  22 ++
 .../test-utils/mock-realtime-note.ts          |  45 +++
 .../test-utils/mock-websocket-doc.ts          |  18 +
 .../test-utils/mock-websocket-transporter.ts  |  35 ++
 .../realtime-note/websocket-awareness.spec.ts |  60 +++
 .../realtime-note/websocket-awareness.ts      |  49 +++
 .../websocket-connection.spec.ts              | 190 +++++++++
 .../realtime-note/websocket-connection.ts     | 100 +++++
 .../realtime-note/websocket-doc.spec.ts       |  56 +++
 src/realtime/realtime-note/websocket-doc.ts   |  71 ++++
 .../extract-note-id-from-request-url.spec.ts  |  39 ++
 .../utils/extract-note-id-from-request-url.ts |  28 ++
 .../websocket/websocket.gateway.spec.ts       | 364 ++++++++++++++++++
 src/realtime/websocket/websocket.gateway.ts   | 109 ++++++
 src/realtime/websocket/websocket.module.ts    |  28 ++
 src/revisions/revisions.service.spec.ts       |   8 +-
 src/seed.ts                                   |  49 ++-
 src/session/session.service.spec.ts           | 142 ++++++-
 src/session/session.service.ts                |  61 +++
 src/utils/frontend-integration.ts             |  36 --
 test/app.e2e-spec.ts                          |   2 +
 test/test-setup.ts                            |   1 -
 yarn.lock                                     | 210 +++++++++-
 44 files changed, 2151 insertions(+), 65 deletions(-)
 create mode 100644 public/intro.md
 create mode 100644 public/motd.md
 create mode 100644 src/realtime/realtime-note/realtime-note.module.ts
 create mode 100644 src/realtime/realtime-note/realtime-note.service.spec.ts
 create mode 100644 src/realtime/realtime-note/realtime-note.service.ts
 create mode 100644 src/realtime/realtime-note/realtime-note.spec.ts
 create mode 100644 src/realtime/realtime-note/realtime-note.ts
 create mode 100644 src/realtime/realtime-note/test-utils/mock-awareness.ts
 create mode 100644 src/realtime/realtime-note/test-utils/mock-connection.ts
 create mode 100644 src/realtime/realtime-note/test-utils/mock-realtime-note.ts
 create mode 100644 src/realtime/realtime-note/test-utils/mock-websocket-doc.ts
 create mode 100644 src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts
 create mode 100644 src/realtime/realtime-note/websocket-awareness.spec.ts
 create mode 100644 src/realtime/realtime-note/websocket-awareness.ts
 create mode 100644 src/realtime/realtime-note/websocket-connection.spec.ts
 create mode 100644 src/realtime/realtime-note/websocket-connection.ts
 create mode 100644 src/realtime/realtime-note/websocket-doc.spec.ts
 create mode 100644 src/realtime/realtime-note/websocket-doc.ts
 create mode 100644 src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts
 create mode 100644 src/realtime/websocket/utils/extract-note-id-from-request-url.ts
 create mode 100644 src/realtime/websocket/websocket.gateway.spec.ts
 create mode 100644 src/realtime/websocket/websocket.gateway.ts
 create mode 100644 src/realtime/websocket/websocket.module.ts
 delete mode 100644 src/utils/frontend-integration.ts

diff --git a/.reuse/dep5 b/.reuse/dep5
index 367d202a3..517bed088 100644
--- a/.reuse/dep5
+++ b/.reuse/dep5
@@ -58,3 +58,7 @@ License: LicenseRef-DCO
 Files: docs/content/theme/styles/Roboto/*
 Copyright: 2011 Christian Robertson
 License: Apache-2.0
+
+Files: public/*.md
+Copyright: 2021 The HedgeDoc developers (see AUTHORS file)
+License: CC0-1.0
diff --git a/codecov.yml b/codecov.yml
index 71728fd93..4950073bb 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1,8 +1,9 @@
-# SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
+# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
 # SPDX-License-Identifier: CC0-1.0
 
 ignore:
   - "src/utils/test-utils"
+  - "src/realtime/realtime-note/test-utils"
 
 codecov:
   notify:
diff --git a/package.json b/package.json
index 42ee7db7b..41981577b 100644
--- a/package.json
+++ b/package.json
@@ -25,14 +25,18 @@
   },
   "dependencies": {
     "@azure/storage-blob": "12.11.0",
+    "@hedgedoc/realtime": "0.1.1",
+    "@mrdrogdrog/optional": "0.1.0",
     "@nestjs/common": "8.4.7",
     "@nestjs/config": "2.1.0",
     "@nestjs/core": "8.4.7",
     "@nestjs/passport": "8.2.2",
     "@nestjs/platform-express": "8.4.7",
+    "@nestjs/platform-ws": "7.6.17",
     "@nestjs/schedule": "2.0.1",
     "@nestjs/swagger": "5.2.1",
     "@nestjs/typeorm": "8.1.4",
+    "@nestjs/websockets": "8.4.4",
     "@types/bcrypt": "5.0.0",
     "@types/cron": "1.7.3",
     "@types/minio": "7.0.13",
@@ -44,10 +48,12 @@
     "class-validator": "0.13.2",
     "cli-color": "2.0.3",
     "connect-typeorm": "1.1.4",
+    "cookie": "0.5.0",
     "express-session": "1.17.3",
     "file-type": "16.5.3",
     "joi": "17.6.0",
     "ldapauth-fork": "5.0.5",
+    "lib0": "0.2.51",
     "minio": "7.0.29",
     "mysql": "2.18.1",
     "nest-router": "1.0.9",
@@ -63,7 +69,10 @@
     "rxjs": "7.5.5",
     "sqlite3": "5.0.8",
     "swagger-ui-express": "4.4.0",
-    "typeorm": "0.3.7"
+    "typeorm": "0.3.7",
+    "ws": "8.7.0",
+    "y-protocols": "1.0.5",
+    "yjs": "13.5.39"
   },
   "devDependencies": {
     "@nestjs/cli": "8.2.8",
@@ -72,6 +81,8 @@
     "@trivago/prettier-plugin-sort-imports": "3.2.0",
     "@tsconfig/node12": "1.0.11",
     "@types/cli-color": "2.0.2",
+    "@types/cookie": "0.5.0",
+    "@types/cookie-signature": "1.0.4",
     "@types/express": "4.17.13",
     "@types/express-session": "1.17.4",
     "@types/jest": "28.1.4",
@@ -81,6 +92,7 @@
     "@types/pg": "8.6.5",
     "@types/source-map-support": "0.5.4",
     "@types/supertest": "2.0.12",
+    "@types/ws": "8.5.3",
     "@typescript-eslint/eslint-plugin": "5.30.5",
     "@typescript-eslint/parser": "5.30.5",
     "eslint": "8.19.0",
diff --git a/public/intro.md b/public/intro.md
new file mode 100644
index 000000000..896fd1362
--- /dev/null
+++ b/public/intro.md
@@ -0,0 +1,3 @@
+:::success
+You're connected to a real backend! :party:
+:::
diff --git a/public/motd.md b/public/motd.md
new file mode 100644
index 000000000..974a3143f
--- /dev/null
+++ b/public/motd.md
@@ -0,0 +1,2 @@
+This is the test motd text
+:smile:
diff --git a/src/app-init.ts b/src/app-init.ts
index 04a4d6853..5cb351f07 100644
--- a/src/app-init.ts
+++ b/src/app-init.ts
@@ -5,6 +5,7 @@
  */
 import { HttpAdapterHost } from '@nestjs/core';
 import { NestExpressApplication } from '@nestjs/platform-express';
+import { WsAdapter } from '@nestjs/platform-ws';
 
 import { AppConfig } from './config/app.config';
 import { AuthConfig } from './config/auth.config';
@@ -14,7 +15,6 @@ import { ConsoleLoggerService } from './logger/console-logger.service';
 import { BackendType } from './media/backends/backend-type.enum';
 import { SessionService } from './session/session.service';
 import { setupSpecialGroups } from './utils/createSpecialGroups';
-import { setupFrontendProxy } from './utils/frontend-integration';
 import { setupSessionMiddleware } from './utils/session';
 import { setupValidationPipe } from './utils/setup-pipes';
 import { setupPrivateApiDocs, setupPublicApiDocs } from './utils/swagger';
@@ -41,8 +41,6 @@ export async function setupApp(
       `Serving OpenAPI docs for private api under '/private/apidoc'`,
       'AppBootstrap',
     );
-
-    await setupFrontendProxy(app, logger);
   }
 
   await setupSpecialGroups(app);
@@ -80,4 +78,5 @@ export async function setupApp(
 
   const { httpAdapter } = app.get(HttpAdapterHost);
   app.useGlobalFilters(new ErrorExceptionMapping(httpAdapter));
+  app.useWebSocketAdapter(new WsAdapter(app));
 }
diff --git a/src/app.module.ts b/src/app.module.ts
index 9ec7596b6..1ccff2a24 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -33,6 +33,7 @@ import { MediaModule } from './media/media.module';
 import { MonitoringModule } from './monitoring/monitoring.module';
 import { NotesModule } from './notes/notes.module';
 import { PermissionsModule } from './permissions/permissions.module';
+import { WebsocketModule } from './realtime/websocket/websocket.module';
 import { RevisionsModule } from './revisions/revisions.module';
 import { SessionModule } from './session/session.module';
 import { UsersModule } from './users/users.module';
@@ -101,6 +102,7 @@ const routes: Routes = [
     MediaModule,
     AuthModule,
     FrontendConfigModule,
+    WebsocketModule,
     IdentityModule,
     SessionModule,
   ],
diff --git a/src/history/history.service.spec.ts b/src/history/history.service.spec.ts
index 2983946fe..aca756a4f 100644
--- a/src/history/history.service.spec.ts
+++ b/src/history/history.service.spec.ts
@@ -12,6 +12,7 @@ import { DataSource, EntityManager, Repository } from 'typeorm';
 import { AuthToken } from '../auth/auth-token.entity';
 import { Author } from '../authors/author.entity';
 import appConfigMock from '../config/mock/app.config.mock';
+import authConfigMock from '../config/mock/auth.config.mock';
 import databaseConfigMock from '../config/mock/database.config.mock';
 import noteConfigMock from '../config/mock/note.config.mock';
 import { NotInDBError } from '../errors/errors';
@@ -81,7 +82,12 @@ describe('HistoryService', () => {
         NotesModule,
         ConfigModule.forRoot({
           isGlobal: true,
-          load: [appConfigMock, databaseConfigMock, noteConfigMock],
+          load: [
+            appConfigMock,
+            databaseConfigMock,
+            authConfigMock,
+            noteConfigMock,
+          ],
         }),
       ],
     })
diff --git a/src/main.ts b/src/main.ts
index 24c594f29..7f25ef785 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -12,7 +12,6 @@ import { setupApp } from './app-init';
 import { AppModule } from './app.module';
 import { AppConfig } from './config/app.config';
 import { AuthConfig } from './config/auth.config';
-import { DatabaseConfig } from './config/database.config';
 import { MediaConfig } from './config/media.config';
 import { ConsoleLoggerService } from './logger/console-logger.service';
 
@@ -33,6 +32,7 @@ async function bootstrap(): Promise<void> {
   const appConfig = configService.get<AppConfig>('appConfig');
   const authConfig = configService.get<AuthConfig>('authConfig');
   const mediaConfig = configService.get<MediaConfig>('mediaConfig');
+
   if (!appConfig || !authConfig || !mediaConfig) {
     logger.error('Could not initialize config, aborting.', 'AppBootstrap');
     process.exit(1);
diff --git a/src/media/media.service.spec.ts b/src/media/media.service.spec.ts
index 440d28486..c7e01a4cc 100644
--- a/src/media/media.service.spec.ts
+++ b/src/media/media.service.spec.ts
@@ -12,6 +12,7 @@ import { Repository } from 'typeorm';
 import appConfigMock from '../../src/config/mock/app.config.mock';
 import { AuthToken } from '../auth/auth-token.entity';
 import { Author } from '../authors/author.entity';
+import authConfigMock from '../config/mock/auth.config.mock';
 import databaseConfigMock from '../config/mock/database.config.mock';
 import mediaConfigMock from '../config/mock/media.config.mock';
 import noteConfigMock from '../config/mock/note.config.mock';
@@ -57,6 +58,7 @@ describe('MediaService', () => {
             mediaConfigMock,
             appConfigMock,
             databaseConfigMock,
+            authConfigMock,
             noteConfigMock,
           ],
         }),
diff --git a/src/notes/alias.service.spec.ts b/src/notes/alias.service.spec.ts
index 954a0343a..8138674ff 100644
--- a/src/notes/alias.service.spec.ts
+++ b/src/notes/alias.service.spec.ts
@@ -11,6 +11,7 @@ import { DataSource, EntityManager, Repository } from 'typeorm';
 import { AuthToken } from '../auth/auth-token.entity';
 import { Author } from '../authors/author.entity';
 import appConfigMock from '../config/mock/app.config.mock';
+import authConfigMock from '../config/mock/auth.config.mock';
 import databaseConfigMock from '../config/mock/database.config.mock';
 import noteConfigMock from '../config/mock/note.config.mock';
 import {
@@ -25,6 +26,7 @@ import { Identity } from '../identity/identity.entity';
 import { LoggerModule } from '../logger/logger.module';
 import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
 import { NoteUserPermission } from '../permissions/note-user-permission.entity';
+import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module';
 import { Edit } from '../revisions/edit.entity';
 import { Revision } from '../revisions/revision.entity';
 import { RevisionsModule } from '../revisions/revisions.module';
@@ -78,13 +80,19 @@ describe('AliasService', () => {
       imports: [
         ConfigModule.forRoot({
           isGlobal: true,
-          load: [appConfigMock, databaseConfigMock, noteConfigMock],
+          load: [
+            appConfigMock,
+            databaseConfigMock,
+            authConfigMock,
+            noteConfigMock,
+          ],
         }),
         LoggerModule,
         UsersModule,
         GroupsModule,
         RevisionsModule,
         NotesModule,
+        RealtimeNoteModule,
       ],
     })
       .overrideProvider(getRepositoryToken(Note))
diff --git a/src/notes/notes.module.ts b/src/notes/notes.module.ts
index 0647e0475..f07774078 100644
--- a/src/notes/notes.module.ts
+++ b/src/notes/notes.module.ts
@@ -11,6 +11,7 @@ import { GroupsModule } from '../groups/groups.module';
 import { LoggerModule } from '../logger/logger.module';
 import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
 import { NoteUserPermission } from '../permissions/note-user-permission.entity';
+import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module';
 import { RevisionsModule } from '../revisions/revisions.module';
 import { User } from '../users/user.entity';
 import { UsersModule } from '../users/users.module';
@@ -35,6 +36,7 @@ import { Tag } from './tag.entity';
     GroupsModule,
     LoggerModule,
     ConfigModule,
+    RealtimeNoteModule,
   ],
   controllers: [],
   providers: [NotesService, AliasService],
diff --git a/src/notes/notes.service.spec.ts b/src/notes/notes.service.spec.ts
index bfd53b9f5..07189d81e 100644
--- a/src/notes/notes.service.spec.ts
+++ b/src/notes/notes.service.spec.ts
@@ -11,6 +11,7 @@ import { DataSource, EntityManager, Repository } from 'typeorm';
 import { AuthToken } from '../auth/auth-token.entity';
 import { Author } from '../authors/author.entity';
 import appConfigMock from '../config/mock/app.config.mock';
+import authConfigMock from '../config/mock/auth.config.mock';
 import databaseConfigMock from '../config/mock/database.config.mock';
 import noteConfigMock from '../config/mock/note.config.mock';
 import {
@@ -24,6 +25,7 @@ import { Identity } from '../identity/identity.entity';
 import { LoggerModule } from '../logger/logger.module';
 import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
 import { NoteUserPermission } from '../permissions/note-user-permission.entity';
+import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module';
 import { Edit } from '../revisions/edit.entity';
 import { Revision } from '../revisions/revision.entity';
 import { RevisionsModule } from '../revisions/revisions.module';
@@ -172,9 +174,15 @@ describe('NotesService', () => {
         UsersModule,
         GroupsModule,
         RevisionsModule,
+        RealtimeNoteModule,
         ConfigModule.forRoot({
           isGlobal: true,
-          load: [appConfigMock, databaseConfigMock, noteConfigMock],
+          load: [
+            appConfigMock,
+            databaseConfigMock,
+            authConfigMock,
+            noteConfigMock,
+          ],
         }),
       ],
     })
diff --git a/src/notes/notes.service.ts b/src/notes/notes.service.ts
index fa6b5a085..b01e24a5a 100644
--- a/src/notes/notes.service.ts
+++ b/src/notes/notes.service.ts
@@ -16,6 +16,7 @@ import {
 import { GroupsService } from '../groups/groups.service';
 import { HistoryEntry } from '../history/history-entry.entity';
 import { ConsoleLoggerService } from '../logger/console-logger.service';
+import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service';
 import { Revision } from '../revisions/revision.entity';
 import { RevisionsService } from '../revisions/revisions.service';
 import { User } from '../users/user.entity';
@@ -43,6 +44,7 @@ export class NotesService {
     @Inject(noteConfiguration.KEY)
     private noteConfig: NoteConfig,
     @Inject(forwardRef(() => AliasService)) private aliasService: AliasService,
+    private realtimeNoteService: RealtimeNoteService,
   ) {
     this.logger.setContext(NotesService.name);
   }
@@ -116,7 +118,13 @@ export class NotesService {
    * @return {string} the content of the note
    */
   async getNoteContent(note: Note): Promise<string> {
-    return (await this.revisionsService.getLatestRevision(note)).content;
+    return (
+      this.realtimeNoteService
+        .getRealtimeNote(note.id)
+        ?.getYDoc()
+        .getCurrentContent() ??
+      (await this.revisionsService.getLatestRevision(note)).content
+    );
   }
 
   /**
diff --git a/src/permissions/permissions.service.spec.ts b/src/permissions/permissions.service.spec.ts
index 84b25e026..91d60f55b 100644
--- a/src/permissions/permissions.service.spec.ts
+++ b/src/permissions/permissions.service.spec.ts
@@ -11,6 +11,7 @@ import { DataSource, EntityManager, Repository } from 'typeorm';
 import { AuthToken } from '../auth/auth-token.entity';
 import { Author } from '../authors/author.entity';
 import appConfigMock from '../config/mock/app.config.mock';
+import authConfigMock from '../config/mock/auth.config.mock';
 import databaseConfigMock from '../config/mock/database.config.mock';
 import noteConfigMock from '../config/mock/note.config.mock';
 import { PermissionsUpdateInconsistentError } from '../errors/errors';
@@ -93,7 +94,12 @@ describe('PermissionsService', () => {
         NotesModule,
         ConfigModule.forRoot({
           isGlobal: true,
-          load: [appConfigMock, databaseConfigMock, noteConfigMock],
+          load: [
+            appConfigMock,
+            databaseConfigMock,
+            authConfigMock,
+            noteConfigMock,
+          ],
         }),
         GroupsModule,
       ],
diff --git a/src/realtime/realtime-note/realtime-note.module.ts b/src/realtime/realtime-note/realtime-note.module.ts
new file mode 100644
index 000000000..e34494570
--- /dev/null
+++ b/src/realtime/realtime-note/realtime-note.module.ts
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Module } from '@nestjs/common';
+
+import { LoggerModule } from '../../logger/logger.module';
+import { PermissionsModule } from '../../permissions/permissions.module';
+import { RevisionsModule } from '../../revisions/revisions.module';
+import { SessionModule } from '../../session/session.module';
+import { UsersModule } from '../../users/users.module';
+import { RealtimeNoteService } from './realtime-note.service';
+
+@Module({
+  imports: [
+    LoggerModule,
+    UsersModule,
+    PermissionsModule,
+    SessionModule,
+    RevisionsModule,
+  ],
+  exports: [RealtimeNoteService],
+  providers: [RealtimeNoteService],
+})
+export class RealtimeNoteModule {}
diff --git a/src/realtime/realtime-note/realtime-note.service.spec.ts b/src/realtime/realtime-note/realtime-note.service.spec.ts
new file mode 100644
index 000000000..08142fb7a
--- /dev/null
+++ b/src/realtime/realtime-note/realtime-note.service.spec.ts
@@ -0,0 +1,103 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Mock } from 'ts-mockery';
+
+import { Note } from '../../notes/note.entity';
+import { Revision } from '../../revisions/revision.entity';
+import { RevisionsService } from '../../revisions/revisions.service';
+import * as realtimeNoteModule from './realtime-note';
+import { RealtimeNote } from './realtime-note';
+import { RealtimeNoteService } from './realtime-note.service';
+import { mockRealtimeNote } from './test-utils/mock-realtime-note';
+import { WebsocketAwareness } from './websocket-awareness';
+import { WebsocketDoc } from './websocket-doc';
+
+describe('RealtimeNoteService', () => {
+  let realtimeNoteService: RealtimeNoteService;
+  let mockedNote: Note;
+  let mockedRealtimeNote: RealtimeNote;
+  let realtimeNoteConstructorSpy: jest.SpyInstance;
+  let revisionsService: RevisionsService;
+  const mockedContent = 'mockedContent';
+  const mockedNoteId = 'mockedNoteId';
+
+  function mockGetLatestRevision(latestRevisionExists: boolean) {
+    jest
+      .spyOn(revisionsService, 'getLatestRevision')
+      .mockImplementation((note: Note) =>
+        note === mockedNote && latestRevisionExists
+          ? Promise.resolve(
+              Mock.of<Revision>({
+                content: mockedContent,
+              }),
+            )
+          : Promise.reject('Revision for note mockedNoteId not found.'),
+      );
+  }
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+    jest.resetModules();
+
+    revisionsService = Mock.of<RevisionsService>({
+      getLatestRevision: jest.fn(),
+    });
+
+    realtimeNoteService = new RealtimeNoteService(revisionsService);
+
+    mockedNote = Mock.of<Note>({ id: mockedNoteId });
+    mockedRealtimeNote = mockRealtimeNote(
+      Mock.of<WebsocketDoc>(),
+      Mock.of<WebsocketAwareness>(),
+    );
+    realtimeNoteConstructorSpy = jest
+      .spyOn(realtimeNoteModule, 'RealtimeNote')
+      .mockReturnValue(mockedRealtimeNote);
+  });
+
+  it("creates a new realtime note if it doesn't exist yet", async () => {
+    mockGetLatestRevision(true);
+    await expect(
+      realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
+    ).resolves.toBe(mockedRealtimeNote);
+    expect(realtimeNoteConstructorSpy).toBeCalledWith(
+      mockedNoteId,
+      mockedContent,
+    );
+    expect(realtimeNoteService.getRealtimeNote(mockedNoteId)).toBe(
+      mockedRealtimeNote,
+    );
+  });
+
+  it("fails if the requested note doesn't exist", async () => {
+    mockGetLatestRevision(false);
+    await expect(
+      realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
+    ).rejects.toBe(`Revision for note mockedNoteId not found.`);
+    expect(realtimeNoteConstructorSpy).not.toBeCalled();
+    expect(realtimeNoteService.getRealtimeNote(mockedNoteId)).toBeUndefined();
+  });
+
+  it("doesn't create a new realtime note if there is already one", async () => {
+    mockGetLatestRevision(true);
+    await expect(
+      realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
+    ).resolves.toBe(mockedRealtimeNote);
+    await expect(
+      realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
+    ).resolves.toBe(mockedRealtimeNote);
+    expect(realtimeNoteConstructorSpy).toBeCalledTimes(1);
+  });
+
+  it('deletes the realtime from the map if the realtime note is destroyed', async () => {
+    mockGetLatestRevision(true);
+    await expect(
+      realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
+    ).resolves.toBe(mockedRealtimeNote);
+    mockedRealtimeNote.emit('destroy');
+    expect(realtimeNoteService.getRealtimeNote(mockedNoteId)).toBeUndefined();
+  });
+});
diff --git a/src/realtime/realtime-note/realtime-note.service.ts b/src/realtime/realtime-note/realtime-note.service.ts
new file mode 100644
index 000000000..d63b5ac92
--- /dev/null
+++ b/src/realtime/realtime-note/realtime-note.service.ts
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Injectable } from '@nestjs/common';
+
+import { Note } from '../../notes/note.entity';
+import { RevisionsService } from '../../revisions/revisions.service';
+import { RealtimeNote } from './realtime-note';
+
+@Injectable()
+export class RealtimeNoteService {
+  constructor(private revisionsService: RevisionsService) {}
+
+  private noteIdToRealtimeNote = new Map<string, RealtimeNote>();
+
+  /**
+   * Creates or reuses a {@link RealtimeNote} that is handling the real time editing of the {@link Note} which is identified by the given note id.
+   * @param note The for which a {@link RealtimeNote realtime note} should be retrieved.
+   * @throws NotInDBError if note doesn't exist or has no revisions.
+   * @return A {@link RealtimeNote} that is linked to the given note.
+   */
+  public async getOrCreateRealtimeNote(note: Note): Promise<RealtimeNote> {
+    return (
+      this.noteIdToRealtimeNote.get(note.id) ??
+      (await this.createNewRealtimeNote(note))
+    );
+  }
+
+  /**
+   * Creates a new {@link RealtimeNote} for the given {@link Note} and memorizes it.
+   *
+   * @param note The note for which the realtime note should be created
+   * @throws NotInDBError if note doesn't exist or has no revisions.
+   * @return The created realtime note
+   */
+  private async createNewRealtimeNote(note: Note): Promise<RealtimeNote> {
+    const initialContent = (await this.revisionsService.getLatestRevision(note))
+      .content;
+    const realtimeNote = new RealtimeNote(note.id, initialContent);
+    realtimeNote.on('destroy', () => {
+      this.noteIdToRealtimeNote.delete(note.id);
+    });
+    this.noteIdToRealtimeNote.set(note.id, realtimeNote);
+    return realtimeNote;
+  }
+
+  /**
+   * Retrieves a {@link RealtimeNote} that is linked to the given {@link Note} id.
+   * @param noteId The id of the {@link Note}
+   * @return A {@link RealtimeNote} or {@code undefined} if no instance is existing.
+   */
+  public getRealtimeNote(noteId: string): RealtimeNote | undefined {
+    return this.noteIdToRealtimeNote.get(noteId);
+  }
+}
diff --git a/src/realtime/realtime-note/realtime-note.spec.ts b/src/realtime/realtime-note/realtime-note.spec.ts
new file mode 100644
index 000000000..b4396d43b
--- /dev/null
+++ b/src/realtime/realtime-note/realtime-note.spec.ts
@@ -0,0 +1,79 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { RealtimeNote } from './realtime-note';
+import { mockAwareness } from './test-utils/mock-awareness';
+import { mockConnection } from './test-utils/mock-connection';
+import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
+import * as websocketAwarenessModule from './websocket-awareness';
+import { WebsocketAwareness } from './websocket-awareness';
+import * as websocketDocModule from './websocket-doc';
+import { WebsocketDoc } from './websocket-doc';
+
+describe('realtime note', () => {
+  let mockedDoc: WebsocketDoc;
+  let mockedAwareness: WebsocketAwareness;
+
+  beforeEach(() => {
+    jest.resetAllMocks();
+    jest.resetModules();
+    mockedDoc = mockWebsocketDoc();
+    mockedAwareness = mockAwareness();
+    jest
+      .spyOn(websocketDocModule, 'WebsocketDoc')
+      .mockImplementation(() => mockedDoc);
+    jest
+      .spyOn(websocketAwarenessModule, 'WebsocketAwareness')
+      .mockImplementation(() => mockedAwareness);
+  });
+
+  afterAll(() => {
+    jest.resetAllMocks();
+    jest.resetModules();
+  });
+
+  it('can connect and disconnect clients', () => {
+    const sut = new RealtimeNote('mock-note', 'nothing');
+    const client1 = mockConnection(true);
+    sut.addClient(client1);
+    expect(sut.getConnections()).toStrictEqual([client1]);
+    expect(sut.hasConnections()).toBeTruthy();
+    sut.removeClient(client1);
+    expect(sut.getConnections()).toStrictEqual([]);
+    expect(sut.hasConnections()).toBeFalsy();
+  });
+
+  it('creates a y-doc and y-awareness', () => {
+    const sut = new RealtimeNote('mock-note', 'nothing');
+    expect(sut.getYDoc()).toBe(mockedDoc);
+    expect(sut.getAwareness()).toBe(mockedAwareness);
+  });
+
+  it('destroys y-doc and y-awareness on self-destruction', () => {
+    const sut = new RealtimeNote('mock-note', 'nothing');
+    const docDestroy = jest.spyOn(mockedDoc, 'destroy');
+    const awarenessDestroy = jest.spyOn(mockedAwareness, 'destroy');
+    sut.destroy();
+    expect(docDestroy).toBeCalled();
+    expect(awarenessDestroy).toBeCalled();
+  });
+
+  it('emits destroy event on destruction', async () => {
+    const sut = new RealtimeNote('mock-note', 'nothing');
+    const destroyPromise = new Promise<void>((resolve) => {
+      sut.once('destroy', () => {
+        resolve();
+      });
+    });
+    sut.destroy();
+    await expect(destroyPromise).resolves.not.toThrow();
+  });
+
+  it("doesn't destroy a destroyed note", () => {
+    const sut = new RealtimeNote('mock-note', 'nothing');
+    sut.destroy();
+    expect(() => sut.destroy()).toThrow();
+  });
+});
diff --git a/src/realtime/realtime-note/realtime-note.ts b/src/realtime/realtime-note/realtime-note.ts
new file mode 100644
index 000000000..0c6b54ef6
--- /dev/null
+++ b/src/realtime/realtime-note/realtime-note.ts
@@ -0,0 +1,121 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Logger } from '@nestjs/common';
+import { EventEmitter } from 'events';
+import TypedEventEmitter, { EventMap } from 'typed-emitter';
+import { Awareness } from 'y-protocols/awareness';
+
+import { WebsocketAwareness } from './websocket-awareness';
+import { WebsocketConnection } from './websocket-connection';
+import { WebsocketDoc } from './websocket-doc';
+
+export type RealtimeNoteEvents = {
+  destroy: () => void;
+};
+
+type TypedEventEmitterConstructor<T extends EventMap> =
+  new () => TypedEventEmitter<T>;
+
+/**
+ * Represents a note currently being edited by a number of clients.
+ */
+export class RealtimeNote extends (EventEmitter as TypedEventEmitterConstructor<RealtimeNoteEvents>) {
+  protected logger: Logger;
+  private readonly websocketDoc: WebsocketDoc;
+  private readonly websocketAwareness: WebsocketAwareness;
+  private readonly clients = new Set<WebsocketConnection>();
+  private isClosing = false;
+
+  constructor(private readonly noteId: string, initialContent: string) {
+    super();
+    this.logger = new Logger(`${RealtimeNote.name} ${noteId}`);
+    this.websocketDoc = new WebsocketDoc(this, initialContent);
+    this.websocketAwareness = new WebsocketAwareness(this);
+    this.logger.debug(`New realtime session for note ${noteId} created.`);
+  }
+
+  /**
+   * Connects a new client to the note.
+   *
+   * For this purpose a {@link WebsocketConnection} is created and added to the client map.
+   *
+   * @param client the websocket connection to the client
+   */
+  public addClient(client: WebsocketConnection): void {
+    this.clients.add(client);
+    this.logger.debug(`User '${client.getUser().username}' connected`);
+  }
+
+  /**
+   * Disconnects the given websocket client while cleaning-up if it was the last user in the realtime note.
+   *
+   * @param {WebSocket} client The websocket client that disconnects.
+   */
+  public removeClient(client: WebsocketConnection): void {
+    this.clients.delete(client);
+    this.logger.debug(
+      `User '${client.getUser().username}' disconnected. ${
+        this.clients.size
+      } clients left.`,
+    );
+    if (!this.hasConnections() && !this.isClosing) {
+      this.destroy();
+    }
+  }
+
+  /**
+   * Destroys the current realtime note by deleting the y-js doc and disconnecting all clients.
+   *
+   * @throws Error if note has already been destroyed
+   */
+  public destroy(): void {
+    if (this.isClosing) {
+      throw new Error('Note already destroyed');
+    }
+    this.logger.debug('Destroying realtime note.');
+    this.isClosing = true;
+    this.websocketDoc.destroy();
+    this.websocketAwareness.destroy();
+    this.clients.forEach((value) => value.disconnect());
+    this.emit('destroy');
+  }
+
+  /**
+   * Checks if there's still clients connected to this note.
+   *
+   * @return {@code true} if there a still clinets connected, otherwise {@code false}
+   */
+  public hasConnections(): boolean {
+    return this.clients.size !== 0;
+  }
+
+  /**
+   * Returns all {@link WebsocketConnection WebsocketConnections} currently hold by this note.
+   *
+   * @return an array of {@link WebsocketConnection WebsocketConnections}
+   */
+  public getConnections(): WebsocketConnection[] {
+    return [...this.clients];
+  }
+
+  /**
+   * Get the {@link Doc YDoc} of the note.
+   *
+   * @return the {@link Doc YDoc} of the note
+   */
+  public getYDoc(): WebsocketDoc {
+    return this.websocketDoc;
+  }
+
+  /**
+   * Get the {@link Awareness YAwareness} of the note.
+   *
+   * @return the {@link Awareness YAwareness} of the note
+   */
+  public getAwareness(): Awareness {
+    return this.websocketAwareness;
+  }
+}
diff --git a/src/realtime/realtime-note/test-utils/mock-awareness.ts b/src/realtime/realtime-note/test-utils/mock-awareness.ts
new file mode 100644
index 000000000..d67b36e76
--- /dev/null
+++ b/src/realtime/realtime-note/test-utils/mock-awareness.ts
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Observable } from 'lib0/observable';
+import { Mock } from 'ts-mockery';
+
+import { WebsocketAwareness } from '../websocket-awareness';
+
+class MockAwareness extends Observable<string> {
+  destroy(): void {
+    //intentionally left blank
+  }
+}
+
+/**
+ * Provides a partial mock for {@link WebsocketAwareness}.
+ */
+export function mockAwareness(): WebsocketAwareness {
+  return Mock.from<WebsocketAwareness>(new MockAwareness());
+}
diff --git a/src/realtime/realtime-note/test-utils/mock-connection.ts b/src/realtime/realtime-note/test-utils/mock-connection.ts
new file mode 100644
index 000000000..53b9f62d4
--- /dev/null
+++ b/src/realtime/realtime-note/test-utils/mock-connection.ts
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Mock } from 'ts-mockery';
+
+import { User } from '../../../users/user.entity';
+import { WebsocketConnection } from '../websocket-connection';
+
+/**
+ * Provides a partial mock for {@link WebsocketConnection}.
+ *
+ * @param synced Defines the return value for the `isSynced` function.
+ */
+export function mockConnection(synced: boolean): WebsocketConnection {
+  return Mock.of<WebsocketConnection>({
+    isSynced: jest.fn(() => synced),
+    send: jest.fn(),
+    getUser: jest.fn(() => Mock.of<User>({ username: 'mockedUser' })),
+  });
+}
diff --git a/src/realtime/realtime-note/test-utils/mock-realtime-note.ts b/src/realtime/realtime-note/test-utils/mock-realtime-note.ts
new file mode 100644
index 000000000..0374ada40
--- /dev/null
+++ b/src/realtime/realtime-note/test-utils/mock-realtime-note.ts
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { EventEmitter } from 'events';
+import { Mock } from 'ts-mockery';
+import TypedEmitter from 'typed-emitter';
+
+import { RealtimeNote, RealtimeNoteEvents } from '../realtime-note';
+import { WebsocketAwareness } from '../websocket-awareness';
+import { WebsocketDoc } from '../websocket-doc';
+
+class MockRealtimeNote extends (EventEmitter as new () => TypedEmitter<RealtimeNoteEvents>) {
+  constructor(
+    private doc: WebsocketDoc,
+    private awareness: WebsocketAwareness,
+  ) {
+    super();
+  }
+
+  public getYDoc(): WebsocketDoc {
+    return this.doc;
+  }
+
+  public getAwareness(): WebsocketAwareness {
+    return this.awareness;
+  }
+
+  public removeClient(): 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(
+  doc: WebsocketDoc,
+  awareness: WebsocketAwareness,
+): RealtimeNote {
+  return Mock.from<RealtimeNote>(new MockRealtimeNote(doc, awareness));
+}
diff --git a/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts b/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts
new file mode 100644
index 000000000..820e8de1c
--- /dev/null
+++ b/src/realtime/realtime-note/test-utils/mock-websocket-doc.ts
@@ -0,0 +1,18 @@
+/*
+ * 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(),
+  });
+}
diff --git a/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts b/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts
new file mode 100644
index 000000000..46ef5c887
--- /dev/null
+++ b/src/realtime/realtime-note/test-utils/mock-websocket-transporter.ts
@@ -0,0 +1,35 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { WebsocketTransporter } from '@hedgedoc/realtime';
+import { MessageTransporterEvents } from '@hedgedoc/realtime/dist/mjs/y-doc-message-transporter';
+import { EventEmitter } from 'events';
+import { Mock } from 'ts-mockery';
+import TypedEmitter from 'typed-emitter';
+
+class MockMessageTransporter extends (EventEmitter as new () => TypedEmitter<MessageTransporterEvents>) {
+  setupWebsocket(): void {
+    //intentionally left blank
+  }
+
+  send(): void {
+    //intentionally left blank
+  }
+
+  isSynced(): boolean {
+    return false;
+  }
+
+  disconnect(): void {
+    //intentionally left blank
+  }
+}
+
+/**
+ * Provides a partial mock for {@link WebsocketTransporter}.
+ */
+export function mockWebsocketTransporter(): WebsocketTransporter {
+  return Mock.from<WebsocketTransporter>(new MockMessageTransporter());
+}
diff --git a/src/realtime/realtime-note/websocket-awareness.spec.ts b/src/realtime/realtime-note/websocket-awareness.spec.ts
new file mode 100644
index 000000000..86b5803c5
--- /dev/null
+++ b/src/realtime/realtime-note/websocket-awareness.spec.ts
@@ -0,0 +1,60 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import * as hedgedocRealtimeModule from '@hedgedoc/realtime';
+import { Mock } from 'ts-mockery';
+
+import { RealtimeNote } from './realtime-note';
+import { mockConnection } from './test-utils/mock-connection';
+import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness';
+import { WebsocketConnection } from './websocket-connection';
+import { WebsocketDoc } from './websocket-doc';
+
+describe('websocket-awareness', () => {
+  it('distributes content updates to other synced clients', () => {
+    const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]);
+    const mockedEncodeUpdateFunction = jest.spyOn(
+      hedgedocRealtimeModule,
+      'encodeAwarenessUpdateMessage',
+    );
+    mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate);
+
+    const mockConnection1 = mockConnection(true);
+    const mockConnection2 = mockConnection(false);
+    const mockConnection3 = mockConnection(true);
+    const send1 = jest.spyOn(mockConnection1, 'send');
+    const send2 = jest.spyOn(mockConnection2, 'send');
+    const send3 = jest.spyOn(mockConnection3, 'send');
+
+    const realtimeNote = Mock.of<RealtimeNote>({
+      getYDoc(): WebsocketDoc {
+        return Mock.of<WebsocketDoc>({
+          on() {
+            //mocked
+          },
+        });
+      },
+      getConnections(): WebsocketConnection[] {
+        return [mockConnection1, mockConnection2, mockConnection3];
+      },
+    });
+
+    const websocketAwareness = new WebsocketAwareness(realtimeNote);
+    const mockUpdate: ClientIdUpdate = {
+      added: [1],
+      updated: [2],
+      removed: [3],
+    };
+    websocketAwareness.emit('update', [mockUpdate, mockConnection1]);
+    expect(send1).not.toBeCalled();
+    expect(send2).not.toBeCalled();
+    expect(send3).toBeCalledWith(mockEncodedUpdate);
+    expect(mockedEncodeUpdateFunction).toBeCalledWith(
+      websocketAwareness,
+      [1, 2, 3],
+    );
+    websocketAwareness.destroy();
+  });
+});
diff --git a/src/realtime/realtime-note/websocket-awareness.ts b/src/realtime/realtime-note/websocket-awareness.ts
new file mode 100644
index 000000000..60cc55936
--- /dev/null
+++ b/src/realtime/realtime-note/websocket-awareness.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { encodeAwarenessUpdateMessage } from '@hedgedoc/realtime';
+import { Awareness } from 'y-protocols/awareness';
+
+import { RealtimeNote } from './realtime-note';
+
+export interface ClientIdUpdate {
+  added: number[];
+  updated: number[];
+  removed: number[];
+}
+
+/**
+ * This is the implementation of {@link Awareness YAwareness} which includes additional handlers for message sending and receiving.
+ */
+export class WebsocketAwareness extends Awareness {
+  constructor(private realtimeNote: RealtimeNote) {
+    super(realtimeNote.getYDoc());
+    this.setLocalState(null);
+    this.on('update', this.distributeAwarenessUpdate.bind(this));
+  }
+
+  /**
+   * Distributes the given awareness changes to all clients.
+   *
+   * @param added Properties that were added to the awareness state
+   * @param updated Properties that were updated in the awareness state
+   * @param removed Properties that were removed from the awareness state
+   * @param origin An object that is used as reference for the origin of the update
+   */
+  private distributeAwarenessUpdate(
+    { added, updated, removed }: ClientIdUpdate,
+    origin: unknown,
+  ): void {
+    const binaryUpdate = encodeAwarenessUpdateMessage(this, [
+      ...added,
+      ...updated,
+      ...removed,
+    ]);
+    this.realtimeNote
+      .getConnections()
+      .filter((client) => client !== origin && client.isSynced())
+      .forEach((client) => client.send(binaryUpdate));
+  }
+}
diff --git a/src/realtime/realtime-note/websocket-connection.spec.ts b/src/realtime/realtime-note/websocket-connection.spec.ts
new file mode 100644
index 000000000..23d9c816f
--- /dev/null
+++ b/src/realtime/realtime-note/websocket-connection.spec.ts
@@ -0,0 +1,190 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import * as hedgedocRealtimeModule from '@hedgedoc/realtime';
+import { WebsocketTransporter } from '@hedgedoc/realtime';
+import { Mock } from 'ts-mockery';
+import WebSocket from 'ws';
+import * as yProtocolsAwarenessModule from 'y-protocols/awareness';
+
+import { User } from '../../users/user.entity';
+import * as realtimeNoteModule from './realtime-note';
+import { RealtimeNote } from './realtime-note';
+import { mockAwareness } from './test-utils/mock-awareness';
+import { mockRealtimeNote } from './test-utils/mock-realtime-note';
+import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
+import { mockWebsocketTransporter } from './test-utils/mock-websocket-transporter';
+import * as websocketAwarenessModule from './websocket-awareness';
+import { ClientIdUpdate, WebsocketAwareness } from './websocket-awareness';
+import { WebsocketConnection } from './websocket-connection';
+import * as websocketDocModule from './websocket-doc';
+import { WebsocketDoc } from './websocket-doc';
+
+import SpyInstance = jest.SpyInstance;
+
+describe('websocket connection', () => {
+  let mockedDoc: WebsocketDoc;
+  let mockedAwareness: WebsocketAwareness;
+  let mockedRealtimeNote: RealtimeNote;
+  let mockedWebsocket: WebSocket;
+  let mockedUser: User;
+  let mockedWebsocketTransporter: WebsocketTransporter;
+  let removeAwarenessSpy: SpyInstance;
+
+  beforeEach(() => {
+    jest.resetAllMocks();
+    jest.resetModules();
+    mockedDoc = mockWebsocketDoc();
+    mockedAwareness = mockAwareness();
+    mockedRealtimeNote = mockRealtimeNote(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).toBeCalledWith(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).toBeCalledWith(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).toBeCalled();
+  });
+
+  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).toBeCalledWith(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).toBeCalledWith(mockedAwareness, [0], sut);
+  });
+
+  it('saves the correct user', () => {
+    const sut = new WebsocketConnection(
+      mockedWebsocket,
+      mockedUser,
+      mockedRealtimeNote,
+    );
+
+    expect(sut.getUser()).toBe(mockedUser);
+  });
+});
diff --git a/src/realtime/realtime-note/websocket-connection.ts b/src/realtime/realtime-note/websocket-connection.ts
new file mode 100644
index 000000000..dca73ff11
--- /dev/null
+++ b/src/realtime/realtime-note/websocket-connection.ts
@@ -0,0 +1,100 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { WebsocketTransporter } from '@hedgedoc/realtime';
+import { Logger } from '@nestjs/common';
+import WebSocket from 'ws';
+import { Awareness, removeAwarenessStates } from 'y-protocols/awareness';
+
+import { User } from '../../users/user.entity';
+import { RealtimeNote } from './realtime-note';
+import { ClientIdUpdate } from './websocket-awareness';
+
+/**
+ * Manages the websocket connection to a specific client.
+ */
+export class WebsocketConnection {
+  protected readonly logger = new Logger(WebsocketConnection.name);
+  private controlledAwarenessIds: Set<number> = new Set();
+  private transporter: WebsocketTransporter;
+
+  /**
+   * Instantiates the websocket connection wrapper for a websocket connection.
+   *
+   * @param websocket The client's raw websocket.
+   * @param user The user of the client
+   * @param realtimeNote The {@link RealtimeNote} that the client connected to.
+   * @throws Error if the socket is not open
+   */
+  constructor(
+    websocket: WebSocket,
+    private user: User,
+    realtimeNote: RealtimeNote,
+  ) {
+    const awareness = realtimeNote.getAwareness();
+    this.transporter = new WebsocketTransporter(
+      realtimeNote.getYDoc(),
+      awareness,
+    );
+    this.transporter.on('disconnected', () => {
+      realtimeNote.removeClient(this);
+    });
+    this.transporter.setupWebsocket(websocket);
+    this.bindAwarenessMessageEvents(awareness);
+  }
+
+  /**
+   * Binds all additional events that are needed for awareness processing.
+   */
+  private bindAwarenessMessageEvents(awareness: Awareness): void {
+    const callback = this.updateControlledAwarenessIds.bind(this);
+    awareness.on('update', callback);
+    this.transporter.on('disconnected', () => {
+      awareness.off('update', callback);
+      removeAwarenessStates(awareness, [...this.controlledAwarenessIds], this);
+    });
+  }
+
+  private updateControlledAwarenessIds(
+    { added, removed }: ClientIdUpdate,
+    origin: WebsocketConnection,
+  ): void {
+    if (origin === this) {
+      added.forEach((id) => this.controlledAwarenessIds.add(id));
+      removed.forEach((id) => this.controlledAwarenessIds.delete(id));
+    }
+  }
+
+  /**
+   * Defines if the current connection has received at least one full synchronisation.
+   */
+  public isSynced(): boolean {
+    return this.transporter.isSynced();
+  }
+
+  /**
+   * Sends the given content to the client.
+   *
+   * @param content The content to send
+   */
+  public send(content: Uint8Array): void {
+    this.transporter.send(content);
+  }
+
+  /**
+   * Stops the connection
+   */
+  public disconnect(): void {
+    this.transporter.disconnect();
+  }
+
+  public getControlledAwarenessIds(): ReadonlySet<number> {
+    return this.controlledAwarenessIds;
+  }
+
+  public getUser(): User {
+    return this.user;
+  }
+}
diff --git a/src/realtime/realtime-note/websocket-doc.spec.ts b/src/realtime/realtime-note/websocket-doc.spec.ts
new file mode 100644
index 000000000..a1ed7d428
--- /dev/null
+++ b/src/realtime/realtime-note/websocket-doc.spec.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import * as hedgedocRealtimeModule from '@hedgedoc/realtime';
+import { Mock } from 'ts-mockery';
+
+import { RealtimeNote } from './realtime-note';
+import { mockConnection } from './test-utils/mock-connection';
+import { WebsocketConnection } from './websocket-connection';
+import { WebsocketDoc } from './websocket-doc';
+
+describe('websocket-doc', () => {
+  it('saves the initial content', () => {
+    const textContent = 'textContent';
+    const websocketDoc = new WebsocketDoc(Mock.of<RealtimeNote>(), textContent);
+
+    expect(websocketDoc.getCurrentContent()).toBe(textContent);
+  });
+
+  it('distributes content updates to other synced clients', () => {
+    const mockEncodedUpdate = new Uint8Array([0, 1, 2, 3]);
+    const mockedEncodeUpdateFunction = jest.spyOn(
+      hedgedocRealtimeModule,
+      'encodeDocumentUpdateMessage',
+    );
+    mockedEncodeUpdateFunction.mockReturnValue(mockEncodedUpdate);
+
+    const mockConnection1 = mockConnection(true);
+    const mockConnection2 = mockConnection(false);
+    const mockConnection3 = mockConnection(true);
+
+    const send1 = jest.spyOn(mockConnection1, 'send');
+    const send2 = jest.spyOn(mockConnection2, 'send');
+    const send3 = jest.spyOn(mockConnection3, 'send');
+
+    const realtimeNote = Mock.of<RealtimeNote>({
+      getConnections(): WebsocketConnection[] {
+        return [mockConnection1, mockConnection2, mockConnection3];
+      },
+      getYDoc(): WebsocketDoc {
+        return websocketDoc;
+      },
+    });
+
+    const websocketDoc = new WebsocketDoc(realtimeNote, '');
+    const mockUpdate = new Uint8Array([4, 5, 6, 7]);
+    websocketDoc.emit('update', [mockUpdate, mockConnection1]);
+    expect(send1).not.toBeCalled();
+    expect(send2).not.toBeCalled();
+    expect(send3).toBeCalledWith(mockEncodedUpdate);
+    expect(mockedEncodeUpdateFunction).toBeCalledWith(mockUpdate);
+    websocketDoc.destroy();
+  });
+});
diff --git a/src/realtime/realtime-note/websocket-doc.ts b/src/realtime/realtime-note/websocket-doc.ts
new file mode 100644
index 000000000..effb43f20
--- /dev/null
+++ b/src/realtime/realtime-note/websocket-doc.ts
@@ -0,0 +1,71 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { encodeDocumentUpdateMessage } from '@hedgedoc/realtime';
+import { Doc } from 'yjs';
+
+import { RealtimeNote } from './realtime-note';
+import { WebsocketConnection } from './websocket-connection';
+
+/**
+ * This is the implementation of {@link Doc YDoc} which includes additional handlers for message sending and receiving.
+ */
+export class WebsocketDoc extends Doc {
+  private static readonly channelName = 'markdownContent';
+
+  /**
+   * Creates a new WebsocketDoc instance.
+   *
+   * The new instance is filled with the given initial content and an event listener will be registered to handle
+   * updates to the doc.
+   *
+   * @param realtimeNote - the {@link RealtimeNote} handling this {@link Doc YDoc}
+   * @param initialContent - the initial content of the {@link Doc YDoc}
+   */
+  constructor(private realtimeNote: RealtimeNote, initialContent: string) {
+    super();
+    this.initializeContent(initialContent);
+    this.bindUpdateEvent();
+  }
+
+  /**
+   * Binds the event that distributes updates in the current {@link Doc y-doc} to all clients.
+   */
+  private bindUpdateEvent(): void {
+    this.on('update', (update: Uint8Array, origin: WebsocketConnection) => {
+      const clients = this.realtimeNote
+        .getConnections()
+        .filter((client) => client !== origin && client.isSynced());
+      if (clients.length > 0) {
+        clients.forEach((client) => {
+          client.send(encodeDocumentUpdateMessage(update));
+        });
+      }
+    });
+  }
+
+  /**
+   * Sets the {@link YDoc's Doc} content to include the initialContent.
+   *
+   * This message should only be called when a new {@link RealtimeNote } is created.
+   *
+   * @param initialContent - the initial content to set the {@link Doc YDoc's} content to.
+   * @private
+   */
+  private initializeContent(initialContent: string): void {
+    this.getText(WebsocketDoc.channelName).insert(0, initialContent);
+  }
+
+  /**
+   * Gets the current content of the note as it's currently edited in realtime.
+   *
+   * Please be aware that the return of this method may be very quickly outdated.
+   *
+   * @return The current note content.
+   */
+  public getCurrentContent(): string {
+    return this.getText(WebsocketDoc.channelName).toString();
+  }
+}
diff --git a/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts b/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts
new file mode 100644
index 000000000..b74713ffe
--- /dev/null
+++ b/src/realtime/websocket/utils/extract-note-id-from-request-url.spec.ts
@@ -0,0 +1,39 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { IncomingMessage } from 'http';
+import { Mock } from 'ts-mockery';
+
+import { extractNoteIdFromRequestUrl } from './extract-note-id-from-request-url';
+
+describe('extract note id from path', () => {
+  it('fails if no URL is present', () => {
+    const mockedRequest = Mock.of<IncomingMessage>();
+    expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow();
+  });
+
+  it('can find a note id', () => {
+    const mockedRequest = Mock.of<IncomingMessage>({
+      url: '/realtime?noteId=somethingsomething',
+    });
+    expect(extractNoteIdFromRequestUrl(mockedRequest)).toBe(
+      'somethingsomething',
+    );
+  });
+
+  it('fails if no note id is present', () => {
+    const mockedRequest = Mock.of<IncomingMessage>({
+      url: '/realtime?nöteId=somethingsomething',
+    });
+    expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow();
+  });
+
+  it('fails if path is empty', () => {
+    const mockedRequest = Mock.of<IncomingMessage>({
+      url: '',
+    });
+    expect(() => extractNoteIdFromRequestUrl(mockedRequest)).toThrow();
+  });
+});
diff --git a/src/realtime/websocket/utils/extract-note-id-from-request-url.ts b/src/realtime/websocket/utils/extract-note-id-from-request-url.ts
new file mode 100644
index 000000000..9b87f491a
--- /dev/null
+++ b/src/realtime/websocket/utils/extract-note-id-from-request-url.ts
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { IncomingMessage } from 'http';
+
+/**
+ * Extracts the note id from the url of the given request.
+ *
+ * @param request The request whose URL should be extracted
+ * @return The extracted note id
+ * @throws Error if the given string isn't a valid realtime URL path
+ */
+export function extractNoteIdFromRequestUrl(request: IncomingMessage): string {
+  if (request.url === undefined) {
+    throw new Error('No URL found in request');
+  }
+  // A valid domain name is needed for the URL constructor, although not being used here.
+  // The example.org domain should be safe to use according to RFC 6761 §6.5.
+  const url = new URL(request.url, 'https://example.org');
+  const noteId = url.searchParams.get('noteId');
+  if (noteId === null) {
+    throw new Error("Path doesn't contain parameter noteId");
+  } else {
+    return noteId;
+  }
+}
diff --git a/src/realtime/websocket/websocket.gateway.spec.ts b/src/realtime/websocket/websocket.gateway.spec.ts
new file mode 100644
index 000000000..ac891426a
--- /dev/null
+++ b/src/realtime/websocket/websocket.gateway.spec.ts
@@ -0,0 +1,364 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { ConfigModule } from '@nestjs/config';
+import { Test, TestingModule } from '@nestjs/testing';
+import { getRepositoryToken } from '@nestjs/typeorm';
+import { IncomingMessage } from 'http';
+import { Mock } from 'ts-mockery';
+import { Repository } from 'typeorm';
+import WebSocket from 'ws';
+
+import { AuthToken } from '../../auth/auth-token.entity';
+import { Author } from '../../authors/author.entity';
+import appConfigMock from '../../config/mock/app.config.mock';
+import authConfigMock from '../../config/mock/auth.config.mock';
+import databaseConfigMock from '../../config/mock/database.config.mock';
+import noteConfigMock from '../../config/mock/note.config.mock';
+import { Group } from '../../groups/group.entity';
+import { Identity } from '../../identity/identity.entity';
+import { LoggerModule } from '../../logger/logger.module';
+import { Alias } from '../../notes/alias.entity';
+import { Note } from '../../notes/note.entity';
+import { NotesModule } from '../../notes/notes.module';
+import { NotesService } from '../../notes/notes.service';
+import { Tag } from '../../notes/tag.entity';
+import { NoteGroupPermission } from '../../permissions/note-group-permission.entity';
+import { NoteUserPermission } from '../../permissions/note-user-permission.entity';
+import { PermissionsModule } from '../../permissions/permissions.module';
+import { PermissionsService } from '../../permissions/permissions.service';
+import { Edit } from '../../revisions/edit.entity';
+import { Revision } from '../../revisions/revision.entity';
+import { SessionModule } from '../../session/session.module';
+import { SessionService } from '../../session/session.service';
+import { Session } from '../../users/session.entity';
+import { User } from '../../users/user.entity';
+import { UsersModule } from '../../users/users.module';
+import { UsersService } from '../../users/users.service';
+import { RealtimeNote } from '../realtime-note/realtime-note';
+import { RealtimeNoteModule } from '../realtime-note/realtime-note.module';
+import { RealtimeNoteService } from '../realtime-note/realtime-note.service';
+import * as websocketConnectionModule from '../realtime-note/websocket-connection';
+import { WebsocketConnection } from '../realtime-note/websocket-connection';
+import * as extractNoteIdFromRequestUrlModule from './utils/extract-note-id-from-request-url';
+import { WebsocketGateway } from './websocket.gateway';
+
+import SpyInstance = jest.SpyInstance;
+
+describe('Websocket gateway', () => {
+  let gateway: WebsocketGateway;
+  let sessionService: SessionService;
+  let usersService: UsersService;
+  let notesService: NotesService;
+  let realtimeNoteService: RealtimeNoteService;
+  let permissionsService: PermissionsService;
+  let mockedWebsocketConnection: WebsocketConnection;
+  let mockedWebsocket: WebSocket;
+  let mockedWebsocketCloseSpy: SpyInstance;
+  let addClientSpy: SpyInstance;
+
+  const mockedValidSessionCookie = 'mockedValidSessionCookie';
+  const mockedSessionIdWithUser = 'mockedSessionIdWithUser';
+  const mockedValidUrl = 'mockedValidUrl';
+  const mockedValidNoteId = 'mockedValidNoteId';
+
+  let sessionExistsForUser = true;
+  let noteExistsForNoteId = true;
+  let userExistsForUsername = true;
+  let userHasReadPermissions = true;
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+    jest.resetModules();
+
+    sessionExistsForUser = true;
+    noteExistsForNoteId = true;
+    userExistsForUsername = true;
+    userHasReadPermissions = true;
+
+    const module: TestingModule = await Test.createTestingModule({
+      providers: [
+        WebsocketGateway,
+        {
+          provide: getRepositoryToken(Note),
+          useClass: Repository,
+        },
+        {
+          provide: getRepositoryToken(Group),
+          useClass: Repository,
+        },
+        {
+          provide: getRepositoryToken(User),
+          useClass: Repository,
+        },
+      ],
+      imports: [
+        LoggerModule,
+        NotesModule,
+        PermissionsModule,
+        RealtimeNoteModule,
+        UsersModule,
+        SessionModule,
+        ConfigModule.forRoot({
+          isGlobal: true,
+          load: [
+            appConfigMock,
+            databaseConfigMock,
+            authConfigMock,
+            noteConfigMock,
+          ],
+        }),
+      ],
+    })
+      .overrideProvider(getRepositoryToken(User))
+      .useClass(Repository)
+      .overrideProvider(getRepositoryToken(AuthToken))
+      .useValue({})
+      .overrideProvider(getRepositoryToken(Identity))
+      .useValue({})
+      .overrideProvider(getRepositoryToken(Edit))
+      .useValue({})
+      .overrideProvider(getRepositoryToken(Revision))
+      .useValue({})
+      .overrideProvider(getRepositoryToken(Note))
+      .useClass(Repository)
+      .overrideProvider(getRepositoryToken(Tag))
+      .useValue({})
+      .overrideProvider(getRepositoryToken(NoteGroupPermission))
+      .useValue({})
+      .overrideProvider(getRepositoryToken(NoteUserPermission))
+      .useValue({})
+      .overrideProvider(getRepositoryToken(Group))
+      .useClass(Repository)
+      .overrideProvider(getRepositoryToken(Session))
+      .useValue({})
+      .overrideProvider(getRepositoryToken(Author))
+      .useValue({})
+      .overrideProvider(getRepositoryToken(Alias))
+      .useValue({})
+      .compile();
+
+    gateway = module.get<WebsocketGateway>(WebsocketGateway);
+    sessionService = module.get<SessionService>(SessionService);
+    usersService = module.get<UsersService>(UsersService);
+    notesService = module.get<NotesService>(NotesService);
+    realtimeNoteService = module.get<RealtimeNoteService>(RealtimeNoteService);
+    permissionsService = module.get<PermissionsService>(PermissionsService);
+
+    jest
+      .spyOn(sessionService, 'extractVerifiedSessionIdFromRequest')
+      .mockImplementation((request: IncomingMessage): string => {
+        if (request.headers.cookie === mockedValidSessionCookie) {
+          return mockedSessionIdWithUser;
+        } else {
+          throw new Error('no valid session cookie found');
+        }
+      });
+
+    const mockUsername = 'mockUsername';
+    jest
+      .spyOn(sessionService, 'fetchUsernameForSessionId')
+      .mockImplementation((sessionId: string) =>
+        sessionExistsForUser && sessionId === mockedSessionIdWithUser
+          ? Promise.resolve(mockUsername)
+          : Promise.reject('no user for session id found'),
+      );
+
+    const mockUser = Mock.of<User>({ username: mockUsername });
+    jest
+      .spyOn(usersService, 'getUserByUsername')
+      .mockImplementation(
+        (username: string): Promise<User> =>
+          userExistsForUsername && username === mockUsername
+            ? Promise.resolve(mockUser)
+            : Promise.reject('user not found'),
+      );
+
+    jest
+      .spyOn(extractNoteIdFromRequestUrlModule, 'extractNoteIdFromRequestUrl')
+      .mockImplementation((request: IncomingMessage): string => {
+        if (request.url === mockedValidUrl) {
+          return mockedValidNoteId;
+        } else {
+          throw new Error('no valid note id found');
+        }
+      });
+
+    const mockedNote = Mock.of<Note>({ id: 'mocknote' });
+    jest
+      .spyOn(notesService, 'getNoteByIdOrAlias')
+      .mockImplementation((noteId: string) =>
+        noteExistsForNoteId && noteId === mockedValidNoteId
+          ? Promise.resolve(mockedNote)
+          : Promise.reject('no note found'),
+      );
+
+    jest
+      .spyOn(permissionsService, 'mayRead')
+      .mockImplementation(
+        (user: User | null, note: Note): Promise<boolean> =>
+          Promise.resolve(
+            user === mockUser && note === mockedNote && userHasReadPermissions,
+          ),
+      );
+
+    const mockedRealtimeNote = Mock.of<RealtimeNote>({
+      addClient() {
+        //intentionally left blank
+      },
+    });
+    jest
+      .spyOn(realtimeNoteService, 'getOrCreateRealtimeNote')
+      .mockReturnValue(Promise.resolve(mockedRealtimeNote));
+
+    mockedWebsocketConnection = Mock.of<WebsocketConnection>();
+    jest
+      .spyOn(websocketConnectionModule, 'WebsocketConnection')
+      .mockReturnValue(mockedWebsocketConnection);
+
+    mockedWebsocket = Mock.of<WebSocket>({
+      close() {
+        //intentionally left blank
+      },
+    });
+
+    mockedWebsocketCloseSpy = jest.spyOn(mockedWebsocket, 'close');
+    addClientSpy = jest.spyOn(mockedRealtimeNote, 'addClient');
+  });
+
+  it('adds a valid connection request', async () => {
+    const request = Mock.of<IncomingMessage>({
+      socket: {
+        remoteAddress: 'mockHost',
+      },
+      url: mockedValidUrl,
+      headers: {
+        cookie: mockedValidSessionCookie,
+      },
+    });
+
+    await expect(
+      gateway.handleConnection(mockedWebsocket, request),
+    ).resolves.not.toThrow();
+    expect(addClientSpy).toBeCalledWith(mockedWebsocketConnection);
+    expect(mockedWebsocketCloseSpy).not.toBeCalled();
+  });
+
+  it('closes the connection if invalid session cookie', async () => {
+    const request = Mock.of<IncomingMessage>({
+      socket: {
+        remoteAddress: 'mockHost',
+      },
+      url: mockedValidUrl,
+      headers: {
+        cookie: 'invalid session cookie',
+      },
+    });
+
+    await expect(
+      gateway.handleConnection(mockedWebsocket, request),
+    ).resolves.not.toThrow();
+    expect(addClientSpy).not.toBeCalled();
+    expect(mockedWebsocketCloseSpy).toBeCalled();
+  });
+
+  it("closes the connection if session doesn't exist", async () => {
+    sessionExistsForUser = false;
+
+    const request = Mock.of<IncomingMessage>({
+      socket: {
+        remoteAddress: 'mockHost',
+      },
+      url: mockedValidUrl,
+      headers: {
+        cookie: mockedValidSessionCookie,
+      },
+    });
+
+    await expect(
+      gateway.handleConnection(mockedWebsocket, request),
+    ).resolves.not.toThrow();
+    expect(addClientSpy).not.toBeCalled();
+    expect(mockedWebsocketCloseSpy).toBeCalled();
+  });
+
+  it("closes the connection if user doesn't exist for username", async () => {
+    userExistsForUsername = false;
+
+    const request = Mock.of<IncomingMessage>({
+      socket: {
+        remoteAddress: 'mockHost',
+      },
+      url: mockedValidUrl,
+      headers: {
+        cookie: mockedValidSessionCookie,
+      },
+    });
+
+    await expect(
+      gateway.handleConnection(mockedWebsocket, request),
+    ).resolves.not.toThrow();
+    expect(addClientSpy).not.toBeCalled();
+    expect(mockedWebsocketCloseSpy).toBeCalled();
+  });
+
+  it("closes the connection if url doesn't contain a valid note id", async () => {
+    const request = Mock.of<IncomingMessage>({
+      socket: {
+        remoteAddress: 'mockHost',
+      },
+      url: 'invalid url',
+      headers: {
+        cookie: mockedValidSessionCookie,
+      },
+    });
+
+    await expect(
+      gateway.handleConnection(mockedWebsocket, request),
+    ).resolves.not.toThrow();
+    expect(addClientSpy).not.toBeCalled();
+    expect(mockedWebsocketCloseSpy).toBeCalled();
+  });
+
+  it('closes the connection if url contains an invalid note id', async () => {
+    noteExistsForNoteId = false;
+
+    const request = Mock.of<IncomingMessage>({
+      socket: {
+        remoteAddress: 'mockHost',
+      },
+      url: mockedValidUrl,
+      headers: {
+        cookie: mockedValidSessionCookie,
+      },
+    });
+
+    await expect(
+      gateway.handleConnection(mockedWebsocket, request),
+    ).resolves.not.toThrow();
+    expect(addClientSpy).not.toBeCalled();
+    expect(mockedWebsocketCloseSpy).toBeCalled();
+  });
+
+  it('closes the connection if user has no read permissions', async () => {
+    userHasReadPermissions = false;
+
+    const request = Mock.of<IncomingMessage>({
+      socket: {
+        remoteAddress: 'mockHost',
+      },
+      url: mockedValidUrl,
+      headers: {
+        cookie: mockedValidSessionCookie,
+      },
+    });
+
+    await expect(
+      gateway.handleConnection(mockedWebsocket, request),
+    ).resolves.not.toThrow();
+    expect(addClientSpy).not.toBeCalled();
+    expect(mockedWebsocketCloseSpy).toBeCalled();
+  });
+});
diff --git a/src/realtime/websocket/websocket.gateway.ts b/src/realtime/websocket/websocket.gateway.ts
new file mode 100644
index 000000000..22a92b24c
--- /dev/null
+++ b/src/realtime/websocket/websocket.gateway.ts
@@ -0,0 +1,109 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets';
+import { IncomingMessage } from 'http';
+import WebSocket from 'ws';
+
+import { ConsoleLoggerService } from '../../logger/console-logger.service';
+import { NotesService } from '../../notes/notes.service';
+import { PermissionsService } from '../../permissions/permissions.service';
+import { SessionService } from '../../session/session.service';
+import { User } from '../../users/user.entity';
+import { UsersService } from '../../users/users.service';
+import { RealtimeNoteService } from '../realtime-note/realtime-note.service';
+import { WebsocketConnection } from '../realtime-note/websocket-connection';
+import { extractNoteIdFromRequestUrl } from './utils/extract-note-id-from-request-url';
+
+/**
+ * Gateway implementing the realtime logic required for realtime note editing.
+ */
+@WebSocketGateway({ path: '/realtime' })
+export class WebsocketGateway implements OnGatewayConnection {
+  constructor(
+    private readonly logger: ConsoleLoggerService,
+    private noteService: NotesService,
+    private realtimeNoteService: RealtimeNoteService,
+    private userService: UsersService,
+    private permissionsService: PermissionsService,
+    private sessionService: SessionService,
+  ) {
+    this.logger.setContext(WebsocketGateway.name);
+  }
+
+  /**
+   * Handler that is called for each new WebSocket client connection.
+   * Checks whether the requested URL path is valid, whether the requested note
+   * exists and whether the requesting user has access to the note.
+   * Closes the connection to the client if one of the conditions does not apply.
+   *
+   * @param clientSocket The WebSocket client object.
+   * @param request The underlying HTTP request of the WebSocket connection.
+   */
+  async handleConnection(
+    clientSocket: WebSocket,
+    request: IncomingMessage,
+  ): Promise<void> {
+    try {
+      const user = await this.findUserByRequestSession(request);
+      const note = await this.noteService.getNoteByIdOrAlias(
+        extractNoteIdFromRequestUrl(request),
+      );
+
+      if (!(await this.permissionsService.mayRead(user, note))) {
+        //TODO: [mrdrogdrog] inform client about reason of disconnect.
+        this.logger.log(
+          `Access denied to note '${note.id}' for user '${user.username}'`,
+          'handleConnection',
+        );
+        clientSocket.close();
+        return;
+      }
+
+      this.logger.debug(
+        `New realtime connection to note '${note.id}' (${
+          note.publicId
+        }) by user '${user.username}' from ${
+          request.socket.remoteAddress ?? 'unknown'
+        }`,
+      );
+
+      const realtimeNote =
+        await this.realtimeNoteService.getOrCreateRealtimeNote(note);
+
+      const connection = new WebsocketConnection(
+        clientSocket,
+        user,
+        realtimeNote,
+      );
+
+      realtimeNote.addClient(connection);
+    } catch (error: unknown) {
+      this.logger.error(
+        `Error occurred while initializing: ${(error as Error).message}`,
+        (error as Error).stack,
+        'handleConnection',
+      );
+      clientSocket.close();
+    }
+  }
+
+  /**
+   * Finds the {@link User} whose session cookie is saved in the given {@link IncomingMessage}.
+   *
+   * @param request The request that contains the session cookie
+   * @return The found user
+   */
+  private async findUserByRequestSession(
+    request: IncomingMessage,
+  ): Promise<User> {
+    const sessionId =
+      this.sessionService.extractVerifiedSessionIdFromRequest(request);
+    const username = await this.sessionService.fetchUsernameForSessionId(
+      sessionId,
+    );
+    return await this.userService.getUserByUsername(username);
+  }
+}
diff --git a/src/realtime/websocket/websocket.module.ts b/src/realtime/websocket/websocket.module.ts
new file mode 100644
index 000000000..afec20737
--- /dev/null
+++ b/src/realtime/websocket/websocket.module.ts
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { Module } from '@nestjs/common';
+
+import { LoggerModule } from '../../logger/logger.module';
+import { NotesModule } from '../../notes/notes.module';
+import { PermissionsModule } from '../../permissions/permissions.module';
+import { SessionModule } from '../../session/session.module';
+import { UsersModule } from '../../users/users.module';
+import { RealtimeNoteModule } from '../realtime-note/realtime-note.module';
+import { WebsocketGateway } from './websocket.gateway';
+
+@Module({
+  imports: [
+    LoggerModule,
+    NotesModule,
+    RealtimeNoteModule,
+    UsersModule,
+    PermissionsModule,
+    SessionModule,
+  ],
+  exports: [WebsocketGateway],
+  providers: [WebsocketGateway],
+})
+export class WebsocketModule {}
diff --git a/src/revisions/revisions.service.spec.ts b/src/revisions/revisions.service.spec.ts
index 017c57fd6..e78590a88 100644
--- a/src/revisions/revisions.service.spec.ts
+++ b/src/revisions/revisions.service.spec.ts
@@ -11,6 +11,7 @@ import { Repository } from 'typeorm';
 import { AuthToken } from '../auth/auth-token.entity';
 import { Author } from '../authors/author.entity';
 import appConfigMock from '../config/mock/app.config.mock';
+import authConfigMock from '../config/mock/auth.config.mock';
 import databaseConfigMock from '../config/mock/database.config.mock';
 import noteConfigMock from '../config/mock/note.config.mock';
 import { NotInDBError } from '../errors/errors';
@@ -49,7 +50,12 @@ describe('RevisionsService', () => {
         LoggerModule,
         ConfigModule.forRoot({
           isGlobal: true,
-          load: [appConfigMock, databaseConfigMock, noteConfigMock],
+          load: [
+            appConfigMock,
+            databaseConfigMock,
+            authConfigMock,
+            noteConfigMock,
+          ],
         }),
       ],
     })
diff --git a/src/seed.ts b/src/seed.ts
index 64ece419e..32e6e5a95 100644
--- a/src/seed.ts
+++ b/src/seed.ts
@@ -66,11 +66,9 @@ dataSource
 
     for (let i = 0; i < 3; i++) {
       const author = (await dataSource.manager.save(
-        dataSource.manager.create(Author, Author.create(1)),
+        Author.create(1),
       )) as Author;
-      const user = (await dataSource.manager.save(
-        dataSource.manager.create(User, users[i]),
-      )) as User;
+      const user = (await dataSource.manager.save(users[i])) as User;
       const identity = Identity.create(user, ProviderType.LOCAL, false);
       identity.passwordHash = await hashPassword(password);
       dataSource.manager.create(Identity, identity);
@@ -95,6 +93,49 @@ dataSource
         identity,
       ]);
     }
+    const createdUsers = await dataSource.manager.find(User);
+    const groupEveryone = Group.create('_EVERYONE', 'Everyone', true) as Group;
+    const groupLoggedIn = Group.create(
+      '_LOGGED_IN',
+      'Logged-in users',
+      true,
+    ) as Group;
+    await dataSource.manager.save([groupEveryone, groupLoggedIn]);
+
+    for (let i = 0; i < 3; i++) {
+      if (i === 0) {
+        const permission1 = NoteUserPermission.create(
+          createdUsers[0],
+          notes[i],
+          true,
+        );
+        const permission2 = NoteUserPermission.create(
+          createdUsers[1],
+          notes[i],
+          false,
+        );
+        notes[i].userPermissions = Promise.resolve([permission1, permission2]);
+        notes[i].groupPermissions = Promise.resolve([]);
+        await dataSource.manager.save([notes[i], permission1, permission2]);
+      }
+
+      if (i === 1) {
+        const readPermission = NoteGroupPermission.create(
+          groupEveryone,
+          notes[i],
+          false,
+        );
+        notes[i].userPermissions = Promise.resolve([]);
+        notes[i].groupPermissions = Promise.resolve([readPermission]);
+        await dataSource.manager.save([notes[i], readPermission]);
+      }
+
+      if (i === 2) {
+        notes[i].owner = Promise.resolve(createdUsers[0]);
+        await dataSource.manager.save([notes[i]]);
+      }
+    }
+
     const foundUsers = await dataSource.manager.find(User);
     if (!foundUsers) {
       throw new Error('Could not find freshly seeded users. Aborting.');
diff --git a/src/session/session.service.spec.ts b/src/session/session.service.spec.ts
index d02af232f..322674c3b 100644
--- a/src/session/session.service.spec.ts
+++ b/src/session/session.service.spec.ts
@@ -5,13 +5,18 @@
  */
 import * as ConnectTypeormModule from 'connect-typeorm';
 import { TypeormStore } from 'connect-typeorm';
+import * as parseCookieModule from 'cookie';
+import * as cookieSignatureModule from 'cookie-signature';
+import { IncomingMessage } from 'http';
 import { Mock } from 'ts-mockery';
 import { Repository } from 'typeorm';
 
+import { AuthConfig } from '../config/auth.config';
 import { DatabaseType } from '../config/database-type.enum';
 import { DatabaseConfig } from '../config/database.config';
 import { Session } from '../users/session.entity';
-import { SessionService } from './session.service';
+import { HEDGEDOC_SESSION } from '../utils/session';
+import { SessionService, SessionState } from './session.service';
 
 jest.mock('cookie');
 jest.mock('cookie-signature');
@@ -20,19 +25,38 @@ describe('SessionService', () => {
   let mockedTypeormStore: TypeormStore;
   let mockedSessionRepository: Repository<Session>;
   let databaseConfigMock: DatabaseConfig;
+  let authConfigMock: AuthConfig;
   let typeormStoreConstructorMock: jest.SpyInstance;
+  const mockedExistingSessionId = 'mockedExistingSessionId';
+  const mockUsername = 'mockUser';
+  const mockSecret = 'mockSecret';
   let sessionService: SessionService;
 
   beforeEach(() => {
     jest.resetModules();
     jest.restoreAllMocks();
+    const mockedExistingSession = Mock.of<SessionState>({
+      user: mockUsername,
+    });
     mockedTypeormStore = Mock.of<TypeormStore>({
       connect: jest.fn(() => mockedTypeormStore),
+      get: jest.fn(((sessionId, callback) => {
+        if (sessionId === mockedExistingSessionId) {
+          callback(undefined, mockedExistingSession);
+        } else {
+          callback(new Error("Session doesn't exist"), undefined);
+        }
+      }) as TypeormStore['get']),
     });
     mockedSessionRepository = Mock.of<Repository<Session>>({});
     databaseConfigMock = Mock.of<DatabaseConfig>({
       type: DatabaseType.SQLITE,
     });
+    authConfigMock = Mock.of<AuthConfig>({
+      session: {
+        secret: mockSecret,
+      },
+    });
 
     typeormStoreConstructorMock = jest
       .spyOn(ConnectTypeormModule, 'TypeormStore')
@@ -41,6 +65,7 @@ describe('SessionService', () => {
     sessionService = new SessionService(
       mockedSessionRepository,
       databaseConfigMock,
+      authConfigMock,
     );
   });
 
@@ -52,4 +77,119 @@ describe('SessionService', () => {
     expect(mockedTypeormStore.connect).toBeCalledWith(mockedSessionRepository);
     expect(sessionService.getTypeormStore()).toBe(mockedTypeormStore);
   });
+
+  it('can fetch a username for an existing session', async () => {
+    await expect(
+      sessionService.fetchUsernameForSessionId(mockedExistingSessionId),
+    ).resolves.toBe(mockUsername);
+  });
+
+  it("can't fetch a username for a non-existing session", async () => {
+    await expect(
+      sessionService.fetchUsernameForSessionId("doesn't exist"),
+    ).rejects.toThrow();
+  });
+
+  describe('extract verified session id from request', () => {
+    const validCookieHeader = 'validCookieHeader';
+    const validSessionId = 'validSessionId';
+
+    function mockParseCookieModule(sessionCookieContent: string): void {
+      jest
+        .spyOn(parseCookieModule, 'parse')
+        .mockImplementation((header: string): Record<string, string> => {
+          if (header === validCookieHeader) {
+            return {
+              [HEDGEDOC_SESSION]: sessionCookieContent,
+            };
+          } else {
+            return {};
+          }
+        });
+    }
+
+    beforeEach(() => {
+      jest.spyOn(parseCookieModule, 'parse').mockImplementation(() => {
+        throw new Error('call not expected!');
+      });
+      jest
+        .spyOn(cookieSignatureModule, 'unsign')
+        .mockImplementation((value, secret) => {
+          if (value.endsWith('.validSignature') && secret === mockSecret) {
+            return 'decryptedValue';
+          } else {
+            return false;
+          }
+        });
+    });
+
+    it('fails if no cookie header is present', () => {
+      const mockedRequest = Mock.of<IncomingMessage>({
+        headers: {},
+      });
+      expect(() =>
+        sessionService.extractVerifiedSessionIdFromRequest(mockedRequest),
+      ).toThrow('No hedgedoc-session cookie found');
+    });
+
+    it("fails if the cookie header isn't valid", () => {
+      const mockedRequest = Mock.of<IncomingMessage>({
+        headers: { cookie: 'no' },
+      });
+      mockParseCookieModule(`s:anyValidSessionId.validSignature`);
+      expect(() =>
+        sessionService.extractVerifiedSessionIdFromRequest(mockedRequest),
+      ).toThrow('No hedgedoc-session cookie found');
+    });
+
+    it("fails if the hedgedoc session cookie isn't marked as signed", () => {
+      const mockedRequest = Mock.of<IncomingMessage>({
+        headers: { cookie: validCookieHeader },
+      });
+      mockParseCookieModule('sessionId.validSignature');
+      expect(() =>
+        sessionService.extractVerifiedSessionIdFromRequest(mockedRequest),
+      ).toThrow("cookie doesn't look like a signed cookie");
+    });
+
+    it("fails if the hedgedoc session cookie doesn't contain a session id", () => {
+      const mockedRequest = Mock.of<IncomingMessage>({
+        headers: { cookie: validCookieHeader },
+      });
+      mockParseCookieModule('s:.validSignature');
+      expect(() =>
+        sessionService.extractVerifiedSessionIdFromRequest(mockedRequest),
+      ).toThrow("cookie doesn't look like a signed cookie");
+    });
+
+    it("fails if the hedgedoc session cookie doesn't contain a signature", () => {
+      const mockedRequest = Mock.of<IncomingMessage>({
+        headers: { cookie: validCookieHeader },
+      });
+      mockParseCookieModule('s:sessionId.');
+      expect(() =>
+        sessionService.extractVerifiedSessionIdFromRequest(mockedRequest),
+      ).toThrow("cookie doesn't look like a signed cookie");
+    });
+
+    it("fails if the hedgedoc session cookie isn't signed correctly", () => {
+      const mockedRequest = Mock.of<IncomingMessage>({
+        headers: { cookie: validCookieHeader },
+      });
+      mockParseCookieModule('s:sessionId.invalidSignature');
+      expect(() =>
+        sessionService.extractVerifiedSessionIdFromRequest(mockedRequest),
+      ).toThrow("Signature of hedgedoc-session cookie isn't valid.");
+    });
+
+    it('can extract a session id from a valid request', () => {
+      const mockedRequest = Mock.of<IncomingMessage>({
+        headers: { cookie: validCookieHeader },
+      });
+      mockParseCookieModule(`s:${validSessionId}.validSignature`);
+      expect(
+        sessionService.extractVerifiedSessionIdFromRequest(mockedRequest),
+      ).toBe(validSessionId);
+    });
+  });
 });
diff --git a/src/session/session.service.ts b/src/session/session.service.ts
index 9275d5580..e9515fb94 100644
--- a/src/session/session.service.ts
+++ b/src/session/session.service.ts
@@ -3,25 +3,43 @@
  *
  * SPDX-License-Identifier: AGPL-3.0-only
  */
+import { Optional } from '@mrdrogdrog/optional';
 import { Inject, Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { TypeormStore } from 'connect-typeorm';
+import { parse as parseCookie } from 'cookie';
+import { unsign } from 'cookie-signature';
+import { IncomingMessage } from 'http';
 import { Repository } from 'typeorm';
 
+import authConfiguration, { AuthConfig } from '../config/auth.config';
 import { DatabaseType } from '../config/database-type.enum';
 import databaseConfiguration, {
   DatabaseConfig,
 } from '../config/database.config';
 import { Session } from '../users/session.entity';
+import { HEDGEDOC_SESSION } from '../utils/session';
 
+export interface SessionState {
+  cookie: unknown;
+  user: string;
+  authProvider: string;
+}
+
+/**
+ * Finds {@link Session sessions} by session id and verifies session cookies.
+ */
 @Injectable()
 export class SessionService {
+  private static readonly sessionCookieContentRegex = /^s:(([^.]+)\.(.+))$/;
   private readonly typeormStore: TypeormStore;
 
   constructor(
     @InjectRepository(Session) private sessionRepository: Repository<Session>,
     @Inject(databaseConfiguration.KEY)
     private dbConfig: DatabaseConfig,
+    @Inject(authConfiguration.KEY)
+    private authConfig: AuthConfig,
   ) {
     this.typeormStore = new TypeormStore({
       cleanupLimit: 2,
@@ -32,4 +50,47 @@ export class SessionService {
   getTypeormStore(): TypeormStore {
     return this.typeormStore;
   }
+
+  /**
+   * Finds the username of the user that own the given session id.
+   *
+   * @async
+   * @param sessionId The session id for which the owning user should be found
+   * @return A Promise that either resolves with the username or rejects with an error
+   */
+  fetchUsernameForSessionId(sessionId: string): Promise<string> {
+    return new Promise((resolve, reject) => {
+      this.typeormStore.get(sessionId, (error?: Error, result?: SessionState) =>
+        error || !result ? reject(error) : resolve(result.user),
+      );
+    });
+  }
+
+  /**
+   * Extracts the hedgedoc session cookie from the given {@link IncomingMessage request} and checks if the signature is correct.
+   *
+   * @param request The http request that contains a session cookie
+   * @return The extracted session id
+   * @throws Error if no session cookie was found
+   * @throws Error if the cookie content is malformed
+   * @throws Error if the cookie content isn't signed
+   */
+  extractVerifiedSessionIdFromRequest(request: IncomingMessage): string {
+    return Optional.ofNullable(request.headers.cookie)
+      .map((cookieHeader) => parseCookie(cookieHeader)[HEDGEDOC_SESSION])
+      .orThrow(() => new Error(`No ${HEDGEDOC_SESSION} cookie found`))
+      .map((cookie) => SessionService.sessionCookieContentRegex.exec(cookie))
+      .orThrow(
+        () =>
+          new Error(
+            `${HEDGEDOC_SESSION} cookie doesn't look like a signed cookie`,
+          ),
+      )
+      .guard(
+        (cookie) => unsign(cookie[1], this.authConfig.session.secret) !== false,
+        () => new Error(`Signature of ${HEDGEDOC_SESSION} cookie isn't valid.`),
+      )
+      .map((cookie) => cookie[2])
+      .get();
+  }
 }
diff --git a/src/utils/frontend-integration.ts b/src/utils/frontend-integration.ts
deleted file mode 100644
index 53c776dd8..000000000
--- a/src/utils/frontend-integration.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
- *
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-import { NestExpressApplication } from '@nestjs/platform-express';
-
-import { ConsoleLoggerService } from '../logger/console-logger.service';
-import { useUnless } from './use-unless';
-
-export async function setupFrontendProxy(
-  app: NestExpressApplication,
-  logger: ConsoleLoggerService,
-): Promise<void> {
-  logger.log(
-    `Setting up proxy to frontend dev server on port 3001`,
-    'setupFrontendProxy',
-  );
-  const createProxyMiddleware = (await import('http-proxy-middleware'))
-    .createProxyMiddleware;
-  const frontendProxy = createProxyMiddleware({
-    logProvider: () => {
-      return {
-        log: (msg) => logger.log(msg, 'FrontendProxy'),
-        debug: (msg) => logger.debug(msg, 'FrontendProxy'),
-        info: (msg) => logger.log(msg, 'FrontendProxy'),
-        warn: (msg) => logger.warn(msg, 'FrontendProxy'),
-        error: (msg) => logger.error(msg, 'FrontendProxy'),
-      };
-    },
-    target: 'http://localhost:3001',
-    changeOrigin: true,
-    ws: true,
-  });
-  app.use(useUnless(['/api', '/public'], frontendProxy));
-}
diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts
index 35a5ebf97..1aae4f2e3 100644
--- a/test/app.e2e-spec.ts
+++ b/test/app.e2e-spec.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 import { getConfigToken } from '@nestjs/config';
+import { WsAdapter } from '@nestjs/platform-ws';
 import { Test } from '@nestjs/testing';
 import request from 'supertest';
 
@@ -50,6 +51,7 @@ describe('App', () => {
      * is done.
      */
     const app = moduleRef.createNestApplication();
+    app.useWebSocketAdapter(new WsAdapter(app));
     await app.init();
     await request(app.getHttpServer()).get('/').expect(404);
     await app.close();
diff --git a/test/test-setup.ts b/test/test-setup.ts
index 056c8c6a3..3aac3cafe 100644
--- a/test/test-setup.ts
+++ b/test/test-setup.ts
@@ -21,7 +21,6 @@ import { TokenAuthGuard } from '../src/auth/token.strategy';
 import { AuthorsModule } from '../src/authors/authors.module';
 import { AppConfig } from '../src/config/app.config';
 import { AuthConfig } from '../src/config/auth.config';
-import { DatabaseConfig } from '../src/config/database.config';
 import { MediaConfig } from '../src/config/media.config';
 import appConfigMock from '../src/config/mock/app.config.mock';
 import authConfigMock from '../src/config/mock/auth.config.mock';
diff --git a/yarn.lock b/yarn.lock
index 320031b5c..ab66fd3f6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -694,6 +694,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@hedgedoc/realtime@npm:0.1.1":
+  version: 0.1.1
+  resolution: "@hedgedoc/realtime@npm:0.1.1"
+  dependencies:
+    isomorphic-ws: ^5.0.0
+    lib0: ^0.2.51
+    typed-emitter: ^2.0.0
+    y-protocols: ^1.0.0
+    yjs: ^13.0.0
+  checksum: 72e83af2d586b08daa13a56d6b2a00ff2fdff4196e9587241dc5bac80fd00fe59d88be6754631a0cc541e74d2059c63824e7c1675c7eaf0bb41ad6f6a0728f46
+  languageName: node
+  linkType: hard
+
 "@humanwhocodes/config-array@npm:^0.9.2":
   version: 0.9.5
   resolution: "@humanwhocodes/config-array@npm:0.9.5"
@@ -1054,6 +1067,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@mrdrogdrog/optional@npm:0.1.0":
+  version: 0.1.0
+  resolution: "@mrdrogdrog/optional@npm:0.1.0"
+  checksum: 96a4f0779b343ad35eb25c660cd645f604fe37c6ec87565b06626b4732f88c4938fbc98d6ef8c90b2c577c326d313e45235e92b7e52518855dd22276cf5168d2
+  languageName: node
+  linkType: hard
+
 "@nestjs/cli@npm:8.2.8":
   version: 8.2.8
   resolution: "@nestjs/cli@npm:8.2.8"
@@ -1199,6 +1219,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@nestjs/platform-ws@npm:7.6.17":
+  version: 7.6.17
+  resolution: "@nestjs/platform-ws@npm:7.6.17"
+  dependencies:
+    tslib: 2.2.0
+    ws: 7.4.5
+  peerDependencies:
+    "@nestjs/common": ^7.0.0
+    "@nestjs/websockets": ^7.0.0
+    rxjs: ^6.0.0
+  checksum: 54e4d0194f5aa5212b2f6a7c4e47f92d72cff79dedb084bc6810225e14ee8aa703287db8704097bca81988530d870b6c9cc18730963fc9bd8bcf07060cd95cbf
+  languageName: node
+  linkType: hard
+
 "@nestjs/schedule@npm:2.0.1":
   version: 2.0.1
   resolution: "@nestjs/schedule@npm:2.0.1"
@@ -1284,6 +1318,26 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@nestjs/websockets@npm:8.4.4":
+  version: 8.4.4
+  resolution: "@nestjs/websockets@npm:8.4.4"
+  dependencies:
+    iterare: 1.2.1
+    object-hash: 3.0.0
+    tslib: 2.3.1
+  peerDependencies:
+    "@nestjs/common": ^8.0.0
+    "@nestjs/core": ^8.0.0
+    "@nestjs/platform-socket.io": ^8.0.0
+    reflect-metadata: ^0.1.12
+    rxjs: ^7.1.0
+  peerDependenciesMeta:
+    "@nestjs/platform-socket.io":
+      optional: true
+  checksum: eb3e2d45a95f571bd15dc0f9cb77c80cf980bce630497b2b79cf75e8cd7459084ad21c4239d676f1ab5e8d8c7ff04ee02ee1183e5b4358889a37bfb5a15eeb86
+  languageName: node
+  linkType: hard
+
 "@nodelib/fs.scandir@npm:2.1.5":
   version: 2.1.5
   resolution: "@nodelib/fs.scandir@npm:2.1.5"
@@ -1584,6 +1638,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/cookie-signature@npm:1.0.4":
+  version: 1.0.4
+  resolution: "@types/cookie-signature@npm:1.0.4"
+  checksum: e5ad4448e2369fc5447d6840d748f6a2488de4700458bea7902d6650bf1751dc9c98745abe75bffed745bf10ca8a0fa2f4aefe702b92b0bd8307c6e76111a9d5
+  languageName: node
+  linkType: hard
+
+"@types/cookie@npm:0.5.0":
+  version: 0.5.0
+  resolution: "@types/cookie@npm:0.5.0"
+  checksum: c0ea731cfe2f08dbc8851fa27212e5b34dadb871c14892d309b0970a158d36bf3d20324847263e0698ce6b1c3a5f151bd4fe45c0f9fc3243d1c8115e41d0e1ce
+  languageName: node
+  linkType: hard
+
 "@types/cookiejar@npm:*":
   version: 2.1.2
   resolution: "@types/cookiejar@npm:2.1.2"
@@ -1991,6 +2059,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/ws@npm:8.5.3":
+  version: 8.5.3
+  resolution: "@types/ws@npm:8.5.3"
+  dependencies:
+    "@types/node": "*"
+  checksum: 0ce46f850d41383fcdc2149bcacc86d7232fa7a233f903d2246dff86e31701a02f8566f40af5f8b56d1834779255c04ec6ec78660fe0f9b2a69cf3d71937e4ae
+  languageName: node
+  linkType: hard
+
 "@types/yargs-parser@npm:*":
   version: 21.0.0
   resolution: "@types/yargs-parser@npm:21.0.0"
@@ -5257,21 +5334,27 @@ __metadata:
   resolution: "hedgedoc@workspace:."
   dependencies:
     "@azure/storage-blob": 12.11.0
+    "@hedgedoc/realtime": 0.1.1
+    "@mrdrogdrog/optional": 0.1.0
     "@nestjs/cli": 8.2.8
     "@nestjs/common": 8.4.7
     "@nestjs/config": 2.1.0
     "@nestjs/core": 8.4.7
     "@nestjs/passport": 8.2.2
     "@nestjs/platform-express": 8.4.7
+    "@nestjs/platform-ws": 7.6.17
     "@nestjs/schedule": 2.0.1
     "@nestjs/schematics": 8.0.11
     "@nestjs/swagger": 5.2.1
     "@nestjs/testing": 8.4.7
     "@nestjs/typeorm": 8.1.4
+    "@nestjs/websockets": 8.4.4
     "@trivago/prettier-plugin-sort-imports": 3.2.0
     "@tsconfig/node12": 1.0.11
     "@types/bcrypt": 5.0.0
     "@types/cli-color": 2.0.2
+    "@types/cookie": 0.5.0
+    "@types/cookie-signature": 1.0.4
     "@types/cron": 1.7.3
     "@types/express": 4.17.13
     "@types/express-session": 1.17.4
@@ -5285,6 +5368,7 @@ __metadata:
     "@types/pg": 8.6.5
     "@types/source-map-support": 0.5.4
     "@types/supertest": 2.0.12
+    "@types/ws": 8.5.3
     "@typescript-eslint/eslint-plugin": 5.30.5
     "@typescript-eslint/parser": 5.30.5
     base32-encode: 1.2.0
@@ -5293,6 +5377,7 @@ __metadata:
     class-validator: 0.13.2
     cli-color: 2.0.3
     connect-typeorm: 1.1.4
+    cookie: 0.5.0
     eslint: 8.19.0
     eslint-config-prettier: 8.5.0
     eslint-plugin-import: 2.26.0
@@ -5305,6 +5390,7 @@ __metadata:
     jest: 28.1.2
     joi: 17.6.0
     ldapauth-fork: 5.0.5
+    lib0: 0.2.51
     minio: 7.0.29
     mocked-env: 1.3.5
     mysql: 2.18.1
@@ -5325,11 +5411,14 @@ __metadata:
     supertest: 6.2.4
     swagger-ui-express: 4.4.0
     ts-jest: 28.0.5
-    ts-mockery: ^1.2.0
+    ts-mockery: 1.2.0
     ts-node: 10.8.2
     tsconfig-paths: 4.0.0
     typeorm: 0.3.7
     typescript: 4.7.4
+    ws: 8.7.0
+    y-protocols: 1.0.5
+    yjs: 13.5.39
   languageName: unknown
   linkType: soft
 
@@ -5890,6 +5979,22 @@ __metadata:
   languageName: node
   linkType: hard
 
+"isomorphic-ws@npm:^5.0.0":
+  version: 5.0.0
+  resolution: "isomorphic-ws@npm:5.0.0"
+  peerDependencies:
+    ws: "*"
+  checksum: e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398
+  languageName: node
+  linkType: hard
+
+"isomorphic.js@npm:^0.2.4":
+  version: 0.2.5
+  resolution: "isomorphic.js@npm:0.2.5"
+  checksum: d8d1b083f05f3c337a06628b982ac3ce6db953bbef14a9de8ad49131250c3592f864b73c12030fdc9ef138ce97b76ef55c7d96a849561ac215b1b4b9d301c8e9
+  languageName: node
+  linkType: hard
+
 "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0":
   version: 3.2.0
   resolution: "istanbul-lib-coverage@npm:3.2.0"
@@ -6601,6 +6706,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"lib0@npm:0.2.51, lib0@npm:^0.2.42, lib0@npm:^0.2.49, lib0@npm:^0.2.51":
+  version: 0.2.51
+  resolution: "lib0@npm:0.2.51"
+  dependencies:
+    isomorphic.js: ^0.2.4
+  checksum: bdd00ba42b66d27d048fc169e7d472b9dfe9140e067daeb92db82f40209365d9399aaed679078cc440c496c43d429427b0e231dbaaf171793d98ea6f5476aa3a
+  languageName: node
+  linkType: hard
+
 "libphonenumber-js@npm:^1.9.43":
   version: 1.10.7
   resolution: "libphonenumber-js@npm:1.10.7"
@@ -8339,6 +8453,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"rxjs@npm:*, rxjs@npm:7.5.5, rxjs@npm:^7.2.0":
+  version: 7.5.5
+  resolution: "rxjs@npm:7.5.5"
+  dependencies:
+    tslib: ^2.1.0
+  checksum: e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6
+  languageName: node
+  linkType: hard
+
 "rxjs@npm:6.6.7, rxjs@npm:^6.6.0":
   version: 6.6.7
   resolution: "rxjs@npm:6.6.7"
@@ -8348,15 +8471,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"rxjs@npm:7.5.5, rxjs@npm:^7.2.0":
-  version: 7.5.5
-  resolution: "rxjs@npm:7.5.5"
-  dependencies:
-    tslib: ^2.1.0
-  checksum: e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6
-  languageName: node
-  linkType: hard
-
 "safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1":
   version: 5.1.2
   resolution: "safe-buffer@npm:5.1.2"
@@ -9177,7 +9291,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"ts-mockery@npm:^1.2.0":
+"ts-mockery@npm:1.2.0":
   version: 1.2.0
   resolution: "ts-mockery@npm:1.2.0"
   peerDependencies:
@@ -9258,6 +9372,20 @@ __metadata:
   languageName: node
   linkType: hard
 
+"tslib@npm:2.2.0":
+  version: 2.2.0
+  resolution: "tslib@npm:2.2.0"
+  checksum: a48c9639f7496fa701ea8ffe0561070fcb44c104a59632f7f845c0af00825c99b6373575ec59b2b5cdbfd7505875086dbe5dc83312304d8979f22ce571218ca3
+  languageName: node
+  linkType: hard
+
+"tslib@npm:2.3.1":
+  version: 2.3.1
+  resolution: "tslib@npm:2.3.1"
+  checksum: de17a98d4614481f7fcb5cd53ffc1aaf8654313be0291e1bfaee4b4bb31a20494b7d218ff2e15017883e8ea9626599b3b0e0229c18383ba9dce89da2adf15cb9
+  languageName: node
+  linkType: hard
+
 "tslib@npm:2.4.0, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.1":
   version: 2.4.0
   resolution: "tslib@npm:2.4.0"
@@ -9344,6 +9472,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"typed-emitter@npm:^2.0.0":
+  version: 2.1.0
+  resolution: "typed-emitter@npm:2.1.0"
+  dependencies:
+    rxjs: "*"
+  dependenciesMeta:
+    rxjs:
+      optional: true
+  checksum: 95821a9e05784b972cc9d152891fd12a56cb4b1a7c57e768c02bea6a8984da7aff8f19404a7b69eea11fae2a3b6c0c510a4c510f575f50162c759ae9059f2520
+  languageName: node
+  linkType: hard
+
 "typedarray@npm:^0.0.6":
   version: 0.0.6
   resolution: "typedarray@npm:0.0.6"
@@ -9843,6 +9983,36 @@ __metadata:
   languageName: node
   linkType: hard
 
+"ws@npm:7.4.5":
+  version: 7.4.5
+  resolution: "ws@npm:7.4.5"
+  peerDependencies:
+    bufferutil: ^4.0.1
+    utf-8-validate: ^5.0.2
+  peerDependenciesMeta:
+    bufferutil:
+      optional: true
+    utf-8-validate:
+      optional: true
+  checksum: 5c7d1527f93ef27f9306aaf52db76315e8ff84174d1df717196527c50334c80bc10307dcaf6674a9aca4bb73aac3f77c23d3d9b1800e8aa810a5ee7f52d67cfb
+  languageName: node
+  linkType: hard
+
+"ws@npm:8.7.0":
+  version: 8.7.0
+  resolution: "ws@npm:8.7.0"
+  peerDependencies:
+    bufferutil: ^4.0.1
+    utf-8-validate: ^5.0.2
+  peerDependenciesMeta:
+    bufferutil:
+      optional: true
+    utf-8-validate:
+      optional: true
+  checksum: 078fa2dbc06b31a45e0057b19e2930d26c222622e355955afe019c9b9b25f62eb2a8eff7cceabdad04910ecd2bd6ef4fa48e6f3673f2fdddff02a6e4c2459584
+  languageName: node
+  linkType: hard
+
 "xml2js@npm:^0.4.15, xml2js@npm:^0.4.19, xml2js@npm:^0.4.23":
   version: 0.4.23
   resolution: "xml2js@npm:0.4.23"
@@ -9874,6 +10044,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"y-protocols@npm:1.0.5, y-protocols@npm:^1.0.0":
+  version: 1.0.5
+  resolution: "y-protocols@npm:1.0.5"
+  dependencies:
+    lib0: ^0.2.42
+  checksum: d19404a4ebafcf3761c28b881abe8c32ab6e457db0e5ffc7dbb749cbc2c3bb98e003a43f3e8eba7f245b2698c76f2c4cdd1c2db869f8ec0c6ef94736d9a88652
+  languageName: node
+  linkType: hard
+
 "y18n@npm:^5.0.5":
   version: 5.0.8
   resolution: "y18n@npm:5.0.8"
@@ -9939,6 +10118,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"yjs@npm:13.5.39, yjs@npm:^13.0.0":
+  version: 13.5.39
+  resolution: "yjs@npm:13.5.39"
+  dependencies:
+    lib0: ^0.2.49
+  checksum: 59a3a0307425a7fbc03ed1d632d3080383a40dd1d9ce5c6272dd083dd2674dd4b92f3678d43e4701150f1d9f46692e7a3b0fc9ace48d4a9e20cdea270108697b
+  languageName: node
+  linkType: hard
+
 "yn@npm:3.1.1":
   version: 3.1.1
   resolution: "yn@npm:3.1.1"