1. Einstieg - Warum Frankenstein?
Die 2010er Jahre waren der große Frontendkrieg. Viele neue UI-Frameworks entstanden in dieser Zeit und verschwanden wieder. Das sieht man heutigen Enterprises an ihren Legacy-Anwendungen an. Viele haben Angular, React, Vue oder Svelte für verschiedene Use Cases im Einsatz. Wenn man diese Anwendungen nicht als Inseln, sondern in ihrer Gesamtheit nutzen möchte, bleibt normalerweise nur die Möglichkeit, auf ein gemeinsames Framework zu migrieren. Ein langfristiger und fehleranfälliger Prozess. Eine bessere Möglichkeit kann es sein, die Apps über eine gemeinsame Orchestrator-Plattform miteinander kommunizieren zu lassen. Das ist die Ausgangslage für unseren Frankenstein Meeting Room auf Basis von Native Federation.
2. Was die App macht
Der Aufbau soll zeigen, wie man eine heterogene Web-App-Landschaft (Angular, React, Svelte) über Native Federation integrieren kann. Das Projekt simuliert im Kleinen eine Legacy-Enterprise-Landschaft. Konkret: In einem Angular-Kalender (Shell) können Meetings ausgewählt werden. Die verknüpften Informationen werden in einem Svelte-Mermaid-Diagramm (Remote 1) sowie einem React-Excalidraw-Whiteboard (Remote 2) dargestellt. Beim Wechsel des Meetings werden die Daten gespeichert und geladen (LocalStorage).
Die Demo läuft unter lutzleonhardt.de/frankenstein-meeting-room, der Code liegt auf GitHub.
3. Ausgangslage und Workflow
Im vorherigen Artikel habe ich bereits die Spezifikation der Umsetzung und das UI-Mockup (Claude Design) vorgestellt.
Das ursprüngliche UI-Mockup aus Teil 1, generiert mit Claude Design. Der Lab-Notebook-Look hat es bis in die fertige App geschafft.
Zur Umsetzung habe ich mein Skill-Kit für agentische Workflows zusammen mit den Code-Agenten Claude Code und Codex im Tandem genutzt. Die Agenten haben die Spezifikation in einzelne Meilensteinpläne überführt. Jeder Meilensteinplan wurde in Tasks zerlegt. Der Vorteil dieses Vorgehens: jeder Meilenstein bringt ein prüfbares Artefakt hervor, das der Entwickler isoliert validieren und reviewen kann. Das Ergebnis dient dann als Basis für den nächsten Schritt. Pro Task entsteht dabei nicht nur der Code, sondern auch ein Task-Log: was probiert wurde, was verworfen wurde, welche Hypothesen unterwegs starben. Diese Negativ-Information geht in normalen Commit-Bodies fast immer verloren — beim Schreiben dieses Posts musste ich genau dort am häufigsten nachlesen.
Ein kleines Problem dabei: der NF-Builder gibt nach Abschluss seiner Arbeit den Prozess nicht frei. Am eigenen Terminal merkt man es nicht. Man drückt Ctrl-C und macht weiter. Im agentischen Workflow wird das zum Problem: der Agent weiß nicht, dass die Arbeit fertig ist, und wartet weiter. Der Ausweg war ein kleines Wrapper-Script: Artefakt vorher löschen, ng build starten, auf Artefakt pollen, dann der gesamten Prozessgruppe SIGKILL schicken.
4. Native Federation: Shell, Remotes, Bus
Im Zentrum von Native Federation steht die Shell (auch Host genannt), welche die anderen Web-Apps oder exportierten UI-Komponenten als Remotes von entfernten Endpunkten lädt. In unserem Fall sind die Remotes eigenständige Web-Apps (Svelte, React), welche je ein Custom Element für die Shell exportieren.
Da für die Kommunikation zwischen den heterogenen Frameworks kein internes Binding genutzt werden kann, muss ein agnostisches Nachrichten-Pattern etabliert werden. Im Fall von Frankenstein Meeting Room habe ich mich für einen simplen, selbst implementierten Pub-Sub-Bus entschieden. Die Topologie ist sternförmig: die Remotes können nicht untereinander kommunizieren.
Die Bus-Implementierung liegt im Shared-Bereich des Monorepos, Shell und Remotes importieren die Funktionalität jeweils selbst. Der eigentliche Bus wird als Singleton an globalThis (window) gehängt. Die BusEvents wurden als DeepReadonly<T> typisiert, um eine versehentliche Mutation über die Framework-Grenze zur Kompilierzeit zu verhindern. Ein Deep-Clone wäre zu kostspielig: die gesamte Excalidraw-Datenstruktur hätte geklont werden müssen.
// packages/shared/src/bus.ts
type BusEvents = {
'context:request': {};
'event:selected': { meetingId: string; initialData: Meeting };
'drawing:changed': { meetingId: string; excalidrawData: ExcalidrawDemoData };
'diagram:changed': { meetingId: string; mermaidSource: string };
};
const bus = (globalThis.frankensteinBus ??= new EventTarget()) as EventTarget;
export function emit<K extends keyof BusEvents>(
name: K, payload: DeepReadonly<BusEvents[K]>,
) {
bus.dispatchEvent(new CustomEvent(name, { detail: payload }));
}
Sternförmige Topologie: alle Bus-Kommunikation läuft durchs Zentrum, Persistenz liegt beim Host. Live gerendert im Svelte-Mermaid-Editor der Demo selbst.
5. M1 — Monorepo, Shared-Package, Shell-Skeleton
Im ersten Meilenstein habe ich das pnpm-Monorepo aufgesetzt und im Shared-Package die Typen sowie den Bus implementiert. Über ein Native-Federation-Schematic wird das Angular-Projekt anschließend in eine Native-Federation-Shell überführt. Im Kern bekommt das Projekt dabei einen angepassten Build, einen leicht abgewandelten Bootstrap-Prozess und eine Federation-Konfiguration. Der Bootstrap läuft zweistufig: die Shell lädt zuerst das Federation-Manifest, injiziert die Import-Map und startet erst dann die eigentliche Angular-App. Die Konfiguration legt fest, welche Abhängigkeiten zwischen Shell und Remotes geteilt werden.
Das ist ein weiterer Vorteil von Native Federation: je nach Konfiguration werden Libraries nur einmal geladen, auch wenn mehrere Remotes und die Shell sie nutzen.
6. M2 — Host komplett: Kalender, Meeting-Service, Panels
Im Meilenstein 2 habe ich die Shell komplettiert, indem ich den Kalender (Schedule-X) hinzugefügt habe. Außerdem gibt es einen Meeting-Service, der auf Basis von Signals die aktuellen Meetings managt, über den Event-Bus Nachrichten empfängt und Daten sendet, zum Beispiel um die Remotes mit dem aktiven Meeting zu initialisieren. Initial werden die Meetings durch ein Seed befüllt. Bei Änderungen werden sie im LocalStorage gespeichert und beim Start wieder von dort geladen. In der Shell gibt es noch zwei weitere Angular-Komponenten: die Detailanzeige für das aktive Meeting sowie eine Übersicht der Bus-Nachrichten. Das Layout ist dreispaltig: Kalender links, Remotes in der Mitte, Detailanzeige und Bus-Log rechts.
Ein kleines Detail im Meeting-Service: die Remotes debouncen ihre Updates (500 ms), und ohne Guard würde ein Meeting-Wechsel mitten im Debounce dazu führen, dass der alte Draft das neue Meeting überschreibt. Der Fix: jede applyDrawingChange/applyDiagramChange im Service prüft die meetingId gegen currentMeeting und droppt stale Updates. Eine Zeile Code, ohne die der Bus eine subtile Daten-Korruption hätte.
private applyDrawingChange(p: DrawingChangedPayload): void {
if (p.meetingId !== this.currentMeeting()?.id) return; // stale-update guard
// ... persist
}
Im mittleren Bereich der Shell müssen jetzt noch die React- und Svelte-Anwendungen integriert werden.
7. M3 & M4 — Whiteboard und Mermaid als Remotes
Im Meilenstein 3 und 4 habe ich das React-Whiteboard (Excalidraw) und das Svelte-Mermaid-Diagramm jeweils als Remote implementiert. Bei beiden bin ich gleich vorgegangen: zuerst die Standalone-App, dann die Federation-Konfiguration darauf gesetzt. So konnte ich Excalidraw beziehungsweise das Mermaid-Diagramm in Isolation entwickeln und testen, bevor das Remote in die Shell eingebunden wurde. Dasselbe UI Projekt (react, svelte) läuft weiterhin unter eigenem Port als eigenständige App und kann zugleich vom Host als Remote geladen werden — fürs Federation-Loading reichen aber die gebauten JavaScript-Chunks plus remoteEntry.json als statische Assets, ein laufender Dev-Server ist nicht nötig (nur für den Standalone Test).
Konzeptionell war der wichtigste Build-Mode-Entscheid: @frankenstein/shared bleibt devDependency, damit shareAll das Package übergeht und der globalThis-Singleton aus Abschnitt 4 intakt bleibt.
Die erste Komplikation im Whiteboard: Excalidraws onChange feuert auch ohne echte Änderungen. Beim Resizen des Fensters bekam ich ~8 drawing:changed-Events in 11 Sekunden, ohne dass jemand etwas gezeichnet hatte. Den 500-ms-Debounce am Sender hatte ich schon eingebaut — der fasst die Events aber nur zusammen, er erkennt nicht, ob sich überhaupt etwas geändert hat. Der eigentliche Fix war ein Fingerprint über ${element.id}:${element.version}: Excalidraw bumpt version nur bei echten Änderungen, nicht bei kosmetischen Reflows. Damit fallen die leeren Events vor dem Debounce raus, und der kümmert sich danach nur noch um die Frequenz echter Edits.
const fp = elements.map(e => `${e.id}:${e.version ?? 0}`).join('|');
if (fp === prevFingerprintRef.current) return; // skip cosmetic re-renders
prevFingerprintRef.current = fp;
// → 500ms debounce → emit('drawing:changed', …)
Dazu noch ein Firefox-Detail: Firefox kappt <canvas> bei ~11180 px Kantenlänge. Da Excalidraws Stylesheet erst nach dem React-Mount geladen wurde, war der Container kurz groß; Excalidraw rechnete eine Canvas-Größe jenseits des Limits aus, und der erste setTransform-Call warf eine Exception mit Stacktrace in der Konsole — Excalidraw bootete gar nicht erst. Chromium hat das Limit nicht: klassischer „funktioniert bei mir"-Bug. Gelöst, indem das Stylesheet schon beim Modul-Init in den Head injiziert wird und der erste Render auf dessen load-Event wartet.
Die Federation-Konfiguration ist bei beiden Remotes gleich aufgebaut: eine federation.config.mjs, in der spezifiziert ist, was das Remote exportiert und welche Bibliotheken geteilt werden. Geteilte Bibliotheken werden in der Regel als Singleton geladen — genau eine Instanz für Shell und alle Remotes, auch wenn jedes Projekt sie selbst als Dependency mitbringt. Versionskonflikte werden zur Build-Zeit aufgelöst, transitive Sub-Dependencies explizit geteilt oder ausgeschlossen. Damit bestimmt die Federation-Konfiguration am Ende auch, wie viele JavaScript-Chunks pro Anwendung entstehen.
Im Whiteboard musste ich einmal nachhelfen: React liefert einige Module noch als CommonJS, die Native Federation beim Build in ESM überführt. Bei einem bestimmten Muster — react/jsx-runtime — klappt diese Übersetzung nicht sauber: die jsx-Funktion war zur Laufzeit undefined und Excalidraw flog beim ersten Render. Behebbar per Pfad-Mapping in der Federation-Konfiguration, das den problematischen Zwischenschritt überspringt und direkt auf Reacts fertig kompilierte CJS-Datei zeigt.
Im Mermaid-Remote war es umgekehrt: Svelte ließ sich gar nicht sauber teilen. Sein interner Code referenziert sich über relative Pfade, an die der Federation-Mechanismus nicht herankommt — am Ende landeten zwei Svelte-Runtimes parallel im Tab, und Mermaid warf zur Laufzeit effect_orphan. Pragmatisch gelöst: Svelte aus der Share-Map raus und direkt ins Mermaid-Bundle gebündelt. Bei nur einer Svelte-App ist das größenmäßig egal — die ~160 kB landen so oder so beim Client, ob als geteilter Chunk neben dem Bundle oder als Bestandteil davon. Erst ab zwei Svelte-Remotes käme die Duplikation zum Tragen.
8. Inseln statt Komponenten
In einer homogenen Landschaft — also wenn alle Apps dasselbe UI-Framework nutzen — würde man Komponenten direkt exportieren und in der Shell verwenden. In einer heterogenen Landschaft funktioniert das nicht: würde ich aus dem React-Projekt nur die React-Komponente exportieren, könnte die Angular-Shell sie nicht laden, weil die React-Runtime im Tab schlicht fehlt. Eine reine Komponente hat keine Plattform unter sich.
Die Lösung sind Inseln: jedes Remote exportiert nicht nur die Komponente, sondern die komplette App inklusive ihres Frameworks. Im Tab läuft dann pro Insel ein eigenes UI-Framework, vollständig gekapselt. Technisch passiert das über native Custom Elements: das Remote definiert ein <whiteboard-remote> (bzw. <mermaid-remote>), die Shell rendert das Tag wie jedes andere DOM-Element, und im connectedCallback bootet das eingebettete Framework. Genau so sind Whiteboard und Mermaid in Meilenstein 3 und 4 umgesetzt.
class WhiteboardRemote extends HTMLElement {
connectedCallback() {
this.root = createRoot(this);
this.unsubs.push(on('event:selected', ({ initialData }) => {
this.render(initialData);
}));
emit('context:request', {});
}
disconnectedCallback() {
this.unsubs.forEach(u => u());
this.root?.unmount();
}
}
customElements.define('whiteboard-remote', WhiteboardRemote);
React DevTools sehen den vollständigen Komponentenbaum (links). Wappalyzer findet kein React (rechts). Die Insel funktioniert.
9. Bus-Zugriff und Events
Shell und Remotes importieren den Bus jeweils direkt aus dem Shared-Package (siehe Abschnitt 4). Konkret läuft das so: nach seiner Initialisierung schickt jedes Remote ein context:request über den Bus und bekommt vom Host ein event:selected mit dem aktuellen Meeting zurück. Erst damit weiß das Remote, welche Whiteboard- oder Mermaid-Daten es laden soll.
Es gibt vier Events:
-
context:request— Remote → Host: „bitte aktuelles Meeting schicken" -
event:selected— Host → Remotes: aktuelles Meeting mit Whiteboard- und Mermaid-Daten -
drawing:changed— React-Remote → Host: das Whiteboard wurde geändert -
diagram:changed— Svelte-Remote → Host: das Mermaid-Diagramm wurde geändert
Der Event-Bus-Log live: context:request beim Mount eines Remotes, event:selected als Host-Antwort, dann drawing:changed für eine echte Whiteboard-Änderung.
Bewusst nicht im Setup: das Anlegen oder Editieren von Meetings über den Bus. Das hätte CRUD-Wiring durch alle drei Frameworks gezogen, ohne am Integrationspattern selbst etwas hinzuzufügen. Was mit Native Federation möglich ist, lässt sich auch mit diesem minimalen Event-Set übersichtlich zeigen.
10. M5 & M6 — Polish und Deployment
In den letzten beiden Meilensteinen ging es um Polish und Deployment. M5 war Feinschliff: das CSS verbessert, Cleanups gemacht, einen repräsentativen Seed angelegt. Der Seed hat eine kleine Pointe: statt Lorem-Ipsum-Meetings ist das Demo-Meeting ein „Architecture Review" der Demo selbst. Das Whiteboard zeichnet drei Boxen plus Bus, Mermaid zeigt das Sequenzdiagramm des Bus-Flows. Das Hero-Bild des Posts ergibt sich daraus von allein.
M6 war das Deployment auf https://lutzleonhardt.de/frankenstein-meeting-room/ — alles statisch unter einem Subpath, kein Backend, plus die beiden Remotes separat unter /whiteboard/ und /mermaid/ als eigenständige Apps. Während der Entwicklung lief jedes Remote über seinen eigenen Standalone-Dev-Server, das Federation-Manifest verwies entsprechend auf localhost-URLs. Nach dem Build wandern die Remotes als statische Assets in die Unterpfade des Hosts, und das Manifest wird passend dazu angepasst.
11. Take-away
Das ganze Setup stand in 10 bis 12 Stunden Nettoaufwand, mit Hilfe des agentischen Prozesses und konsequenter Kuratierung. Erweitern lässt es sich leicht: weitere React-Anwendungen lassen sich genauso einbinden wie ein zusätzliches Framework, zum Beispiel Vue. Damit eignet sich Native Federation gut, um eine gewachsene heterogene Legacy-Landschaft auf einer Plattform zusammenzuführen, ohne eine große Migration durchziehen zu müssen.
Besondere Fallstricke gibt es vor allem dann, wenn mehrere Versionen desselben Frameworks parallel laufen — etwa Angular 15 und Angular 17 in derselben Plattform. Dann muss man genau auf geteilte Dependencies, Sub-Dependencies und transitive Dependencies achten. Das Laden der Abhängigkeiten läuft aber zentral über die native Import-Map, was es überschaubar hält. Im Gegensatz zu Webpack Module Federation, wo die Remote-Map tief im Code vergraben war.
Fazit: die Reibung zwischen drei Frameworks in einem Tab befindet sich nicht da, wo man sie erwarten würde. Bus und Loading liefen problemlos. Komplikationen gab es beim Firefox-CSS, dem React-CJS-Wrapper und einem Svelte-Compiler, der zweimal in derselben Seite lief. Der Code liegt unter github.com/lutzleonhardt/FrankensteinMeetingRoom.
Lutz Leonhardt ist Mitglied im Native Federation Advisory Board. Mehr unter lutzleonhardt.de.




Top comments (0)