Backend und Sidecar
Der ausführbare Teil eines Pakets läuft nie im Prozess der Anwendung wie eine Bibliothek. Er läuft in einer eigenen Laufzeit, bekommt seine Bytes vom Host gereicht und erreicht die Außenwelt nur über eine vermittelte Oberfläche.
Zwei Arten, ein Feld im Manifest:
// in-process: ein JavaScript-Modul in einer abgeschotteten Laufzeit"backend": { "entry": "backend.js" }
// Sidecar: ein eigener Prozess, eigene Sprache, eigene Bibliotheken"backend": { "entry": "runtime/main.py", "runtime": "sidecar", "requires": { "language": "python", "minVersion": "3.11" }}Nehmen Sie in-process, solange nichts dagegen spricht. Es ist kleiner, startet schneller und läuft in einer echten Abschottung.
Nehmen Sie einen Sidecar, wenn Sie eine Verbindung offen halten müssen oder wenn die Bibliothek, die Sie brauchen, in JavaScript nicht existiert. Genau das sind industrielle Konnektoren: ein Socket, der offen bleibt und meldet, wenn etwas ankommt — keine Funktion, die eine Frage beantwortet.
Der Preis eines Sidecars ist ehrlich und zweimal lesenswert: es ist ein echter Prozess auf der Maschine des Betreibers. Der Host startet ihn, beaufsichtigt ihn und beendet ihn — er schottet ihn nicht ab.
Das in-process-Backend
Abschnitt betitelt „Das in-process-Backend“Ihr Einstiegsmodul exportiert eine Funktion, die die vermittelte Host-Oberfläche
bekommt und ein Objekt mit routes und handle zurückgibt.
export default (host) => { async function query(sql, params) { const res = await host.request({ method: 'POST', path: 'data/query', body: { sql, params } }); return res && res.body && Array.isArray(res.body.rows) ? res.body.rows : []; }
return { routes: [ { method: 'GET', path: '/reports' }, { method: 'POST', path: '/reports' } ],
handle: async (env) => { const segments = String(env.path || '').split('/').filter((s) => s.length > 0); const caller = env.caller;
// DAS TOR. Serverseitig, vor der Routenwahl. Ein fehlender caller heisst // "niemand hat etwas nachgewiesen" und wird abgelehnt, nie als vertrauenswuerdig // behandelt. const roles = caller && Array.isArray(caller.roles) ? caller.roles : []; if (env.method !== 'GET' && !roles.includes('tenant-admin')) { return { status: 403, body: { error: 'not_allowed_to_write' } }; }
if (segments[0] === 'reports' && env.method === 'GET') { return { status: 200, body: { rows: await query('SELECT * FROM plugin_pkg.acme_reports') } }; } return { status: 404, body: { error: 'no_such_route' } }; } };};Zwei Regeln, die daraus folgen:
routeswird beim Start gemeldet. Der Host kennt die Routentabelle, bevor die erste Anfrage kommt. Was nicht deklariert ist, wird nicht zugestellt.handlemuss immer antworten. Ein unbehandelter Wurf wird zu einem 500, ohne den Worker zu töten — aber Ihr Aufrufer wartet dann bis zur Zeitgrenze. Fangen Sie und geben Sie einen Code zurück; die Meldung bleibt intern.
Erreichbar ist Ihre Route unter /api/plugins/<pluginId>/<pfad>.
Der Umschlag
Abschnitt betitelt „Der Umschlag“Was handle bekommt, und was es zurückgeben muss:
// hinein{ method, path, headers?, body?, caller? }// heraus{ status, headers?, body? }env.caller wird vom Host aus der Sitzung gestempelt: { appUserId, roles, kind }. Ein
caller-Schlüssel in env.body ist Text des Aufrufers und bleibt dort — er kann nie an
diese Stelle geraten. Ein Backend, das auf dem Rückkanal selbst einen caller behauptet,
wird mit caller_not_settable abgewiesen.
caller ist optional: ein Hintergrundaufruf hat keinen. Lesen Sie „kein caller” als
„niemand hat etwas nachgewiesen” — fail-closed, nie als vertrauenswürdig.
Die vermittelte Host-Oberfläche
Abschnitt betitelt „Die vermittelte Host-Oberfläche“host.request({ method, path, body }) ist der einzige Weg nach draußen. Die Pfade sind eine
geschlossene Menge:
| Pfad | Wofür | Braucht |
|---|---|---|
data/query |
Eine parametrisierte Abfrage, mandantengebunden | data:read / data:write |
data/batch |
Mehrere Anweisungen atomar innerhalb eines Aufrufs | wie oben |
audit |
Eine Audit-Zeile. Der Akteur wird host-seitig gesetzt, nie von Ihnen | — |
events |
Ein Echtzeit-Ereignis über den vermittelten Bus | — |
log |
Eine Protokollzeile in das zentrale Protokoll | — |
net/request |
Eine ausgehende HTTP(S)-Anfrage, vom Host vermittelt | net:request |
capability/invoke |
Eine Fähigkeit des Hosts aufrufen, z. B. tag.write |
capability:invoke plus das Recht der Fähigkeit |
host/resolve |
Ein fachlicher Name → eine Kern-Kennung (tag, recordType, field, role) |
— |
host/describe |
Die Gegenrichtung: eine Kennung, die Ihnen schon gehört → ihr Name | — |
Für die letzten beiden gibt es Zucker, der bei einem Fehlschlag wirft, statt eine Nicht-200-Antwort zurückzugeben:
const tagId = await host.resolve('tag', 'Press1.PartCount');const tagPath = await host.describe('tag', tagId);host/describe ist kein Stöberloch: eine Kennung muss Ihnen bereits gehören — in Ihrer
eigenen Zeile stehen —, um beschrieben zu werden. Beschreiben ist nie der Weg, an eine
Kennung zu kommen, die Sie nicht schon hatten.
Was data/query erreichen darf, entscheidet nicht ein Textfilter über Ihr SQL, sondern die
Rolle, unter der es läuft: sie kann den mandantengebundenen Bereich nicht verlassen. Es gibt
deshalb ausdrücklich keine Verbotsliste für Anweisungen.
Grenzen, die der Host setzt
Abschnitt betitelt „Grenzen, die der Host setzt“| Grenze | Wert |
|---|---|
| Start des Backends | 10 s |
| Ein Aufruf | 30 s |
| Auslaufen beim Wechsel | 30 s |
| Neustartversuche nach Absturz | 3, mit 1 s / 5 s / 30 s Wartezeit |
Der Sidecar
Abschnitt betitelt „Der Sidecar“Ein Sidecar ist ein eigener Prozess. Der Host startet ihn, prüft vorher den Interpreter und redet über drei Kanäle mit ihm:
| fd | Richtung | Inhalt |
|---|---|---|
| 0 (stdin) | Host → Sidecar | gerahmte Frames |
| 1 (stdout) | Sidecar → Host | gerahmte Frames |
| 2 (stderr) | Sidecar → Host | freier Text, zeilenweise, nie gerahmt |
stderr bleibt ungerahmt, weil ein Traceback beim Start entsteht, bevor die Rahmung
steht. Der Host protokolliert jede Zeile als Log Ihres Plugins auf Stufe warn.
Ihr Sidecar darf
stdoutfür nichts anderes benutzen. Einprint()zerstört den Strom. Leiten Siesys.stdoutbeim Start aufsys.stderrum und behalten Sie den echten Deskriptor für sich.
Die Rahmung
Abschnitt betitelt „Die Rahmung“+--------+------------------------+| 4 Byte | N Byte || Länge | UTF-8-JSON |+--------+------------------------+Länge ist big endian, vorzeichenlos, und zählt sich selbst nicht mit. Obergrenze 8 MiB je Frame. UTF-8 wird streng dekodiert — ein verstümmeltes Byte darf nicht still zu U+FFFD werden, sonst landet es als heiler Wert in einem Tag.
Fehlernamen, damit beide Seiten dasselbe protokollieren: frame_too_large,
invalid_frame_length (Länge 0), incomplete_frame, invalid_frame_json, unknown_frame.
Nach dem ersten Rahmungsfehler ist der Leser vergiftet: er richtet sich nie neu aus, sondern wirft bei jedem weiteren Byte denselben Fehler. Ein verrutschter Strom lässt sich nicht wieder einfangen; jeder Versuch wäre Raten.
Der Ablauf
Abschnitt betitelt „Der Ablauf“Host Sidecar |-- spawn(python, entry) ---------->| |-- load -------------------------> | | | Digest über den Baum | <------------------------ ready -| |-- request (POST /connections) --> | | <--------------------- response -| Socket geht auf | <------------------------ event -| Telegramm empfangen | <------------------------- host -| capability/invoke tag.write |-- host-response ----------------> | |-- shutdown ---------------------> |Der erste Frame ist load und nennt rootPath, entry, archiveDigest und die Grenzen.
rootPath zeigt auf das bereits entpackte und geprüfte Paket — Ihr Sidecar entpackt
nichts und prüft keine Signatur.
Ihre Antwort ist ready mit routes, archiveDigest und runtimeVersion. Den Digest
rechnen Sie selbst über den Baum, und der Host vergleicht ihn mit dem aus load. Ein
Unterschied ist entry_digest_mismatch, und der Prozess wird beendet.
So wird der Baum-Digest gebildet, damit beide Seiten dasselbe rechnen: alle Dateien relativ
zu rootPath, Pfade mit /, sortiert nach ihren UTF-8-Bytes; je Datei
sha256(pfad-als-utf8) || sha256(inhalt) als rohe 32 Byte aneinandergehängt; über das Ganze
nochmals sha256. Verzeichnisse zählen nicht. Das Ergebnis trägt das Präfix sha256:.
Frames im Betrieb
Abschnitt betitelt „Frames im Betrieb“request/response— übercorrelationIdgepaart,envelopeist derselbe Umschlag wie oben.host/host-response— Ihr Rückkanal. Dieselben Operationen wie beim in-process-Backend, pluscapability/invoke.event— Sidecar → Host, unaufgefordert, ohnecorrelationId. Das ist der Frame, wegen dem es diese Bauart gibt: ein gehaltener Socket meldet, wenn etwas ankommt, nicht wenn jemand fragt.shutdown— die höfliche Bitte. Durchgesetzt wird sie durch Schließen von stdin, danach Gnadenfrist, danach Töten.
Ihr Sidecar braucht zwei Fäden
Abschnitt betitelt „Ihr Sidecar braucht zwei Fäden“Kein Entwurfsdetail, sondern eine Zwangsläufigkeit: ein host-Aufruf wartet auf einen
Frame, den nur die Leseschleife lesen kann. Wer aus der Leseschleife heraus den Host ruft,
verklemmt sich selbst.
Also: die Leseschleife liest nur. Eingehende request-Frames gehen an einen
Arbeiter-Faden, host-response wird in der Leseschleife aufgelöst. Ein host-Aufruf aus
dem Lese-Faden heraus wird verweigert — mit einem Fehler, nicht mit einer Verklemmung.
Der Socket gehört Ihnen
Abschnitt betitelt „Der Socket gehört Ihnen“Es gibt kein net/listen, net/connect oder net/send. Ein host-vermittelter Socket
sähe sicherer aus, wäre es aber nicht: ein Python-Prozess hat ambiente Netzrechte und könnte
die Vermittlung ohnehin umgehen. Wir bauten eine Durchsetzung, die nichts durchsetzt, und
zahlten jedes empfangene Byte zweimal über die Rahmung.
Also die Trennung: Daten (der Socket) hält der Sidecar selbst; Steuerung (Tags, Logs, Audit, Datenbank) ist host-vermittelt, denn dort ist die Durchsetzung echt — der Sidecar hat keinen anderen Weg dorthin.
net:socket im Manifest ist damit eine Angabe, kein Käfig: sie wird geprüft,
protokolliert und auditiert, und ein Paket ohne sie wird gar nicht erst als Konnektor
gestartet. Sie hält keinen bösartigen Sidecar auf. Das kann erst eine Grenze auf
Betriebssystemebene, und die gibt es hier nicht.
Was ein Sidecar nie bekommt
Abschnitt betitelt „Was ein Sidecar nie bekommt“- Keine Datenbankverbindung und keine Zugangsdaten dorthin.
- Keine Umgebungsvariablen außer der Erlaubnisliste.
- Keine offenen Dateideskriptoren außer 0, 1, 2.
- Keinen Schreibzugriff außerhalb des eigenen Arbeitsverzeichnisses.
Ein Konnektor-Sidecar
Abschnitt betitelt „Ein Konnektor-Sidecar“Ein Konnektor-Plugin ist ein Paket mit einer connector-Contribution und einem
Sidecar-Backend. Nur der Typ ist ein Formular ohne Wirkung; nur der Sidecar ist ein Prozess
ohne Konfiguration.
Es bedient fünf Routen, mehr nicht:
| Route | Was sie tut |
|---|---|
POST /connections |
Verbindung anlegen oder ändern. { id, settings } |
GET /connections |
Was der Sidecar gerade wirklich hält |
GET /connections/:id |
Zustand einer Verbindung; unbekannt → 404 |
DELETE /connections/:id |
Schließen und vergessen. Zweimal ist kein Fehler |
POST /connections/:id/send |
Hinausschicken. { payload } → { sent: n } |
Erneut mit derselben
idaufgerufen heißt ÄNDERN, nicht ein zweites Mal öffnen. Der Host ruft das nach jeder Änderung und nach jedem Sidecar-Neustart. Ein Sidecar, das dabei einen zweiten Socket öffnet, hält am Ende so viele offen, wie der Betreiber gespeichert hat.
sent: 0ist ein FEHLER, kein Erfolg. Wer nach außen schreibt, bekommt eine Quittung. Ein Server ohne verbundene Gegenstelle hat nichts gesendet, und ein Tag, das daraufhin „geschrieben” anzeigt, lügt den Betreiber an.
Und es sendet zwei Ereignisse:
{ "kind": "event", "type": "connector/received", "payload": { "connectionId": "c1", "payload": "OK", "bytes": 2, "terminator": "delimiter", "peer": "10.0.0.5:51234" } }terminator ist delimiter | silence | eof. Bei silence wurde das Telegramm nicht durch
ein Steuerzeichen beendet, sondern weil es still wurde — ein Schnitt an möglicherweise
falscher Stelle, sichtbar nur, wenn er mitgeteilt wird.
{ "kind": "event", "type": "connector/state", "payload": { "connectionId": "c1", "connected": true, "lastError": "", "rxCount": 17, "txCount": 3 } }Die Schlüssel dieses Payloads sind die Kanalnamen. Der Host schreibt jeden in das Tag,
das diesen channel gewählt hat, und kennt die Namen nicht selbst. Ein Konnektor mit einem
fünften Zustandswert deklariert ihn in seinem channel-Feld, sendet ihn hier, und der Host
trägt ihn ein — ohne eine Zeile Kerncode.
Rechte für einen Konnektor
Abschnitt betitelt „Rechte für einen Konnektor“| Recht | Wofür |
|---|---|
net:socket |
Der gehaltene Socket. Ohne das ist es kein Konnektor. |
capability:invoke |
Öffnet den Rückkanal. |
data:write |
Liegt dahinter und wird leicht vergessen: tag.write hängt unter diesem Recht. Fehlt es, endet jedes empfangene Telegramm als permission_denied statt als Tag-Wert. |
data:read nur, wenn Ihr Konnektor wirklich Tags liest.
Beim Schreiben in ein Tag setzen Sie inbound: true, wenn der Wert von der Gegenstelle
kam:
{ "op": "capability/invoke", "payload": { "name": "tag.write", "input": { "path": "Line1.Rx", "value": "OK", "inbound": true } } }Ohne dieses Feld schickt ein zweiseitiges Tag das gerade empfangene Telegramm postwendend wieder hinaus. Es ist Herkunft, keine Befugnis: die einzige Wirkung ist, den ausgehenden Versand zu überspringen. Alle Tore bleiben, wo sie waren. Den Mandanten setzt der Host aus dem Aufrufkontext, nie aus Ihrer Nutzlast.
Was der Host tut, damit Sie es nicht nachbauen
Abschnitt betitelt „Was der Host tut, damit Sie es nicht nachbauen“- Er hält den Sidecar am Leben und startet ihn nach einem Absturz neu.
- Er ruft nach jedem Start
POST /connectionsfür jede gespeicherte Verbindung. - Er macht aus
connector/receivedundconnector/stateTag-Schreibvorgänge — unter dem Akteurmachine, mit Audit-Eintrag. - Er macht aus einem Schreibvorgang auf ein Ausgangs-Tag ein
POST /connections/:id/send. - Er lehnt einen Schreibvorgang auf ein Zustands-Tag ab.
connectedist ein Messwert, kein Schalter.
Was heute offen ist
Abschnitt betitelt „Was heute offen ist“- Ein Sidecar je Paket. Mehrere Prozesse aus einem Paket sind nicht verboten, nur nicht bedacht.
- Ein Konnektor-Typ je Paket. Ebenso.
- Kein Anfrage/Antwort-Paar auf dem Socket. Senden und auf die Antwort warten gibt es als Route nicht; mit zwei Tags und einem Skript-Auslöser ist es nachbaubar.
- Keine mitgebrachten Abhängigkeiten. Der Interpreter ist eine Voraussetzung der Installation, kein Paketinhalt.
- Kein Durchsatzversprechen. Bei hoher Telegrammrate ist die Rahmung des Kanals die erste Stelle, die weh tut: jedes Telegramm ist ein Frame.