Zum Inhalt springen

Die Oberfläche eines Plugins

Die Schale lädt Plugin-Oberflächen zur Laufzeit über Module Federation. Kein Neuausrollen, keine neue Programmversion. Ihr Remote wird geladen, wenn jemand Ihre Route oder Ihre Seite öffnet, und beim Abmelden wieder abgehängt.

Wenn Ihr Plugin keine eigenen Seiten hat, lassen Sie frontend im Manifest weg. Ein Konnektor braucht keine Oberfläche: sein Einstellungsformular entsteht aus der Deklaration.

Jede Plugin-Oberfläche stellt genau zwei Module bereit:

vite.config.ts
federation({
name: 'acme_report',
filename: 'remoteEntry.js',
exposes: {
'./register': './frontend/register.ts',
'./View': './frontend/View.tsx'
},
shared: {
react: { singleton: true, requiredVersion: false },
'react-dom': { singleton: true, requiredVersion: false },
'@prodlog/ui': { singleton: true, requiredVersion: false },
'@prodlog/sdk': { singleton: true, requiredVersion: false }
}
})

react, react-dom, @prodlog/ui und @prodlog/sdk sind Singletons, vom Host vorgegeben. Eine zweite Kopie von React wirft im Inneren der Schale einen invalid-hook-call — das ist die häufigste Ursache für „mein Plugin lädt, zeigt aber nichts”.

export const contribution = {
id: 'acme.report',
path: '/acme.report',
view: 'acme.report/View'
};
export default contribution;

Diese Datei wird gelesen, nachdem das Remote geladen ist. Das Manifest ist das, was der Host beim Installieren prüft; halten Sie beide im Gleichschritt.

import { useState, type ReactNode } from 'react';
import type { ShellContext } from '@prodlog/sdk';
import '@prodlog/ui/plugin.css';
export default function View({ ctx }: { ctx: ShellContext }): ReactNode {
return <p>{ctx.locale}</p>;
}

Alles, was pro Einhängung entstehen muss — ein Query-Client, ein Kontext-Provider —, gehört in diese Komponente und nicht auf Modulebene. Module Federation hält ein Remote für die Lebensdauer der Seite vor; ein modulglobaler Zustand überlebt einen Mandantenwechsel im selben Browserfenster.

Das einzige, was die Schale Ihnen reicht:

interface ShellContext {
readonly locale: string; // BCP-47, z. B. 'de' oder 'en'
notify(input: NotifyInput): void; // level: 'info' | 'success' | 'warn' | 'error'
readonly store: ScopedStore;
readonly surface: Surface; // 'main' | 'kiosk' | 'capture'
readonly log: Logger;
readonly correlationId: string;
readonly page?: PageInstance; // nur auf der Seitentyp-Route
readonly theme?: ThemeControl; // die Farben des Mandanten, für einen Theme-Editor
readonly language?: LanguageControl; // Sprache wechseln, wo die Schale keinen Umschalter hat
readonly help?: HelpControl; // die Hilfe: Artikel verlinken, Beschriftung erklären
readonly configurable?: boolean; // false: Editoren ausblenden (die native App)
}

Die Felder mit ? sind optional: Eine Schale, die eines nicht anbietet, lässt es weg, und Ihr Plugin muss ohne es funktionieren.

notify ist die vermittelte Senke für Toasts und ARIA-Live-Meldungen. Bauen Sie keine eigenen Hinweisbanner — die Schale zeichnet für alle Plugins denselben.

store ist ein einfacher Schlüssel-Wert-Speicher, an Ihren Plugin-Namensraum gebunden. Werte sind für die Schale undurchsichtig; sie liest sie nie.

await ctx.store.set('user', 'acme.report', 'lastFilter', { line: 'A' });
const value = await ctx.store.get('user', 'acme.report', 'lastFilter');

Reichweiten: tenant (der ganze Mandant), user (eine Person über ihre Sitzungen hinweg). client — ein Browsertab — ist flüchtig und wird vom persistenten Speicher abgelehnt; solcher Zustand gehört in den Tab, nicht in die Datenbank.

surface sagt, wo Sie gezeichnet werden: main ist die normale Schale, kiosk der rahmenlose Präsentationsmodus, capture eine berührungsorientierte Erfassungsfläche. Zeichnen Sie im Kiosk keine Bedienleiste.

correlationId wird von der Schale einmal je Einhängung vergeben. Geben Sie sie bei eigenen Backend-Aufrufen im Kopf x-correlation-id weiter — dann hängt die Zeile, die Ihre Backend-Route schreibt, an derselben Kennung, und eine Plugin-Sitzung liest sich als eine Geschichte. Die Körnung ist die Einhängung, nicht der Klick: sie überlebt einen Sprach- oder Flächenwechsel.

help verbindet Ihre Oberflächen mit dem Hilfe-Panel. Die Hilfe-Bausteine der Schale können Sie nicht importieren — Ihr Frontend ist ein eigenes Bündel —, deshalb reicht die Schale Ihnen das hier:

const article = ctx.help?.topic('boards'); // null: kein solcher Artikel
const forLabel = ctx.help?.forKey('widget.none'); // der Artikel zu Ihrer Beschriftung
if (article) ctx.help?.open(article.id); // zeigt ihn im Hilfe-Panel

