Eine Anwendung von Lenny Ziegler (lz045), Jonas Gehrung (jg175), Adrian Rossa(ar162)

Was ist Cloudsheets?
CloudSheets ist eine Cloud-basierte Web Applikation für die kollaborative Bearbeitung von Tabellen. Ziel des Projekts ist es, die Grundfunktionalität von Google Sheets in einer schlanken Anwendung nachzubauen. Gleichzeitig soll durch den gezielten Einsatz möglichst vieler AWS-Services ein cloud-nativer Entwicklungsprozess und praktischer Umgang mit AWS gelernt werden.
Problem
Die Echtzeitbearbeitung durch mehrere gleichzeitig aktive User und die dafür notwendige Synchronisierung und Konfliktlösung kann sehr komplex werden. Selbst wenn zwei User parallel dasselbe Feld der Tabelle bearbeiten, muss ein konsistenter Zustand erreicht werden. Zudem müssen die Zugriffsrechte auf die erstellten Tabellen klar geregelt werden, um eine gemeinsame Nutzung zu ermöglichen. Erschwert wird dies dadurch, dass wir uns noch nie mit einer Plattform wie AWS beschäftigt haben und die benötigten Services sowie deren Verknüpfung erst kennenlernen mussten.
Darüber hinaus mussten wir uns damit auseinandersetzen, wie man Updates und Tests durchführt, ohne den laufenden Betrieb der Applikation zu unterbrechen, und wie man effizient im Team arbeitet. Nicht zuletzt spielen die Kosten ebenfalls eine wichtige Rolle für unser Projekt, da wir von Anfang an mit dem 200$-Startguthaben auf AWS auskommen wollen.
Lösung
- Verwendung eines eigenen WebSocket-Servers im Backend (Hocuspocus) in Kombination mit CRDTs (Conflict-free Replicated Data Types)
- Unterteilung der Architektur in einen Produktionsstack (prod) und einen Entwicklungsstack (dev)
- Deployment mithilfe einer GitHub Actions Pipeline und Dokumentation mit MkDocs auf GitHub Pages
- Zerstörung der Stacks nach dem Testlauf mittels
cdk destroy -all(keine laufenden Kosten) sowie der Einsatz kostengünstiger Services wie des Parameter Store statt des Secrets Managers - Nutzung bestehender Libraries wie AG Grid im Frontend, um den Aufwand an Stellen zu minimieren, die nicht im Fokus des Projekts stehen
Features
- Simples funktionales Frontend
- Account-Registrierung und Login mit Multi-Faktor-Authentifizierung
- Management von Sheets (Erstellen, Löschen, Teilen)
- Rollensystem mit Berechtigungsstufen (Owner, Viewer, Editor)
- Kollaboratives Bearbeiten von Sheets in Echtzeit
- Skalierung und Monitoring mithilfe von AWS
Architektur und Anforderungen
https://jobonabas.github.io/CloudSheets/architecture/#diagram
Wir haben uns für eine monolithische Schichtenarchitektur entschieden, da diese für unser Projekt den besten Kompromiss aus einfacher Wartbarkeit und geringem Overhead durch Einfachheit bietet. Der Nachteil des “Single Point of Failure” wird von uns bewusst in Kauf genommen. Da wir uns konsequent für TypeScript als Programmiersprache entschieden haben, nutzen wir dies sowohl im Backend mit Fastify als auch im Frontend mit React/Vite. Dies schafft eine einheitliche Codebasis, in der wir direkt von der Typisierung profitieren und Konflikte zwischen Front- und Backend minimieren.
Für die Datenhaltung kommt RDS (PostgreSQL) zum Einsatz. Da unser Kernfeature das Editieren von Tabellen ist, passt die relationale Struktur perfekt. Außerdem haben wir im Studium bereits mit relationalen Datenbanken gearbeitet.
Infrastruktur
AWS bietet drei Optionen für Backend-Logik: Lambda (FaaS), EC2 (Virtual Machines) und ECS (Container). Bei unserem Projekt entschieden wir uns für ECS/ECR, also Container, da es den idealen “Middleground” darstellt. Folgende Vor- und Nachteile bieten sich im Vergleich
- Im Vergleich mit Lambda: Lambda ist zustandslos, unsere Echtzeit-Editing-Funktion (WebSockets) wäre dort nur mit Aufwand umsetzbar.
- Im Vergleich zu ECS2 (VMs): Virtuelle Maschinen bieten mehr Kontrolle und Konfigurationsmöglichkeiten, welche für unser Projekt unnötige Komplexität bedeuten würde.
- Container sind außerdem kostengünstig und eignen sich für unsere Websocket Anwendung.
Die gesamte Infrastruktur wird über CDK in TypeScript definiert, um neben der Versionierbarkeit volle Kontrolle über die Infrastruktur zu behalten.
Der Weg zum fertigen Deployment war jedoch von mehreren Entscheidungen und Kompromissen geprägt:
- Frontend: Ursprünglich wollten wir auf CloudFront verzichten, um Kosten zu sparen. Da S3 (als Bucket) kein HTTPS nativ unterstützt, wäre die Domain jedoch nicht sicher gewesen. Da CloudFront jedoch schlussendlich doch der sauberste und damit kostengünstigste Weg ist, TLS/HTTPS bereitzustellen, haben wir uns schließlich dazu entschieden, es doch zu verwenden.
- Stack-Management: Um Änderungen in einer echten AWS Umgebung zu testen bevor sie in Betrieb gebracht werden unterteilen wir in einen Produktions (prod) und einen Dev-Stack (dev).
Security
Sicherheit stellte für uns einen zentralen Aspekt unserer Architekturplanung dar:
- Authentifizierung: Da ein externer Auth-Provider zu einem Vendor-Lock-in führen könnte und ein eigenes JWT-System zusätzlichen Aufwand bedeuten würde, entschieden wir uns, AWS Cognito zu verwenden. Cognito integriert sich zudem nahtlos in das AWS-Ökosystem und in unser CDK-Skript.
- Credentials: Initial war der Plan ausschließlich, den Parameter Store aufgrund der niedrigen Kosten zu verwenden. Jedoch scheint der Secrets Manager AWS Best Practice zu sein und RDS generiert sein Passwort bereits automatisch dort. Deshalb nutzten wir schlussendlich beides.
Realtime-Collaboration
Der Kern unserer Anwendung ist die Kollaboration in Echtzeit, realisiert über WebSockets und CRDTs (Conflict-free Replicated Data Types).
Die Verwendung eines WebSockets ermöglicht dabei die sofortige Übertragung von Änderungen (CRDT-Deltas) an alle verbundenen Clients. Außerdem kümmert sich der WebSocket um den aktiven Push von Nachrichten durch den Server, sobald eine Änderung stattfindet.
Zunächst war der Plan, den offiziellen y-websocket-Server direkt zu nutzen, was jedoch an der mangelnden Kompatibilität mit unserem Fastify-Backend (Import-Probleme und fehlende Types) scheiterte. Schlussendlich entschieden wir uns für Hocuspocus, da es nahtlos mit Yjs kompatibel ist und flexible WebHooks bietet. Hocuspocus übernimmt dabei nur die Logik für die CRDT-Daten, während das TCP-Connection-Upgrade und das Netzwerk von Fastify übernommen werden. Das ermöglicht uns, alle API-Endpoints (REST und WebSocket) unter einem Dach zu vereinen.
Tech-Stack
Frontend
| Technologie | Zweck | Begründung |
| React + Vite | Frontend Framework | Standard für performante UIs; schnelles HMR (Hot Module Replacement). |
| AG Grid CE | Spreadsheet-UI | Hohe Feature-Dichte (Editing, Filter) bei geringem Eigenaufwand. Premade (spart Arbeit) |
| Yjs | CRDT-Engine | Ermöglicht real time kollaboratives Editieren dank automatischer Synchronisierung des Dokuments/Konfliktbehebung durch den CRDT-Datentyp |
| hocuspocus provider | Provider | WebSocket-Verbindung für Yjs |
Backend
| Technologie | Zweck | Begründung |
| Node.js + Fastify | API-Server | Hoher Durchsatz bei WebSockets, TypeScript-nativ, validierte Schemas. |
| hocuspocus Server | Sync-Instanz | Schlankes Backend-Modul für Yjs. |
| PostgreSQL | Persistenz | Speichert User-Daten und Yjs-Doc-Snapshots. Lokal in Docker oder in RDS. |
| AWS Cognito | Auth Service | Stellt die JWT Token zur Authentifizierung bei Nutzung der API Endpoints zur Verfügung |
Cloud(AWS)
| Service | Zweck | Begründung |
| ECS Express | Container Hosting | Kernanwendung, automatisiert SSL und Deployment direkt aus ECR. |
| RDS PostgreSQL | Managed DB | Datenhaltung; Free Tier (db.t3.micro). |
| S3 | SPA Hosting | Hosting der statischen Assets (Frontend) |
| SSM Parameter Store & Secret Manager | Config & Secrets | Verwaltung von DB-Credentials und API-Keys |
| ECR | Image Registry | Notwendig für Container-Deployments auf ECS Express |
DevOps/IaC
| Technologie | Zweck | Begründung |
| AWS CDK (TS) | Infrastructure as Code | Gesamte Infrastruktur als Code, reproduzierbar/versionierbar und typensicher (Typescript). |
| GitHub Actions | CI/CD | Automatisiert den Build-Prozess und den Push zu ECR/ECS. |
| Docker | Containerisierung | lokal Container testen (Backend, Datenbank etc) |
Funktionsprinzipien und Echtzeitbearbeitung
Frontend
Die kollaborative Bearbeitung zerfällt in zwei Fragen, die fast nichts miteinander zu tun haben: Wie kommen Tabelle und Dokument zusammen? Und wie kommt das Dokument zu den anderen und in die Datenbank? Wir haben das absichtlich als zwei getrennte Issues aufgebaut, und die Trennlinie war von Anfang an klar: Ein Hook liefert das Dokument inklusive Verbindungsstatus, die Tabellenkomponente nimmt ein doc entgegen und sonst nichts.
Der Effekt zeigte sich beim Umbau. Solange das Backend noch fehlte, kam das Dokument aus einem lokalen new Y.Doc() im Browser. Danach kam es aus einem HocuspocusProvider. In der Ansicht war das ein getauschter Aufruf:
<span style="color: #8F5902;font-style: italic">// vorher: rein lokal, ohne Backend</span>
<span style="color: #204A87;font-weight: bold">const</span> <span style="color: #000;font-weight: bold">{</span> doc <span style="color: #000;font-weight: bold">}</span> <span style="color: #CE5C00;font-weight: bold">=</span> useLocalSheetDoc<span style="color: #000;font-weight: bold">(</span>id<span style="color: #000;font-weight: bold">)</span>
<span style="color: #8F5902;font-style: italic">// nachher: synchronisiert</span>
<span style="color: #204A87;font-weight: bold">const</span> <span style="color: #000;font-weight: bold">{</span> doc<span style="color: #000;font-weight: bold">,</span> status<span style="color: #000;font-weight: bold">,</span> readOnly <span style="color: #000;font-weight: bold">}</span> <span style="color: #CE5C00;font-weight: bold">=</span> useSheetDoc<span style="color: #000;font-weight: bold">(</span>id<span style="color: #000;font-weight: bold">,</span> apiUrl<span style="color: #000;font-weight: bold">)</span>Der Commit, der die Synchronisation eingeführt hat, ist natürlich größer als diese Zeile: Die Verbindungslogik ist komplett neu dazugekommen. Entscheidend ist, was nicht dabei steht. sheetGrid.tsx taucht im Diff überhaupt nicht auf. Die Tabelle selbst musste kein einziges Mal angepasst werden. useLocalSheetDoc ist außerdem bewusst stehen geblieben: Falls das Backend vor einer Vorführung ausfällt, führt der Weg zurück zu einer funktionsfähigen, aber nicht geteilten Tabelle genau über denselben gewechselten Aufruf. Im Frontend verteilt sich das Ganze auf mehreren Dateien, die je eine Aufgabe haben: sheetDoc.ts definiert das Datenmodell und die einzigen Funktionen, die es verändern. sheetGrid.tsx ist die Tabelle und weiß nicht, ob sie lokal läuft oder an einem Server hängt. sheetConnection.ts kümmert sich um die Verbindung und deren Lebenszyklus. Und sheetView.tsx sorgt dafür, dass die drei Teile zusammenarbeiten. Später kam presence.ts dazu für Anwesenheit und Cursor.
Ein Schreibweg, ein Leseweg
Das Grundprinzip: Die Tabelle hält keine eigenen Daten. Sie stellt dar, was im Yjs-Dokument steht, und schreibt jede Änderung dorthin zurück.
Konkret heißt das: Wenn jemand einen Wert eintippt, meldet AG Grid ihn an den valueSetter der Spalte. Der schreibt ihn nicht in das Zeilenobjekt, sondern ausschließlich ins Dokument. Yjs schließt die Transaktion und benachrichtigt seine Beobachter, noch im selben Aufruf. Der Beobachter liest daraufhin alle Zeilen neu aus dem Dokument aus und meldet React, dass es neu rendern soll. Erst darüber landet der Wert wieder in der Anzeige.
<span style="color: #8F5902;font-style: italic">// frontend/src/components/sheetGrid.tsx</span>
valueSetter<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">(</span>params<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">ValueSetterParams</span><span style="color: #CE5C00;font-weight: bold"><</span>SheetRow<span style="color: #CE5C00;font-weight: bold">></span><span style="color: #000;font-weight: bold">)</span> <span style="color: #000;font-weight: bold">=></span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #204A87;font-weight: bold">if</span> <span style="color: #000;font-weight: bold">(</span>readOnly <span style="color: #CE5C00;font-weight: bold">||</span> <span style="color: #CE5C00;font-weight: bold">!</span>params<span style="color: #000;font-weight: bold">.</span>data<span style="color: #000;font-weight: bold">)</span> <span style="color: #204A87;font-weight: bold">return</span> <span style="color: #204A87;font-weight: bold">false</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">return</span> setCell<span style="color: #000;font-weight: bold">(</span>doc<span style="color: #000;font-weight: bold">,</span> params<span style="color: #000;font-weight: bold">.</span>data<span style="color: #000;font-weight: bold">.</span>id<span style="color: #000;font-weight: bold">,</span> column<span style="color: #000;font-weight: bold">.</span>key<span style="color: #000;font-weight: bold">,</span> <span style="color: #204A87">String</span><span style="color: #000;font-weight: bold">(</span>params<span style="color: #000;font-weight: bold">.</span>newValue <span style="color: #CE5C00;font-weight: bold">??</span> <span style="color: #4E9A06">''</span><span style="color: #000;font-weight: bold">));</span>
<span style="color: #000;font-weight: bold">},</span>
<span style="color: #8F5902;font-style: italic">// frontend/src/lib/sheetDoc.ts</span>
<span style="color: #204A87;font-weight: bold">export</span> <span style="color: #204A87;font-weight: bold">function</span> setCell<span style="color: #000;font-weight: bold">(</span>doc<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">Y.Doc</span><span style="color: #000;font-weight: bold">,</span> rowId<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">,</span> column<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">,</span> value<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">)</span><span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">boolean</span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #204A87;font-weight: bold">const</span> rows <span style="color: #CE5C00;font-weight: bold">=</span> getRows<span style="color: #000;font-weight: bold">(</span>doc<span style="color: #000;font-weight: bold">);</span>
<span style="color: #204A87;font-weight: bold">const</span> index <span style="color: #CE5C00;font-weight: bold">=</span> findRowIndex<span style="color: #000;font-weight: bold">(</span>rows<span style="color: #000;font-weight: bold">,</span> rowId<span style="color: #000;font-weight: bold">);</span>
<span style="color: #204A87;font-weight: bold">if</span> <span style="color: #000;font-weight: bold">(</span>index <span style="color: #CE5C00;font-weight: bold"><</span> <span style="color: #0000CF;font-weight: bold">0</span><span style="color: #000;font-weight: bold">)</span> <span style="color: #204A87;font-weight: bold">return</span> <span style="color: #204A87;font-weight: bold">false</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">const</span> row <span style="color: #CE5C00;font-weight: bold">=</span> rows<span style="color: #000;font-weight: bold">.</span>get<span style="color: #000;font-weight: bold">(</span>index<span style="color: #000;font-weight: bold">);</span>
<span style="color: #204A87;font-weight: bold">if</span> <span style="color: #000;font-weight: bold">((</span>row<span style="color: #000;font-weight: bold">.</span>get<span style="color: #000;font-weight: bold">(</span>column<span style="color: #000;font-weight: bold">)</span> <span style="color: #CE5C00;font-weight: bold">??</span> <span style="color: #4E9A06">''</span><span style="color: #000;font-weight: bold">)</span> <span style="color: #CE5C00;font-weight: bold">===</span> value<span style="color: #000;font-weight: bold">)</span> <span style="color: #204A87;font-weight: bold">return</span> <span style="color: #204A87;font-weight: bold">false</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #8F5902;font-style: italic">// …</span>
<span style="color: #204A87;font-weight: bold">if</span> <span style="color: #000;font-weight: bold">(</span>value <span style="color: #CE5C00;font-weight: bold">===</span> <span style="color: #4E9A06">''</span><span style="color: #000;font-weight: bold">)</span> row<span style="color: #000;font-weight: bold">.</span><span style="color: #204A87;font-weight: bold">delete</span><span style="color: #000;font-weight: bold">(</span>column<span style="color: #000;font-weight: bold">);</span>
<span style="color: #204A87;font-weight: bold">else</span> row<span style="color: #000;font-weight: bold">.</span>set<span style="color: #000;font-weight: bold">(</span>column<span style="color: #000;font-weight: bold">,</span> value<span style="color: #000;font-weight: bold">);</span>
<span style="color: #204A87;font-weight: bold">return</span> <span style="color: #204A87;font-weight: bold">true</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #000;font-weight: bold">}</span>Der Umweg wirkt zunächst überflüssig, ist aber der Kern der Sache: Eine eigene Eingabe nimmt damit denselben Weg wie eine fremde. Es gibt genau einen Schreibweg (valueSetter → setCell) und genau einen Leseweg (Dokument → Beobachter → Anzeige). Zwei Wege in die Anzeige könnten auseinanderlaufen, einer nicht.
Das Datenmodell
Der Hocuspocus-Server kümmert sich nicht um die Dokumentstruktur: Beim Laden schiebt er ein Update durch und beim Speichern holt er sich eines heraus. Was drinsteht, entscheidet letztlich das Frontend. Unsere Wahl lautet:
doc.getArray('rows') → Y.Array<Y.Map<string>>
[
Y.Map { id: "a1b2…", c0: "Umsatz", c2: "1200" },
Y.Map { id: "c3d4…", c0: "Kosten", c2: "800" },
]Die Y.Array trägt die Reihenfolge der Zeilen, jede Zeile ist eine Y.Map von Spaltenschlüssel auf Zellinhalt. Zehn feste Spalten, ein neues Sheet startet standardmäßig mit 25 Zeilen.
Der Vorteil liegt im Zusammenführen. Weil jede Zelle ein eigener Eintrag in der Map ist, berühren sich gleichzeitige Änderungen an verschiedenen Zellen gar nicht. Was Yjs dabei garantiert, von uns mit zwei Tabs gegengeprüft:
| Gleichzeitig passiert | Ergebnis nach dem Abgleich |
| A schreibt c0, B schreibt c1 derselben Zeile | Beide Werte stehen da |
| A und B schreiben dieselbe Zelle | Einer gewinnt, aber beide Seiten sehen denselben |
| A hängt eine Zeile an, B löscht eine andere | Beides gilt, Reihenfolge stimmt überein |
Nur bei derselben Zelle muss überhaupt entschieden werden, und dort gilt der letzte Schreiber. Für eine Tabelle ist das die richtige Regel: Eine Zelle wird als Ganzes bestätigt, nicht Zeichen für Zeichen.
Zwei Details in diesem Modell haben uns Ärger erspart. Erstens arbeiten die Zellinhalte hier als Strings, nicht als Y.Text. Mit Y.Text könnte man innerhalb einer Zelle Zeichen für Zeichen zusammenführen, das braucht aber einen Editor pro Zelle. Diesen gibt es hier nicht, also bleibt der String die zuverlässige Wahl.
Zweitens hat jede Zeile eine eigene id. AG Grid kennt Zeilen über getRowId und aktualisiert bei Änderungen nur die betroffene Zeile, statt die ganze Tabelle neu zu zeichnen. Ohne diese Optimierung würden fremde Eingaben die gerade geöffnete Zelle schließen.
Beim Testen kam außerdem noch ein dritter Punkt dazu: Die Zeilennummer steckt ebenfalls in den Zeilendaten und wird nicht aus dem Grid-Index abgeleitet. Das passt zur gleichen Optimierung wie oben. AG Grid aktualisiert eine Zeile nur, wenn sich deren Daten ändern. Nach dem Löschen rutschen die Zeilen darunter nach oben, ihre Daten bleiben aber gleich, und eine aus dem Index abgeleitete Nummer bliebe sichtbar stehen.
Yjs trifft React
Yjs spricht kein React. Änderungen melden sich über Rückruffunktionen. React braucht dagegen einen Wert, der sich beim Rendern nicht verändert. Die Brücke dazu heißt useSyncExternalStore.
<span style="color: #204A87;font-weight: bold">return</span> <span style="color: #000;font-weight: bold">{</span>
subscribe<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">(</span>onStoreChange<span style="color: #000;font-weight: bold">)</span> <span style="color: #000;font-weight: bold">=></span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #204A87;font-weight: bold">if</span> <span style="color: #000;font-weight: bold">(</span>listeners<span style="color: #000;font-weight: bold">.</span>size <span style="color: #CE5C00;font-weight: bold">===</span> <span style="color: #0000CF;font-weight: bold">0</span><span style="color: #000;font-weight: bold">)</span> <span style="color: #000;font-weight: bold">{</span>
rows<span style="color: #000;font-weight: bold">.</span>observeDeep<span style="color: #000;font-weight: bold">(</span>handleChange<span style="color: #000;font-weight: bold">);</span>
<span style="color: #8F5902;font-style: italic">// …</span>
snapshot <span style="color: #CE5C00;font-weight: bold">=</span> readRows<span style="color: #000;font-weight: bold">(</span>rows<span style="color: #000;font-weight: bold">);</span>
<span style="color: #000;font-weight: bold">}</span>
listeners<span style="color: #000;font-weight: bold">.</span>add<span style="color: #000;font-weight: bold">(</span>onStoreChange<span style="color: #000;font-weight: bold">);</span>
<span style="color: #8F5902;font-style: italic">// …</span>
<span style="color: #000;font-weight: bold">},</span>
getSnapshot<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">()</span> <span style="color: #000;font-weight: bold">=></span> snapshot<span style="color: #000;font-weight: bold">,</span>
<span style="color: #000;font-weight: bold">};</span>Beim ersten Rendern legt sich ein kleiner Speicher an, der den Dokumentinhalt als Array einfacher Objekte hält. Ändert sich das Dokument, liest der Beobachter alle Zeilen neu aus und meldet React, dass es neu rendern soll. Zwischen zwei Änderungen gibt der Speicher immer dasselbe Array zurück, was React genau so verlangt.
Der naheliegendere Weg wäre gewesen, im Effekt zu abonnieren und den Anfangszustand per setState nachzuziehen. Genau das verbietet bei uns die Regel react-hooks/set-state-in-effect aus eslint-plugin-react-hooks: synchrones setState in einem Effekt. Der Speicher umgeht das Problem, statt die Regel zu unterdrücken. Eine kleine Feinheit steckt im Abonnement: Zwischen dem Anlegen des Speichers beim Rendern und dem Abonnieren im Effekt kann schon eine Änderung dazwischenkommen, oft der erste Serverzustand. Deshalb liest der Speicher beim Abonnieren nochmals frisch. Ohne das bliebe die Tabelle leer, bis zufällig die nächste Änderung kommt.
So funktioniert gemeinsames Bearbeiten
Die Tabelle enthält keine eigenen Daten. Sie stellt dar, was im Yjs-Dokument steht, und schreibt jede Änderung dorthin zurück. Ein Yjs-Dokument ist ein CRDT: eine Datenstruktur, die an mehreren Stellen gleichzeitig verändert werden kann und am Ende überall denselben Zustand ergibt, ohne Sperren und ohne Server, der Konflikte auflöst.
Daraus ergibt sich im Frontend genau ein Schreibweg (Zelleingabe → Dokument) und genau ein Leseweg (Dokument → AG Grid). Auch die eigene Eingabe geht über das Dokument und nicht direkt in die Anzeige, so bleiben lokale und fremde Änderungen synchron und laufen nicht auseinander.
Die Verbindung kümmert sich dann um den Rest: Ein Provider verschickt jede Änderung als Binärpaket über WebSocket zum Hocuspocus-Server, der sie an alle anderen Clients verteilt. Das Dokument schreibt er zeitversetzt in eine Postgres-Spalte (bytea). Authentifiziert wird mit einem Cognito-Token, das im Hocuspocus-Protokoll mitläuft, weil ein Browser bei WebSockets keine eigenen Header senden kann. Mit einem einfachen Skript lässt sich messen, dass eine Änderung schon nach etwa 1,8 Millisekunden im anderen Tab ankommt.
Bei gleichzeitigen Änderungen an derselben Zelle gewinnt deterministisch dieselbe Version auf beiden Seiten, entschieden wird das anhand der Client-ID. Änderungen an unterschiedlichen Zellen beeinflussen sich nicht gegenseitig, weil jede Zelle ihr eigenes Dokumentelement ist. Für eine Tabelle bedeutet das: Eine Zelle wird als Ganzes bestätigt, nicht Zeichen für Zeichen.
Fällt die Verbindung aus, zeigt sich „Getrennt“, ein Banner listet die noch nicht übertragenen Änderungen auf, und die Bearbeitung läuft lokal weiter. Die Wiederverbindung erfolgt dann automatisch. Die Provider-Einstellungen haben wir auch angepasst: Standardmäßig verdoppelt sich die Wartezeit auf bis zu 30 Sekunden, was im Worst Case eine halbe Minute Wartezeit vor einer scheinbar toten Tabelle bedeutet. Bei uns passt sich der Abstand um den Faktor 1,5 an und liegt maximal bei zehn Sekunden. Der vorhandene Jitter des Providers hilft zusätzlich, dass nach einem Backend-Neustart nicht alle Clients gleichzeitig anfragen.
<span style="color: #204A87;font-weight: bold">const</span> RECONNECT <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #000;font-weight: bold">{</span>
delay<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">1000</span><span style="color: #000;font-weight: bold">,</span>
factor<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">1.5</span><span style="color: #000;font-weight: bold">,</span>
maxDelay<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">10000</span><span style="color: #000;font-weight: bold">,</span>
jitter<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">true</span><span style="color: #000;font-weight: bold">,</span>
minDelay<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">500</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #8F5902;font-style: italic">// …</span>
messageReconnectTimeout<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">20000</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #000;font-weight: bold">}</span> <span style="color: #204A87;font-weight: bold">as</span> <span style="color: #204A87;font-weight: bold">const</span><span style="color: #000;font-weight: bold">;</span>Die Verbindung gehört dabei nicht zur Ansicht, sondern wird pro Sheet geteilt und bleibt nach dem Verlassen noch fünf Sekunden offen. Ohne diese Schonfrist wäre sie im React StrictMode kaum tragbar. Im Entwicklungsmodus hängt React jede Komponente einmal zusätzlich an und wieder ab, der frisch geöffnete Socket würde sofort wieder geschlossen. Praktischer Nebeneffekt: Der Weg Übersicht → Sheet → Übersicht wird nicht jedes Mal neu aufgerufen.
Haben beide Seiten offline verschiedene Zellen bearbeitet, tauchen am Ende beide Änderungen auf, darum kümmert sich das CRDT und nicht der Server. Cursor und Anwesenheit der anderen laufen absichtlich nicht durchs Dokument, sondern über Yjs’ Awareness-Protokoll: Cursor sind flüchtig und landen nicht in der Datenbank. Nach einem Absturz wäre die Markierung sonst permanent da. Warum Yjs statt Operational Transformation? OT bräuchte einen zentralen Transformationspunkt. Ein CRDT macht den Server bewusst simpel, dieser verteilt und speichert nur und die Offline-Fähigkeit fällt als Nebeneffekt auch einfach weg.
Challenges
Das Datenbankpasswort stand im Klartext
Unsere RDS-Instanz generiert das Passwort selbst und speichert es im AWS Secrets Manager. Um es in den Container zu bekommen, haben wir daraus eine Verbindungs-URL gebaut. Der Aufruf-Name war im Nachhinein eine Warnung: unsafeUnwrap().
Im synthetisierten CloudFormation-Template wirkte das harmlos. Dort stand lediglich eine Referenz wie {{resolve:secretsmanager:…}}, kein Passwort. Beim Deployment wird diese Referenz aber aufgelöst und das Ergebnis landet als normale Umgebungsvariable in der ECS-Service-Konfiguration. Jeder mit der Berechtigung ecs:DescribeServices hätte sie dort lesen können. Die Pipeline hat das Problem bis dahin dadurch gelöst, dass das Verzeichnis cdk.out gar nicht erst als Artefakt hochgeladen wurde. Das war eher eine Symptombehandlung als eine echte Lösung.
Die Lösung: Benutzername und Passwort liegen jetzt im SSM Parameter Store, das Passwort als SecureString. Der ECS-Agent holt beides beim Start des Tasks ab und leitet es ausschließlich an den Container weiter. In der Service-Konfiguration sieht man danach nur noch den Parameter-ARN:
<span style="color: #204A87;font-weight: bold">const</span> dbUser <span style="color: #CE5C00;font-weight: bold">=</span> config<span style="color: #000;font-weight: bold">.</span>database<span style="color: #000;font-weight: bold">.</span>secret<span style="color: #CE5C00;font-weight: bold">?</span><span style="color: #000;font-weight: bold">.</span>secretValueFromJson<span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">'username'</span><span style="color: #000;font-weight: bold">).</span>unsafeUnwrap<span style="color: #000;font-weight: bold">()</span> <span style="color: #CE5C00;font-weight: bold">??</span> <span style="color: #4E9A06">'cloudsheet'</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">const</span> dbPass <span style="color: #CE5C00;font-weight: bold">=</span> config<span style="color: #000;font-weight: bold">.</span>database<span style="color: #000;font-weight: bold">.</span>secret<span style="color: #CE5C00;font-weight: bold">?</span><span style="color: #000;font-weight: bold">.</span>secretValueFromJson<span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">'password'</span><span style="color: #000;font-weight: bold">).</span>unsafeUnwrap<span style="color: #000;font-weight: bold">()</span> <span style="color: #CE5C00;font-weight: bold">??</span> <span style="color: #4E9A06">''</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">const</span> databaseUrl <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #4E9A06">`postgresql://${</span>dbUser<span style="color: #4E9A06">}:${</span>dbPass<span style="color: #4E9A06">}@${</span>dbHost<span style="color: #4E9A06">}:${</span>dbPort<span style="color: #4E9A06">}/${</span>dbName<span style="color: #4E9A06">}`</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #8F5902;font-style: italic">// …</span>
environment<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">[</span>
<span style="color: #8F5902;font-style: italic">// …</span>
<span style="color: #000;font-weight: bold">{</span> name<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">'DATABASE_URL'</span><span style="color: #000;font-weight: bold">,</span> value<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">databaseUrl</span> <span style="color: #000;font-weight: bold">},</span>
<span style="color: #000;font-weight: bold">],</span>secrets<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">[</span>
<span style="color: #000;font-weight: bold">{</span>
name<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">'DB_USERNAME'</span><span style="color: #000;font-weight: bold">,</span>
valueFrom<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">dbParameterArn</span><span style="color: #000;font-weight: bold">(</span><span style="color: #204A87;font-weight: bold">this</span><span style="color: #000;font-weight: bold">,</span> config<span style="color: #000;font-weight: bold">.</span>dbCredentials<span style="color: #000;font-weight: bold">.</span>usernameParameterName<span style="color: #000;font-weight: bold">),</span>
<span style="color: #000;font-weight: bold">},</span>
<span style="color: #8F5902;font-style: italic">// … DB_PASSWORD identisch, über passwordParameterName</span>
<span style="color: #000;font-weight: bold">],</span>Dabei gibt es eine Einschränkung, die AWS zwar dokumentiert, aber nicht durchsetzt: CloudFormation kann keine SecureString-Parameter anlegen. AWS::SSM::Parameter unterstützt nur String und StringList. Im CDK wird der Typ zwar angeboten, das Template entsteht sauber, doch der Deploy scheitert erst zur Laufzeit. Es wurde durch eine kleine Custom Resource gelöst: eine Lambda, die nur den ARN des Secrets erhält, den Wert dann ausliest und per ssm:PutParameter speichert. So landet zu keinem Zeitpunkt ein Klartextwert im CloudFormation-Template.
<span style="color: #204A87;font-weight: bold">new</span> CustomResource<span style="color: #000;font-weight: bold">(</span><span style="color: #204A87;font-weight: bold">this</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #4E9A06">'Resource'</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #000;font-weight: bold">{</span>
serviceToken<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">provider.serviceToken</span><span style="color: #000;font-weight: bold">,</span>
resourceType<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">'Custom::DbCredentialsToSsm'</span><span style="color: #000;font-weight: bold">,</span>
properties<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">{</span>
secretArn<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">props.secret.secretArn</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #8F5902;font-style: italic">// …</span>
<span style="color: #000;font-weight: bold">},</span>
<span style="color: #000;font-weight: bold">});</span>
<span style="color: #8F5902;font-style: italic">// …</span>
actions<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">[</span><span style="color: #4E9A06">'ssm:GetParameters'</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #4E9A06">'ssm:GetParameter'</span><span style="color: #000;font-weight: bold">],</span>
resources<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">this.parameterArns</span><span style="color: #000;font-weight: bold">,</span>Auf dem Backend wird die Verbindung aus den einzelnen DB_*-Variablen zusammengesetzt, das ist bewusst kein Umweg über eine URL. RDS erzeugt Passwörter mit Zeichen wie @, : oder /, und genau das zerlegt einen postgresql://-String an einer ungünstigen Stelle. Fehlt eine Variable, gibt die Fehlermeldung sie beim Namen an und das Passwort bleibt unveröffentlicht.
Zwei Punkte bleiben absichtlich offen: Secrets Manager bleibt für RDS die zentrale Quelle der Wahrheit. Der Parameter Store dient als Auslieferungsweg, nicht als einziger Speicher. Zugangsdaten existieren also an zwei Orten und eine Passwort-Rotation außerhalb des Stacks würde die Kopie veralten lassen. Der spannendste Nebeneffekt kam beim ersten echten Durchlauf: Vier Fehler, die seit Wochen im Repo schlummerten, wurden sichtbar. Die knex-CLI konnte keine TypeScript-Migrationen laden, dadurch wäre der Container nie gestartet. Der RDS-Instanz fehlte der Parameter databaseName, die Datenbank hätte deswegen schlicht nicht existiert. Dazu zwei Testprobleme, die die CI beim ersten Lauf gegen eine frische Datenbank rot gemacht hätten. Solche Fehler ließen sich nicht durch reines Lesen entdecken, sichtbar wurden sie erst, als die komplette CI-Sequenz lokal gegen eine leere Datenbank durchgelaufen ist.
Drei Variablen, die auseinanderlaufen konnten
Unser Deployment-Workflow hatte drei Einstellmöglichkeiten: Zielumgebung, Name des ECR-Repositorys und Name des Stacks, der dieses Repository erstellt. Darüber stand ein Kommentar, der die folgende Person bat, alle drei gemeinsam zu ändern.
Ein Kommentar ist keine Absicherung. Wenn eine der drei Zeilen angepasst wird und die anderen vergessen, landet man bei Dev-Infrastruktur und das Image wandert gleichzeitig in die Prod-Registry und in der Pipeline bemerkt es keiner.
Darum haben wir die Zuordnung in eine JSON-Datei verschoben, die von beiden Seiten gelesen wird: von der CDK-App und vom Workflow. Wir haben bewusst JSON statt TypeScript gewählt, sonst brauchte die Pipeline eine eigene Kopie, und genau diese Dopplung hat das Problem erst verursacht.
<span style="color: #000;font-weight: bold">{</span>
<span style="color: #204A87;font-weight: bold">"dev"</span><span style="color: #000;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #204A87;font-weight: bold">"ecrStack"</span><span style="color: #000;font-weight: bold">:</span> <span style="color: #4E9A06">"EcrDevStack"</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #204A87;font-weight: bold">"ecrRepository"</span><span style="color: #000;font-weight: bold">:</span> <span style="color: #4E9A06">"cloudsheets-backend-dev"</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #204A87;font-weight: bold">"stackSuffix"</span><span style="color: #000;font-weight: bold">:</span> <span style="color: #4E9A06">""</span>
<span style="color: #000;font-weight: bold">},</span>
<span style="color: #204A87;font-weight: bold">"prod"</span><span style="color: #000;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #204A87;font-weight: bold">"ecrStack"</span><span style="color: #000;font-weight: bold">:</span> <span style="color: #4E9A06">"EcrStack"</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #204A87;font-weight: bold">"ecrRepository"</span><span style="color: #000;font-weight: bold">:</span> <span style="color: #4E9A06">"cloudsheets-backend"</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #204A87;font-weight: bold">"stackSuffix"</span><span style="color: #000;font-weight: bold">:</span> <span style="color: #4E9A06">"-prod"</span>
<span style="color: #000;font-weight: bold">}</span>
<span style="color: #000;font-weight: bold">}</span>Eine Kleinigkeit darin ist Absicht und kein Versehen: dev bleibt ohne Suffix, prod erhält -prod. Symmetrisch umbenennen klingt zwar hübsch, funktioniert aber nicht, weil CloudFormation keine Ressourcen beim Umbenennen mitnimmt. Es gäbe sonst zwei Sätze an Stacks, und alte RDS-, Bucket- und Distribution-Dienste würden zurückbleiben, dazu kämen weitere Kosten.
Beim Umbau tauchten zwei weitere Fehler derselben Familie auf. Erstens waren immer beide ECR-Stacks Teil der App: Ein cdk deploy --all für Dev legte nebenbei auch die Prod-Registry an. Zweitens schlich sich bei der App ein Tippfehler im Umgebungsnamen ein. appConfig['prd'] ist undefined und ab diesem Punkt führte jede Prüfung der Form === 'dev' automatisch zum Prod-Zweig. Ein vermeintlicher Dev-Stack hätte überall RETAIN-Richtlinien bekommen.
Absicherung läuft jetzt vierfach und in der Reihenfolge, in der die Prüfungen greifen: Ein Ableitungsskript lehnt unbekannte Umgebungen ab und schlägt Alarm, sobald alte Variablen wieder eingetragen werden. Jest-Tests prüfen die Zuordnung, der ungewöhnlichste davon öffnet die Workflow-Datei selbst und stellt sicher, dass dort nie wieder ein fest verdrahteter Name auftaucht. Ein YAML-Leser-Test wirkte eigenartig, doch genau dort steckte der Fehler. Ein CI-Job erzeugt bei jedem Pull Request beide Umgebungen und vergleicht die entstehenden Templates, kein Stack, kein Repository und kein Bucket der jeweils anderen Umgebung darf darin vorkommen. Und die CDK-App wirft bei unbekanntem Umgebungsnamen eine Exception, statt einfach weiterzulaufen. Seitdem gilt außerdem: Deployment ist eine Entscheidung, kein Nebeneffekt. Anfangs landeten Pushes direkt nach AWS. Jetzt hängt der Deploy-Job an einem GitHub-Environment mit manueller Freigabe, dadurch bleibt der Lauf stehen, bis jemand ihn gezielt freigibt. Das kostet einen Klick und schafft die Gelegenheit, vor dem Deploy die IAM-Berechtigungen in Ruhe zu prüfen.
Ein grüner Deploy, der nichts ausliefert
Der erste vollständige Deploy lief durch. Vier Stacks, 69 Ressourcen, alle im Status CREATE_COMPLETE. Dann lief der Smoke-Test der Pipeline, der nach dem Deploy den Endpunkt /health abfragt und zwanzig Mal schlug er mit Timeout fehl.
Der erste Impuls war, dass es am Container liegt. Die ECS-Konsole sagte etwas anderes: ein laufender Task im Status ACTIVE, „has reached a steady state“, ein registriertes Target in der Target Group. Ein registriertes Target bedeutet, dass der Load Balancer den Health Check des Containers erfolgreich durchführt. Der Container beantwortete also genau die Anfrage, die von außen im Timeout endete.
Wichtiger als die Fehlerursache war die Art des Problems. Ein defekter Container liefert übliche HTTP-Fehlercodes wie 502 oder 503. Ein Timeout bedeutet dagegen: Es kam nicht einmal eine TCP-Verbindung zustande. Das Problem lag also im Netzwerk, vor dem Container. Und eine einzige DNS-Abfrage genügte, um es zu klären.
$ nslookup cl-300fb2d34ac94b11a6e5221442d05494.ecs.eu-central-1.on.aws
Addresses: <span style="color: #0000CF;font-weight: bold">10</span>.0.245.77
<span style="color: #0000CF;font-weight: bold">10</span>.0.172.123Private Adressen. Der Load Balancer blieb also intern, vom Internet aus war er dadurch grundsätzlich nicht erreichbar.
Warum das passiert ist: Der ECS Express Modus nimmt einem viel Arbeit ab. Man übergibt Subnetze und ein Container-Image, den Load Balancer samt Target Group und Security Group baut AWS selbst. Was der Ressourcentyp AWS::ECS::ExpressGatewayService aber nicht anbietet, ist eine Eigenschaft für „öffentlich erreichbar“. Diese Entscheidung hängt allein von den übergebenen Subnetzen ab. Wir hatten private Subnetze gewählt, aus dem nachvollziehbaren Reflex, dass private Subnetze die sicherere Wahl sind. Für die Tasks trifft das auch zu. Nur wird in denselben Subnetzen eben auch die Eingangstür gebaut, und die stand damit in einem Bereich, den von außen niemand erreicht.
Die Korrektur war eine Zeile: privateSubnets → publicSubnets. Wichtiger war der Regressionstest, den wir dazugeschrieben haben. Er prüft, dass das Gateway in den öffentlichen Subnetzen liegt, und begründet im Kommentar, warum: Beim nächsten Lesen sieht „privat” garantiert wieder wie die sichere Wahl aus.
test<span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">'places the gateway in public subnets so it is reachable from outside the VPC'</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #000;font-weight: bold">()</span> <span style="color: #000;font-weight: bold">=></span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #8F5902;font-style: italic">// …</span>
<span style="color: #204A87;font-weight: bold">const</span> subnets<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">Array</span><span style="color: #CE5C00;font-weight: bold"><</span><span style="color: #000;font-weight: bold">{</span> <span style="color: #4E9A06">'Fn::ImportValue'</span><span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span> <span style="color: #000;font-weight: bold">}</span><span style="color: #CE5C00;font-weight: bold">></span> <span style="color: #CE5C00;font-weight: bold">=</span>
service<span style="color: #000;font-weight: bold">.</span>Properties<span style="color: #000;font-weight: bold">.</span>NetworkConfiguration<span style="color: #000;font-weight: bold">.</span>Subnets<span style="color: #000;font-weight: bold">;</span>
expect<span style="color: #000;font-weight: bold">(</span>subnets<span style="color: #000;font-weight: bold">).</span>toHaveLength<span style="color: #000;font-weight: bold">(</span><span style="color: #0000CF;font-weight: bold">2</span><span style="color: #000;font-weight: bold">);</span>
<span style="color: #204A87;font-weight: bold">for</span> <span style="color: #000;font-weight: bold">(</span><span style="color: #204A87;font-weight: bold">const</span> subnet <span style="color: #204A87;font-weight: bold">of</span> subnets<span style="color: #000;font-weight: bold">)</span> <span style="color: #000;font-weight: bold">{</span>
expect<span style="color: #000;font-weight: bold">(</span>subnet<span style="color: #000;font-weight: bold">[</span><span style="color: #4E9A06">'Fn::ImportValue'</span><span style="color: #000;font-weight: bold">]).</span>toContain<span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">'PublicSubnet'</span><span style="color: #000;font-weight: bold">);</span>
<span style="color: #000;font-weight: bold">}</span>
<span style="color: #000;font-weight: bold">});</span>Eine Verbindung, die React StrictMode überlebt
Die Verbindung gehört nicht zur Ansicht, sondern zu einem Verzeichnis, das anhand der Verbindungs-URL unterschieden wird. Die Ansicht meldet beim Einhängen lediglich, dass die Verbindung genutzt wird, und beim Aushängen wieder ab. Fällt der Zähler auf null, gibt es eine fünfsekündige Schonfrist, bevor der Socket geschlossen wird.
<span style="color: #204A87;font-weight: bold">function</span> armRelease<span style="color: #000;font-weight: bold">(</span>connection<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">Connection</span><span style="color: #000;font-weight: bold">,</span> url<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">)</span><span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">void</span> <span style="color: #000;font-weight: bold">{</span>
connection<span style="color: #000;font-weight: bold">.</span>releaseTimer <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #204A87">window</span><span style="color: #000;font-weight: bold">.</span>setTimeout<span style="color: #000;font-weight: bold">(()</span> <span style="color: #000;font-weight: bold">=></span> <span style="color: #000;font-weight: bold">{</span>
connection<span style="color: #000;font-weight: bold">.</span>releaseTimer <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #204A87;font-weight: bold">undefined</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">if</span> <span style="color: #000;font-weight: bold">(</span>connection<span style="color: #000;font-weight: bold">.</span>refs <span style="color: #CE5C00;font-weight: bold">></span> <span style="color: #0000CF;font-weight: bold">0</span><span style="color: #000;font-weight: bold">)</span> <span style="color: #204A87;font-weight: bold">return</span><span style="color: #000;font-weight: bold">;</span>
connection<span style="color: #000;font-weight: bold">.</span>dispose<span style="color: #000;font-weight: bold">();</span>
connections<span style="color: #000;font-weight: bold">.</span><span style="color: #204A87;font-weight: bold">delete</span><span style="color: #000;font-weight: bold">(</span>url<span style="color: #000;font-weight: bold">);</span>
<span style="color: #000;font-weight: bold">},</span> RELEASE_DELAY_MS<span style="color: #000;font-weight: bold">);</span>
<span style="color: #000;font-weight: bold">}</span>Diese Schonfrist ist nicht optional. Im Entwicklungsmodus von React hängt StrictMode jede Komponente absichtlich einmal zusätzlich aus und wieder ein. Ohne sie würde der Socket bei jedem Seitenaufruf sofort wieder geschlossen. Genau dort wäre die Anwendung kaputt, wo man gerade entwickelt und demonstriert. Als Nebeneffekt verbindet der kurze Weg Übersicht → Sheet → Übersicht nicht jedes Mal neu.
Wenn die Verbindung wegbricht
Der Provider verbindet von sich aus neu, nur zu langsam: Voreingestellt verdoppelt sich die Wartezeit bis auf 30 Sekunden. So lange starrt man schlimmstenfalls auf eine tote Tabelle. Bei uns wächst der Abstand deshalb um den Faktor 1,5 und ist bei zehn Sekunden gedeckelt.
<span style="color: #204A87;font-weight: bold">const</span> RECONNECT <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #000;font-weight: bold">{</span>
delay<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">1000</span><span style="color: #000;font-weight: bold">,</span>
factor<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">1.5</span><span style="color: #000;font-weight: bold">,</span>
maxDelay<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">10000</span><span style="color: #000;font-weight: bold">,</span>
jitter<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">true</span><span style="color: #000;font-weight: bold">,</span>
minDelay<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">500</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #8F5902;font-style: italic">// …</span>
messageReconnectTimeout<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">20000</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #000;font-weight: bold">}</span> <span style="color: #204A87;font-weight: bold">as</span> <span style="color: #204A87;font-weight: bold">const</span><span style="color: #000;font-weight: bold">;</span>Der Jitter, den der Provider ohnehin mitbringt, sorgt dafür, dass nach einem Backend-Neustart nicht alle Clients gleichzeitig anklopfen. Solange die Verbindung weg ist, sieht man neben dem Titel „Getrennt“ auch die Anzahl der noch nicht übertragenen Änderungen, ohne diese Zahl wäre der Hinweis „wird übertragen, sobald die Verbindung steht“ eine Behauptung, die niemand nachprüfen könnte. Der rohe Zustand des Providers taugt als Anzeige nichts: Er meldet jeden Wiederverbindungsversuch, und das Abzeichen zappelt ungefiltert im Sekundentakt. Deshalb wird er geglättet, mit etwa 1,2 Sekunden Karenz vor „Getrennt“.
Yjs-WebSocket in Fastify einbetten
Als elementarer Baustein für die Kernfunktion unserer Anwendung kommt wie bereits beschrieben ein WebSocket zum Einsatz. Da wir für die Synchronisierung CRDTs mit Yjs verwenden, bot es sich an, den Websocket im Backend mithilfe von y-websocket zu implementieren, da dies vom gleichen Entwickler wie Yjs ist und damit volle Kompatibilität garantiert.
Jedoch musste ich bei der Implementierung schnell feststellen, dass man y-websocket Server und deren Connection Handler nicht ohne weiteres mit Fastify integrieren kann.
Die setupWSConnection Funktion von y-websocket Server, welche für den Aufbau einer WebSocket-Verbindung zuständig ist, wird nicht als offizieller Export deklariert und kann deshalb nicht direkt im Fastify-Backend importiert und genutzt werden. Ein klassischer Import wie import { setupWSConnection } from 'y-websocket'; ist also nicht möglich.
Auch ein direkter Import über interne Dateien wie utils.js (import { setupWSConnection } from 'y-websocket/bin/utils.js'; wird von Node.js blockiert, da dies bei ESM-Modulen nicht erlaubt ist wenn nicht direkt in package.json vorgesehen. Deshalb führt dies zum Error: Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './bin/utils.js' is not defined by "exports"...
Dazu kommt noch, dass y-websocket keine Types deklariert und damit nicht besonders gut mit TypeScript kompatibel ist.
Ein Lösungsansatz wäre y-websocket als komplett separaten Prozess/Container auszulagern und damit Import Konflikte zu vermeiden. Dies bedeutet jedoch zusätzlichen Deployment und Monitoring-Aufwand, weshalb es besser war nach einer Alternative zu suchen, welche sich mit Fastify und Typescript zu einem eleganten singulären Backend kombinieren lässt.
Diese Bedingungen werden durch Hocuspocus erfüllt (und der Name ist lustig).
Leider stellte sich heraus, dass sich auch Hocuspocus nicht ohne weiteres mit dem Fastify Backend verschmelzen lassen will. Standardmäßig nutzt Hocuspocus seinen eigenen Server Wrapper und damit ein eigenes Netzwerk. Dieser Server Wrapper macht die handleConnection Funktion, welche für die eigentliche WebSocket Verbindung zuständig ist nicht zugänglich.
Zum Glück funktioniert hier jedoch der direkte Import des Hocuspocus Core, welches die handleConnection Funktion enthält, mit import { Hocuspocus } from '@hocuspocus/server .
Damit lässt sich ein Fastify REST Endpoint implementieren, welcher ein Upgrade auf eine WebSocket-Verbindung mit Hocuspocus durchführt.
Ablauf:
- Client ruft Sheet Editor auf und frägt damit WebSocket Upgrade per
/sheets/:id/syncREST-Endpoint an. Fastify prüft die Header und Autorisierung. - Fastify erstellt dann einen WebSocket zwischen dem Client und dem Backend in der AWS Cloud.
- Sobald der Socket besteht übergibt Fastify ihn an Hocuspocus mit der
handleConnectionFunktion. - Hocuspocus prüft noch einmal die Identität (
onAuthenticateHook) und Rolle des Clients und lädt das angefragte Sheet (onLoadDocumentHook) und kümmert sich um Synchronisation über mehrere Clients und das Persistieren von Änderungen (onStoreDocumentHook).
Als Übergabewert erwartet handleConnection ein Standard Request Object (Fetch-Style) von Fastify welches nicht mit dem Fastify eigenen Request-Format übereinstimmt. Es muss also zunächst in das richtige Format gebracht werden:
<span style="color: #8F5902;font-style: italic">// create standard request object (Fetch-style) from fastify request object</span>
<span style="color: #204A87;font-weight: bold">const</span> protocol <span style="color: #CE5C00;font-weight: bold">=</span> request<span style="color: #000;font-weight: bold">.</span>headers<span style="color: #000;font-weight: bold">[</span><span style="color: #4E9A06">'x-forwarded-proto'</span><span style="color: #000;font-weight: bold">]</span> <span style="color: #CE5C00;font-weight: bold">===</span> <span style="color: #4E9A06">'https'</span> <span style="color: #CE5C00;font-weight: bold">?</span> <span style="color: #4E9A06">'https'</span> <span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">'http'</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">const</span> webRequest <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #204A87;font-weight: bold">new</span> Request<span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">`${</span>protocol<span style="color: #4E9A06">}://${</span>request<span style="color: #000;font-weight: bold">.</span>headers<span style="color: #000;font-weight: bold">.</span>host<span style="color: #4E9A06">}${</span>request<span style="color: #000;font-weight: bold">.</span>url<span style="color: #4E9A06">}`</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #000;font-weight: bold">{</span>
headers<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">new</span> Headers<span style="color: #000;font-weight: bold">(</span>request<span style="color: #000;font-weight: bold">.</span>headers <span style="color: #204A87;font-weight: bold">as</span> Record<span style="color: #CE5C00;font-weight: bold"><</span><span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #CE5C00;font-weight: bold">></span><span style="color: #000;font-weight: bold">),</span> <span style="color: #8F5902;font-style: italic">// key value format</span>
method<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">request.method</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #000;font-weight: bold">});</span>Damit sind alle API Endpoints und das gesamte Backend mit Fastify integriert.
Identitäts-Lücke im Sharing-Endpoint (Cognito vs. User-Table)
Eine weitere Herausforderung, die es in der Entwicklung des Backends zu bewältigen durfte, war der Invite/Share Flow, bei welchem User andere mithilfe von Rollen Zugriff erteilen, gemeinsam an einer Tabelle zu arbeiten.
Da das Einladen/Rechte erteilen über die E-Mail Adresse des Users, dem man Zugriff erteilen möchte, funktionieren soll, muss man im Backend auf diese E-Mail Adresse zugreifen.
Aufgrund unserer Entscheidung, die Registrierung von Usern über AWS Cognito abzuwickeln, müsste das Backend also bei jedem Einladevorgang eine Anfrage an Cognito mithilfe der ListUsers API stellen, was wegen Rückgabe aller User langsam ist, API Kosten verursacht und saubere relationale Fremdschlüssel-Beziehungen in der Datenbank erschwert.
Das Access-Token, welches das Backend von Cognito zur Authentifizierung des Clients und der Anfrage genutzt wird, enthält leider ebenfalls nicht die E-Mail Adresse in der Payload.
Man kann das Access-Token zwar mithilfe einer Lambda-Funktion customizen, um die E-Mail Adresse hinzuzufügen, jedoch ist dies sehr komplex und erzeugt Latenz.
Die von uns schlussendlich gewählte Lösung ist eine zusätzliche User-Tabelle in PostgreSQL, in welcher User zusätzlich nach Registrierung gespeichert werden.
Dies ist direkt in der verifyUser() Funktion implementiert, welche sowieso bei jeder Anfrage an einen Endpoint genutzt wird, um die Authentizität mithilfe des JWT-Tokens von Cognito zu prüfen.
Wenn der User, der die Anfrage stellt, noch nicht in der User-Tabelle enthalten ist, werden die Infos wie E-Mail Adresse von Cognito mithilfe des /userInfo Endpoints angefragt und die User Tabelle mit dem User und seiner E-Mail Adresse aktualisiert.
Da dies nur einmal pro User gemacht wird, ist es wesentlich schneller und günstiger, als jedes mal alle User mit der ListUsers API anzufordern. Man spart sich also Anfragen an die User-Datenhaltung von Cognito.
<span style="color: #204A87;font-weight: bold">const</span> user_exists <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #204A87;font-weight: bold">await</span> db<span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">'users'</span><span style="color: #000;font-weight: bold">).</span>where<span style="color: #000;font-weight: bold">({</span> id<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">payload.sub</span> <span style="color: #000;font-weight: bold">}).</span>first<span style="color: #000;font-weight: bold">();</span>
<span style="color: #204A87;font-weight: bold">if</span> <span style="color: #000;font-weight: bold">(</span><span style="color: #CE5C00;font-weight: bold">!</span>user_exists<span style="color: #000;font-weight: bold">)</span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #8F5902;font-style: italic">// first authentication of this user: add user to DB user table with fetched email address</span>
<span style="color: #204A87;font-weight: bold">let</span> email <span style="color: #CE5C00;font-weight: bold">=</span> payload<span style="color: #000;font-weight: bold">.</span>email<span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">if</span> <span style="color: #000;font-weight: bold">(</span><span style="color: #CE5C00;font-weight: bold">!</span>email<span style="color: #000;font-weight: bold">)</span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #204A87;font-weight: bold">const</span> response <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #204A87;font-weight: bold">await</span> fetch<span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">`${</span>cognitoDomain<span style="color: #4E9A06">}/oauth2/userInfo`</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #000;font-weight: bold">{</span>
method<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">'GET'</span><span style="color: #000;font-weight: bold">,</span>
headers<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">{</span>
Authorization<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">`Bearer ${</span>token<span style="color: #4E9A06">}`</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #000;font-weight: bold">},</span>
<span style="color: #000;font-weight: bold">});</span>
<span style="color: #204A87;font-weight: bold">const</span> additional_userdata <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #204A87;font-weight: bold">await</span> response<span style="color: #000;font-weight: bold">.</span>json<span style="color: #000;font-weight: bold">();</span>
email <span style="color: #CE5C00;font-weight: bold">=</span> additional_userdata<span style="color: #000;font-weight: bold">.</span>email<span style="color: #000;font-weight: bold">;</span>
<span style="color: #000;font-weight: bold">}</span>
<span style="color: #204A87;font-weight: bold">await</span> db<span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">'users'</span><span style="color: #000;font-weight: bold">).</span>insert<span style="color: #000;font-weight: bold">({</span>
id<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">payload.sub</span><span style="color: #000;font-weight: bold">,</span>
email<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">email</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #000;font-weight: bold">});</span>
<span style="color: #000;font-weight: bold">}</span>Für die Anfrage an den /userInfo Cognito Endpoint benötigt das Backend die exakte URL der Cognito-Domain, welche jedoch im separaten CDK-Stack erzeugt wird.
Dieses Cross-Stack-Problem haben wir folgendermaßen gelöst:
- Export im Quell-Stack: Die relevanten Werte werden im AuthStack als für andere Stacks auslesbar exportiert:
<span style="color: #8F5902;font-style: italic">// frontend-stack.ts</span>
<span style="color: #204A87;font-weight: bold">public</span> <span style="color: #204A87;font-weight: bold">readonly</span> bucket<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">IBucket</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">public</span> <span style="color: #204A87;font-weight: bold">readonly</span> distribution<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">cloudfront.Distribution</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #8F5902;font-style: italic">// auth-stack.ts</span>
<span style="color: #204A87;font-weight: bold">public</span> <span style="color: #204A87;font-weight: bold">readonly</span> userPoolId<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">public</span> <span style="color: #204A87;font-weight: bold">readonly</span> userPoolClientId<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #204A87;font-weight: bold">public</span> <span style="color: #204A87;font-weight: bold">readonly</span> cognitoDomain<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #8F5902;font-style: italic">// ecs-express-stack.ts</span>
<span style="color: #204A87;font-weight: bold">public</span> <span style="color: #204A87;font-weight: bold">readonly</span> endpoint<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">;</span>- Übergabe der Werte: Bei der Instanziierung von EcsExpressStack (Backend) werden die Werte als Umgebungsvariablen direkt in die Container-Definition des ECS Express Services injiziert.
<span style="color: #204A87;font-weight: bold">const</span> service <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #204A87;font-weight: bold">new</span> CfnExpressGatewayService<span style="color: #000;font-weight: bold">(</span><span style="color: #204A87;font-weight: bold">this</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #4E9A06">'ExpressService'</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #8F5902;font-style: italic">// Unique per environment: a prod deploy must not adopt the dev service.</span>
serviceName<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">scopedName</span><span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">'cloudsheets-backend'</span><span style="color: #000;font-weight: bold">,</span> config<span style="color: #000;font-weight: bold">.</span>environment<span style="color: #000;font-weight: bold">),</span>
executionRoleArn<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">executionRole.roleArn</span><span style="color: #000;font-weight: bold">,</span>
infrastructureRoleArn<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">infrastructureRole.roleArn</span><span style="color: #000;font-weight: bold">,</span>
primaryContainer<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">{</span>
image<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.imageUri</span><span style="color: #000;font-weight: bold">,</span>
containerPort<span style="color: #000;font-weight: bold">,</span>
environment<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">[</span>
<span style="color: #000;font-weight: bold">{</span> name<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">'COGNITO_USER_POOL_ID'</span><span style="color: #000;font-weight: bold">,</span> value<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.cognitoUserPoolId</span> <span style="color: #000;font-weight: bold">},</span>
<span style="color: #000;font-weight: bold">{</span> name<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">'COGNITO_CLIENT_ID'</span><span style="color: #000;font-weight: bold">,</span> value<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.cognitoClientId</span> <span style="color: #000;font-weight: bold">},</span>
<span style="color: #000;font-weight: bold">{</span> name<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">'COGNITO_DOMAIN'</span><span style="color: #000;font-weight: bold">,</span> value<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.cognitoDomain</span> <span style="color: #000;font-weight: bold">},</span>
<span style="color: #8F5902;font-style: italic">// …</span>
<span style="color: #000;font-weight: bold">],</span>
<span style="color: #000;font-weight: bold">},</span>
<span style="color: #000;font-weight: bold">});</span>- Auslesen beim Start im Backend mit dotenv in
index.ts. Danach kann mitprocess.env.COGNITO_DOMAINdie Cognito Domain inverifyUser.tsverwendet werden.
Cognito sagt nein

Cognito braucht SSL-Verschlüsselung um die Tokens sicher zu transportieren.
Mit Cloudfront davor kommen gleich 3 Vorteile mit:
- Cloudfront bietet native SSL-Verschlüsselung. Dadurch werden keine Teuren SSL-Zertifikate benötigt
- Cloudfront cached den Inhalt, der abgefragt wird und dadurch verbessert sich indirekt die Performance der Seite
- Der öffentliche Lesezugriff auf den S3 Bucket kann eingeschränkt werden, was die Sicherheit erhöht
Allerdings kauft man sich gleich 3 potenzielle Nachteile wieder ein:
- Cloudfront erzeugt zusätzliche Kosten und braucht zeit beim deploy
- Die Propagationen dauern
- Falsches Caching-Verhalten kann zu nervigen Bugs führen und zeit beim Debuggen kosten
Ein weiteres Problem, das mit Cognito mitkommt, ist die Konfiguration an sich.
Mit jedem Deploy ändern sich die Konfigurationsdaten. Das Frontend braucht die nötigen Daten für den Austausch der verschiedenen Tokens usw. mit dem idp. Das Problem ist, dass sich mit jedem Deploy diese Daten ändern. Das Frontend braucht also irgendwie automatisiert die Daten. Allerdings kann man dem Frontend nicht einfach Zugriff auf die gesamte Cloud geben. Die Lösung war eine extra json die vom Client aus dem Bucket gefetcht wird. Dadurch benötigt das Frontend keinen Zugriff auf AWS selbst, sondern nur auf den Bucket, was es schon hat. Es aktualisiert sich von selbst.
Henne-Ei-Problem: And it goes round ‘n round
Zunächst gab es einige zirkuläre Beziehungen, die Probleme beim Verbinden des Frontends mit dem Backend erzeugt haben.
Im ersten Ansatz wurde dieses Problem versucht mit Custom Ressources im Frontend und Backend-Stack zu lösen. Entscheidend war jedoch die Anpassung der Build-Reihenfolge.
Sobald die nötigen exportierten Variablen existieren, kann in einem separaten Frontend-Config-Stack eine JSON erzeugt werden, welche in den S3-Bucket gepusht wird.
<span style="color: #204A87;font-weight: bold">export</span> <span style="color: #204A87;font-weight: bold">interface</span> FrontendConfigStackConfig <span style="color: #000;font-weight: bold">{</span>
frontendBucket<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">IBucket</span><span style="color: #000;font-weight: bold">;</span>
frontendDistribution<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">cloudfront.Distribution</span><span style="color: #000;font-weight: bold">;</span>
userPoolId<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">;</span>
userPoolClientId<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">;</span>
cognitoDomain<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">;</span>
callbackUrl<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">;</span>
apiUrl<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">;</span>
<span style="color: #000;font-weight: bold">}</span>
<span style="color: #204A87;font-weight: bold">export</span> <span style="color: #204A87;font-weight: bold">class</span> FrontendConfigStack <span style="color: #204A87;font-weight: bold">extends</span> Stack <span style="color: #000;font-weight: bold">{</span>
<span style="color: #204A87;font-weight: bold">constructor</span><span style="color: #000;font-weight: bold">(</span>
scope<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">Construct</span><span style="color: #000;font-weight: bold">,</span>
id<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">string</span><span style="color: #000;font-weight: bold">,</span>
config<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">FrontendConfigStackConfig</span><span style="color: #000;font-weight: bold">,</span>
props?<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">StackProps</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #000;font-weight: bold">)</span> <span style="color: #000;font-weight: bold">{</span>
<span style="color: #204A87;font-weight: bold">super</span><span style="color: #000;font-weight: bold">(</span>scope<span style="color: #000;font-weight: bold">,</span> id<span style="color: #000;font-weight: bold">,</span> props<span style="color: #000;font-weight: bold">);</span>
<span style="color: #204A87;font-weight: bold">new</span> s3deploy<span style="color: #000;font-weight: bold">.</span>BucketDeployment<span style="color: #000;font-weight: bold">(</span><span style="color: #204A87;font-weight: bold">this</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #4E9A06">'DeployFrontendConfig'</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #000;font-weight: bold">{</span>
sources<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">[</span>
s3deploy<span style="color: #000;font-weight: bold">.</span>Source<span style="color: #000;font-weight: bold">.</span>jsonData<span style="color: #000;font-weight: bold">(</span><span style="color: #4E9A06">'config.json'</span><span style="color: #000;font-weight: bold">,</span> <span style="color: #000;font-weight: bold">{</span>
authority<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #4E9A06">`https://cognito-idp.${</span><span style="color: #204A87;font-weight: bold">this</span><span style="color: #000;font-weight: bold">.</span>region<span style="color: #4E9A06">}.amazonaws.com/${</span>config<span style="color: #000;font-weight: bold">.</span>userPoolId<span style="color: #4E9A06">}`</span><span style="color: #000;font-weight: bold">,</span>
clientId<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.userPoolClientId</span><span style="color: #000;font-weight: bold">,</span>
callbackUrl<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.callbackUrl</span><span style="color: #000;font-weight: bold">,</span>
logoutUrl<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.callbackUrl</span><span style="color: #000;font-weight: bold">,</span>
cognitoDomain<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.cognitoDomain</span><span style="color: #000;font-weight: bold">,</span>
apiUrl<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.apiUrl</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #000;font-weight: bold">}),</span>
<span style="color: #000;font-weight: bold">],</span>
destinationBucket<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.frontendBucket</span><span style="color: #000;font-weight: bold">,</span>
distribution<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">config.frontendDistribution</span><span style="color: #000;font-weight: bold">,</span>
distributionPaths<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #000;font-weight: bold">[</span><span style="color: #4E9A06">'/config.json'</span><span style="color: #000;font-weight: bold">],</span>
prune<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">false</span><span style="color: #000;font-weight: bold">,</span>
<span style="color: #000;font-weight: bold">});</span>
<span style="color: #000;font-weight: bold">}</span>
<span style="color: #000;font-weight: bold">}</span>Diese kann dann vom Frontend gefetcht werden. Dadurch sind die Stacks entkoppelt. Man kauft sich allerdings einen Nachteil ein:
Wenn einer der Stacks beim deploy fehlschlägt, dann erzeugt dies Probleme. Dies geht auch mit der Architektur einher, die zu Beginn thematisiert wurde.
Money, Money, Money
In die Cloud zu deployen kann teuer werden. Speziell dann, wenn man vergisst, die verwendeten Services nach der Präsentation zu löschen und man dann plötzlich eine Mail bekommt: “Ihr Budget wurde überschritten”.