Jede Antwort trägt id, title, lead (der erste Absatz, als reiner Text) und href (der Artikel im Help Center). @prodlog/ui zeichnet sie so wie die Schale: HelpTopicLink für eine Zeile unter einem Fehler oder einem leeren Zustand, HelpTopicLabel für eine Beschriftung, die beim Zeigen ein Kästchen öffnet.

forKey sucht den Schlüssel in Ihrem Namensraum: Die Schale setzt Ihre Plugin-Kennung davor, board mit widget.none findet also den Artikel, dessen covers: board:widget.none nennt. Eine Beschriftung der Schale oder eines anderen Plugins können Sie nicht beanspruchen.

Jede Antwort ist null, solange die Hilfe noch lädt, in einer Installation ohne Help Center und auf Flächen ohne Hilfe-Panel. Zeichnen Sie dann nichts — nie einen Verweis, der ins Leere führt. Der Kontext wird neu gebaut, sobald die Hilfe da ist; ein Verweis erscheint dann von selbst.

Ein Plugin liefert keine globalen Stile. Importieren Sie @prodlog/ui/plugin.css, nicht @prodlog/ui/styles.css.

import '@prodlog/ui/plugin.css';

Nicht enthalten und ausdrücklich der Schale vorbehalten:

  • Preflight — ein globaler Reset gilt dem ganzen Dokument. Ein Remote, das ihn mitbringt, gestaltet Elemente um, die ihm nicht gehören.
  • Die Schriften — sonst lädt jedes Remote dieselben Schnitte erneut.
  • Der Tokenblock — die Tokens stehen auf :root im Dokument der Schale; ein hsl(var(--brand)) in Ihrem Plugin löst sich durch Vererbung dagegen auf.

Das fertige Stylesheet eines Remotes wird beim Bauen in eine schwächere Kaskadenschicht eingepackt. Ungeschichtete Regeln gewinnen in CSS immer gegen geschichtete, unabhängig von der Reihenfolge — die Schale bleibt ungeschichtet. Damit kann kein Remote die Schale überschreiben, egal wie viele laden und in welcher Folge. Ihre eigenen Elemente gestalten Sie unverändert.

Das ist keine Bitte, sondern von der Schale durchgesetzt. Der Anlass war messbar: eine unbedingte .hidden{display:none} aus einem Plugin schlug die Regel der Schale, und die Wortmarke verschwand, sobald einmal eine Plugin-Seite offen war.

Früher trug ein Plugin einen Menüeintrag bei, für jeden Mandanten gleich. Ein Seitentyp dreht das um: Ihr Plugin deklariert eine Art von Seite, und der Mandant legt davon so viele benannte Ausprägungen an, wie er will, und hängt sie in seinen eigenen Baum.

{
"category": "pageType",
"id": "acme.report",
"label": "Report",
"meta": {
"label": "Report",
"defaultIcon": "clipboard-list",
"create": { "path": "/reports", "nameField": "name", "refField": "id" }
}
}

Beim Anlegen ruft die Schale POST /api/plugins/<id><create.path> auf, schickt den vom Mandanten getippten Namen unter create.nameField und liest die Kennung der neuen Ausprägung aus dem obersten Feld create.refField der Antwort. Nur POST, kein method- Schalter: etwas anzulegen ist ein POST.

Beim Anzeigen bekommen Sie die Ausprägung im Kontext:

export default function View({ ctx }: { ctx: ShellContext }): ReactNode {
if (!ctx.page) {
// über die eigene `route`-Contribution erreicht - es gibt hier keine Ausprägung
return <Overview />;
}
return <Report type={ctx.page.type} reference={ctx.page.ref} />;
}

ctx.page.ref ist genau das, was Ihr eigener Anlegeaufruf zurückgegeben hat. Die Schale deutet es nie — es ist Ihre Kennung für Ihr eigenes Ding.

Die Richtung ist immer Navigation → Seitentyp, nie zurück. Ihr Plugin erfährt nie, dass ein Menü existiert, bekommt keine Kennung eines Navigationsknotens und hat nichts, wohin es zurückrufen könnte. Ein Menü ist eine Rechtefläche des Mandanten.

Die Route einer Ausprägung ist /p/<pageType>/<pageRef>; die Konstante dafür heißt PAGE_ROUTE_PREFIX, und pageRoutePath(type, ref) baut sie.

  • Nichts wird geladen. Prüfen Sie, ob frontend.entry im Manifest steht und die Datei unter dist/ im Archiv liegt. Fehlt eines von beidem, gibt es für die Schale nichts zu laden, und sie versucht es gar nicht erst.
  • invalid-hook-call. Ein Singleton fehlt in shared, meistens react-dom.
  • Die Schale sieht nach dem Öffnen Ihrer Seite kaputt aus. Sie importieren styles.css statt plugin.css.
  • Nach einer Aktualisierung zeigt die Seite den alten Stand. Der Remote-Name wird aus Id und Version gebildet; das ist bewusst so. Prüfen Sie, dass die Version im Manifest wirklich gestiegen ist.