In diesem Fall muss man sich Gedanken machen, wann es Sinn macht, eine Anwendung in die Cloud zu deployen. Benutzen wenige User die Anwendung, so reicht es unter Umständen aus, einen einfachen Webserver zu verwenden. Muss die Anwendung sich an die Nutzlast anpassen und wird kollaborativ (unter Umständen in mehreren Regionen) verwendet, so kann es Sinn machen, sie in die Cloud auszulagern. Dies bietet zusätzlich den Vorteil, dass man sich um die Wartung der Infrastruktur nicht kümmern muss.
Über die Zeit hinweg hat sich bei uns ein Service als größter Kostenfaktor gezeigt: EC2. Selbst im Ruhezustand produzierte dieser Kosten. Zu Spitzenzeiten sogar 48,36$, in Verbindung mit mit dem Loadbalancer sogar etwas mehr als 60$ in etwas mehr als einer Woche. Dies kann Kosten von etwa 200$ bedeuten.

In diesem Fall muss man sich fragen, ob es nicht besser wäre, statt EC2 FARGATE als Launch-Type zu nutzen, da dies Preis/Leistungs-Technisch deutlich besser ist. Andere Alternativen sind beispielsweise Fargate Spot. Dies ist aber nicht so stabil wie Fargate selbst. Für dev Umgebungen könnte dies unter Umständen sogar reichen. Nach einer genaueren Recherche stellte sich heraus, dass unter EC2-Other der größte Kostenfaktor das ungenutzte NAT-Gateway war. Innerhalb von 11 Tagen sind 38,55$ an Kosten angefallen aufgrund des ungenutzten Nat-Gateways. Auf einen Monat hochgerechnet sind das etwas mehr als 100$.
<span style="color: #204A87;font-weight: bold">this</span>.vpc <span style="color: #CE5C00;font-weight: bold">=</span> <span style="color: #204A87;font-weight: bold">new</span> Vpc(<span style="color: #204A87;font-weight: bold">this</span><span style="color: #CE5C00;font-weight: bold">,</span> <span style="color: #4E9A06">'BackendVpc'</span><span style="color: #CE5C00;font-weight: bold">,</span> <span style="color: #000;font-weight: bold">{</span> maxAzs<span style="color: #CE5C00;font-weight: bold">:</span> <span style="color: #204A87;font-weight: bold">2</span> <span style="color: #000;font-weight: bold">}</span>)<span style="color: #000;font-weight: bold">;</span>
Zur Kostenoptimierung würde es sich also anbieten das NatGateway zu entfernen.
Weitere Themen, die man sich noch anschauen könnte, sind die VPC-Kosten. Allerdings sind diese nicht weiter optimierbar, da die Kostenursache die Public IP-Address ist und diese für den Loadbalancer benötigt wird.
Next Steps
Für die Zukunft würden sich noch folgende Themen anbieten
- Horizontale Skalierung mit Redis -> wenn User von verschiedenen Backends/Servern dasselbe Sheet bearbeiten wollen
- feste Domain (damit sich nicht nach jedem Deployment jedes mal der Link ändert)
- RDS-Proxy für DB-Skalierung
- Multi-Region-Deployment
- Kostenoptimierung
Learnings
Ein grüner Deploy ist kein Nachweis für eine funktionierende Anwendung. CloudFormation meldet Erfolg, sobald die Ressourcen angelegt sind. Ob dahinter tatsächlich etwas erreichbar ist, bleibt eine andere Frage. Der Smoke-Test in der Pipeline war die einzige Prüfung, die den internen Load Balancer bemerkt hat, kein Unit-Test hätte das gefunden.
Die Art des Fehlers zeigt, wo man suchen muss. Timeout statt Fehlercode war das eigentliche Signal. Hätten wir zuerst in die Container-Logs geschaut, wären wir lange beschäftigt gewesen. Die sahen nämlich völlig normal aus.
Früher deployed – localhost lügt. Fast alles, was uns aufgehalten hat, lag ausschließlich in der Cloud: das fehlende databaseName, die knex-CLI, der interne Load Balancer. Lokal lief jedes Mal alles. Einmal hat localhost sogar wortwörtlich gelogen: Die CI starb an ECONNREFUSED ::1:5432, weil Node ab Version 17 localhost zuerst auf IPv6 auflöst, der Postgres-Container im Actions-Runner seinen Port aber nur auf IPv4 veröffentlicht. Die Lösung waren neun Zeichen: localhost → 127.0.0.1. Rückblickend hätte ein durchgehender Deployment-Pfad, notfalls mit einem Dummy-Frontend, Wochen vor der eigentlichen Funktionalität stehen sollen.
Absicherungen dort, wo sie greifen. Ein Kommentar über drei Config-Zeilen hat noch nie jemanden aufgehalten. Ein Skript, das die Ausführung abbricht, schon. Wir haben die betroffenen Stellen so umgebaut, dass der falsche Zustand gar nicht erst entstehen kann, statt nur davor zu warnen.
Die Build-Reihenfolge entscheidet: Die Build-Reihenfolge entscheidet, wie sich Dinge miteinander verknüpfen lassen. Und kann einen Prozess vereinfachen oder auch erschweren. Oder eine zirkuläre Beziehung verursachen 🙂
Services verstehen: Es erleichtert Vieles, wenn man sich zuerst mit der Konsole eines Services beschäftigt. So bekommt man ein grundlegendes Verständnis der Services und kann die einzelnen Parameter später leichter im cdk umsetzen. Auch hätte das uns die vielen Kosten in Verbindung mit dem ungenutzten Natgateway gespart.
Früher Deployed – Localhost lügt (Teil 2): Cognito funktioniert mit Localhost problemlos ohne SSL-Verschlüsselung. Sobald die Webseite aber selbst deployed wird, verändert sich die Situation. Cognito wirft dann eine Fehlermeldung, weil die Token ohne Verschlüsselung nicht übertragen werden können.
Kosten, die bis ins Astronomische gehen: Dies geht mit dem Punkt “Services verstehen” einher. Die Art, wie Services genutzt werden, kann sich auf den Faktor Kosten auswirken (siehe ungenutztes NAT-Gateway).
Ein zentrales einziges Backend ist nicht trivial: Bei der Nutzung externer Libraries und Tools ist es oft nicht selbstverständlich, dass sie sich nahtlos zu einem einheitlichen Backend zusammenfügen lassen. Es hilft, diese Anforderung schon bei der Tool-Auswahl und -Recherche bereits im Hinterkopf zu haben oder auf ein einheitliches Backend zu verzichten und stattdessen mehrere Services/Container zu verknüpfen.
Cross-Stack-Variablen: Das dynamische Durchreichen von Variablen über verschiedene Stacks hinweg ist mit Infrastructure as Code erstaunlich unkompliziert.
Fazit
Dieses Projekt hat seinen Zweck voll erfüllt. Wir konnten erste praktische Erfahrungen mit AWS sammeln und eine Menge dabei lernen. Gleichzeitig haben wir gemerkt, wie viel Tiefe und Lernpotenzial AWS darüber hinaus noch bietet.
Mit etwas mehr Zeit hätten wir uns beispielsweise gerne noch die Synchronisation mehrerer Backend-Instanzen mithilfe von Redis/Caching angeschaut und die Skalierung ausgiebiger getestet. Gerade weil wir auf eine recht klassische Schichtenarchitektur gesetzt haben, wären wir hier vermutlich schnell auf weitere spannende Herausforderungen und Learnings gestoßen.
Besonders überrascht hat uns, wie schnell die Kosten bei AWS in die Höhe schießen können. In zukünftigen Projekten werden wir hier definitiv noch wachsamer sein.
Abseits der Technik gab es, wie so oft bei praktischen Gruppenarbeiten, auch wichtige Learnings für die Zusammenarbeit. Neben transparenter Kommunikation gehört dazu vor allem Git. Rückblickend hätten wir gerne schon etwas früher im Projekt eine Pipeline für automatisiertes Testen und Deployen aufgesetzt. Dies hätte einige Prozesse vermutlich enorm beschleunigt, da Fehler früher aufgefallen wären.
Wir hoffen, wir konnten mit diesem Blogpost unseren Lernprozess und die gelösten Hürden gut nachvollziehbar machen!
Quellen
- https://tiptap.dev/docs/hocuspocus/ Abgerufen am: 18.06.2026 10:40
- https://www.telerik.com/blogs/modern-apis-fastify-node Abgerufen am: 18.06.2026 10:40
- https://www.fradev.io/articles/kubernetes/create-react-project-with-vite/ Abgerufen am: 18.06.2026 10:40
- https://yjs.dev/ Abgerufen am: 18.06.2026 10:41
- https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type Abgerufen am: 18.06.2026 10:43
- https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html, Abgerufen am 13.09.2026 16:35
- https://aws.amazon.com/de/about-aws/whats-new/2016/11/announcing-regional-edge-caches-for-amazon-cloudfront/, Abgerufen am 13.09.2026 16:38
- https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html, Abgerufen am 13.09.2026 16:39
- https://aws.amazon.com/de/cloudfront/pricing/, Abgerufen am 13.09.2026 16:40
- https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html, Abgerufen am 13.09.2026 16:42

Leave a Reply
You must be logged in to post a comment.