webserver.js Dynamische Services

Dynamische Services erweitern webserver.js mit eigenständigen, hot-reload-fähigen Modulen, die Request-Handler, HTTP-Endpunkte und Zugriffssteuerung bereitstellen, ohne den Serverkern zu verändern oder den Manager neu zu starten.

Überblick

Ein dynamischer Service ist ein eigenständiges Modul, das von webserver.js zur Laufzeit erkannt, geladen und verwaltet wird. Jeder Service kann Folgendes bereitstellen:

  • Request-Handler — WebSocket-Nachrichten-Handler, die in der Request-Handler-Registry registriert werden.
  • HTTP-Endpunkte — Express-ähnliche Routen, die auf dem Webserver eingehängt werden.
  • Zugriffssteuerungseinträge — Deklarative ACL-Regeln für die Endpunkte des Services.

Services werden aus einem definierten Verzeichnis geladen, auf Änderungen überwacht und automatisch neu geladen, wenn sich die kompilierte Ausgabe ändert. Dies ermöglicht einen Entwicklungsworkflow, bei dem Codeänderungen ohne Neustart des webserver.js Managers wirksam werden.

Dynamische Services bestehen neben dem statischen Erweiterungsmuster, das in webserver.js Kundenerweiterungen beschrieben ist. Statische Erweiterungen (Server-Subclassing) eignen sich für Kernanpassungen, die immer vorhanden sein müssen. Dynamische Services eignen sich für optionale, unabhängig bereitstellbare Features, die hot-reload-fähig sein sollen.

Service-Verzeichnis

Services befinden sich im Unterverzeichnis services/ von javascript/webserver-js/ auf jeder Ebene der WinCC OA Projekthierarchie (Installation, Unterprojekte, Projekt). Der Service-Manager durchsucht alle Hierarchieebenen und führt die Ergebnisse zusammen.

<project>/
  javascript/
    webserver-js/
      services/
        my-service-a/
          package.json
          dist/
        my-service-b/
          package.json
          dist/

Jedes Unterverzeichnis, das eine package.json mit einem "webserverjs-service"-Schlüssel enthält, wird als Service behandelt.

Service-Manifest

Das Service-Manifest wird in der package.json des Services unter dem Schlüssel "webserverjs-service" definiert. Es deklariert, was der Service bereitstellt:

{
  "name": "my-alarm-export",
  "version": "1.0.0",
  "webserverjs-service": {
    "version": 1,
    "outDir": "dist",
    "requestHandlers": [
      {
        "source": "alarmExportHandler.js",
        "className": "AlarmExportHandler"
      }
    ],
    "httpEndpoints": [
      {
        "mountPath": "/alarms/export",
        "source": "alarmRoutes.js",
        "routesFunction": "routes"
      }
    ],
    "acl": [
      {
        "path": "/alarms/export",
        "allowUsers": "*"
      }
    ]
  }
}

Manifest-Felder:

Feld Standard Beschreibung
version 1 Manifest-Schema-Version (muss 1 sein).
outDir "dist" Verzeichnis mit der kompilierten JavaScript-Ausgabe, relativ zum Service-Stammverzeichnis. Quellpfade in requestHandlers und httpEndpoints werden relativ zu diesem Verzeichnis aufgelöst.
requestHandlers [] Array von WebSocket-Request-Handler-Definitionen. Jeder Eintrag gibt eine source-Datei und einen className an.
httpEndpoints [] Array von HTTP-Endpunkt-Definitionen. Jeder Eintrag gibt einen mountPath, eine source-Datei und optional einen routesFunction-Namen an (Standard: "routes").
acl [] Array von Zugriffssteuerungseinträgen für die HTTP-Endpunkte des Services.

Service-Aktivierung

Eine services.json-Datei im Verzeichnis services/ steuert, welche Services aktiviert oder deaktiviert sind:

{
  "my-alarm-export": { "enabled": true },
  "legacy-connector": { "enabled": false }
}

Die Aktivierung wird pro Hierarchieebene ausgewertet. Eine services.json auf einer höheren Prioritätsebene (Projekt) überschreibt Einstellungen von niedrigeren Ebenen (Installation). Services, die in keiner services.json aufgeführt sind, sind standardmäßig aktiviert.

Änderungen an services.json werden vom File-Watcher erkannt und wirken sofort — neu aktivierte Services werden geladen, neu deaktivierte Services werden entladen.

Service-Lebenszyklus

Jeder Service durchläuft die folgenden Zustände:

  1. Discovered — Das Service-Verzeichnis wurde gefunden und das Manifest wurde geparst.
  2. Loading — Das Ausgabeverzeichnis wird überprüft und JavaScript-Module werden importiert. Der initialize()-Hook wird aufgerufen.
  3. Running — Handler sind registriert, HTTP-Routen eingehängt und ACL-Einträge gesetzt. Der Service verarbeitet Anfragen.
  4. Draining — Eine Codeänderung oder Deaktivierung wurde erkannt. Neue Anfragen werden in eine Warteschlange eingereiht, während laufende Anfragen abgeschlossen werden (mit konfigurierbarem Timeout).
  5. Unloading — Der dispose()-Hook wird aufgerufen. Handler werden abgemeldet, Routen ausgehängt und ACL-Einträge entfernt.

Nach dem Entladen kehrt der Service in den Zustand Discovered zurück, wenn er neu geladen wird, oder wird vollständig entfernt, wenn sein Verzeichnis gelöscht wurde.

Wenn ein Service nicht geladen werden kann (ungültiges Manifest, fehlendes Ausgabeverzeichnis oder initialize()-Fehler), wechselt er in den Zustand Error. Der Service-Manager protokolliert den Fehler und fährt mit dem Laden anderer Services fort. Der fehlgeschlagene Service wird automatisch erneut versucht, wenn sich seine Quelldateien ändern.

Das IWsjService-Interface

Ein Service-Modul kann optional eine Klasse oder ein Objekt exportieren, das das IWsjService-Interface für die Lebenszyklusverwaltung implementiert:

export interface IWsjService {
  /**
   * Called after the module is loaded and before handlers/endpoints
   * are registered. Use for one-time setup.
   */
  initialize?(context: WsjServiceContext): Promise<void>;

  /**
   * Called before the service is unloaded. Use for cleanup:
   * close connections, cancel timers, release resources.
   */
  dispose?(): Promise<void>;
}

Beide Hooks sind optional. Wenn ein Service nur deklarative Handler und Endpunkte über das Manifest bereitstellt, ist keine Code-Level-Lebenszyklusverwaltung erforderlich.

Der WsjServiceContext, der an initialize() übergeben wird, bietet Zugriff auf:

  • winccoa — Die WinccoaManager-Instanz für Datapoint-Operationen.
  • manifest — Das geparste Service-Manifest.
  • serviceDir — Absoluter Pfad zum Stammverzeichnis des Services.
  • acl — Die WsjAccessControlList für programmatische ACL-Verwaltung.
  • app — Die Express-App für erweiterte Routing-Anforderungen.
  • log — Ein auf den Service-Namen bezogener Logger.

Request-Handler

Dynamische Services registrieren WebSocket-Request-Handler über die bestehende WsjRequestHandlerRegistry. Jeder Handler muss WsjRequestHandlerBase erweitern und ein eindeutiges prefix definieren:

import { WsjRequestHandlerBase, WsjRequestResult,
         WsjConnectionContext } from '@wincc-oa/backend';

export class AlarmExportHandler extends WsjRequestHandlerBase {
  public get prefix(): string {
    return 'alarms.export.';
  }

  public async handleRequest(
    command: string,
    params: Record<string, unknown>,
    result: WsjRequestResult,
    context: WsjConnectionContext
  ): Promise<WsjRequestResult> {
    // Handle commands like 'alarms.export.csv', 'alarms.export.pdf'
    result.setSuccess({ format: command, data: '...' });
    return result;
  }
}

Das Handler-Prefix muss über alle Services hinweg eindeutig sein. Wenn zwei Services versuchen, dasselbe Prefix zu registrieren, schlägt die zweite Registrierung fehl und der Konflikt wird protokolliert.

HTTP-Endpunkte

HTTP-Endpunkte sind Express-ähnliche Routen, die in einer Routes-Funktion definiert werden. Der Service-Loader injiziert die Router-Factory als erstes Argument, da das intern verwendete HTTP-Framework gebündelt ist und nicht direkt importiert werden kann:

export function routes(Router: any) {
  const router = Router();

  router.get('/status', (req: any, res: any) => {
    res.json({ status: 'ok' });
  });

  router.post('/export', async (req: any, res: any) => {
    // Handle export request
    res.json({ result: 'exported' });
  });

  return router;
}

Der Funktionsname muss mit dem routesFunction-Wert im Manifest übereinstimmen (Standard: "routes"). Die Routen werden unter dem im Manifest angegebenen mountPath eingehängt.

Service-Routen werden nach allen statischen Routen eingehängt, sodass statische Routen immer Vorrang haben.

Zugriffssteuerung

Jeder Service kann ACL-Regeln für seine HTTP-Endpunkte im Manifest deklarieren. Diese Regeln werden beim Laden des Services registriert und beim Entladen entfernt:

"acl": [
  {
    "path": "/alarms/export",
    "allowUsers": "*"
  },
  {
    "path": "/alarms/public/*",
    "allowDefault": true
  },
  {
    "path": "/alarms/admin/*",
    "allowUsers": ["root", "admin"],
    "denyUsers": ["guest"]
  }
]

Verfügbare ACL-Felder:

  • path — Das URL-Pfadmuster, das geschützt werden soll (unterstützt Wildcards).
  • allowUsers — Benutzer mit Zugriffsberechtigung. "*" erfordert Authentifizierung ohne Einschränkung auf bestimmte Benutzer.
  • denyUsers — Benutzer, denen der Zugriff explizit verweigert wird.
  • allowDefault — Bei true ist der Zugriff ohne Authentifizierung erlaubt (offener Zugriff).
  • allowUnknown — Zugriff für unbekannte/anonyme Benutzer erlauben.
  • allowDisabled — Zugriff für deaktivierte Benutzerkonten erlauben.
  • oidcAuthCodeFlow — OIDC-Authorization-Code-Flow für diesen Pfad aktivieren.

Hot-Reload

Der Service-Manager überwacht alle Service-Ausgabeverzeichnisse auf Änderungen von JavaScript-Dateien (.js, .mjs, .cjs). Wenn eine Änderung erkannt wird:

  1. Das Ereignis wird entprellt (Standard: 1000 ms), um schnelle aufeinanderfolgende Änderungen zusammenzufassen.
  2. Der Service wechselt in den Zustand Draining — neue Anfragen werden in eine Warteschlange eingereiht, während laufende Anfragen abgeschlossen werden.
  3. Nachdem alle ausstehenden Anfragen abgeschlossen sind (oder nach einem Timeout), wird der alte Service entladen (dispose() wird aufgerufen, Handler und Routen werden entfernt).
  4. Das neue Modul wird geladen, initialize() wird aufgerufen und Handler und Routen werden neu registriert.
  5. In der Warteschlange befindliche Anfragen werden von den neuen Handlern verarbeitet.

Dies ermöglicht einen nahtlosen Entwicklungsworkflow: Ein TypeScript-Build-Tool im Watch-Modus ausführen, und Änderungen werden automatisch wirksam, ohne den Server neu zu starten.

Anmerkung:
Änderungen an package.json werden ebenfalls überwacht. Wenn sich der "webserverjs-service"-Schlüssel ändert, wird der Service neu geladen. Wenn package.json gelöscht wird, wird der Service entladen.

Erstellen von Services

Der Service-Manager lädt vorkompiliertes JavaScript aus dem Ausgabeverzeichnis. Das Erstellen (TypeScript-Kompilierung, Bundling, Abhängigkeitsinstallation) liegt in der Verantwortung des Entwicklers. Jedes Build-Tool kann verwendet werden (tsc, esbuild, rollup, etc.).

Wenn ein Service npm-Pakete benötigt:

  1. Die Pakete werden im Feld dependencies der package.json des Services deklariert.
  2. npm install wird im Service-Verzeichnis vor dem Start des Webservers ausgeführt.

Das Verzeichnis node_modules/ bleibt über Server-Neustarts hinweg bestehen.

Services können importieren von:

  • Node.js Built-ins (node:fs, node:path, etc.)
  • Eigene node_modules
  • Die öffentliche webserver.js API über @wincc-oa/backend
  • winccoa-manager (immer im Hostprozess verfügbar)
Wichtig:
Das HTTP-Framework (ultimate-express) ist in den Webserver gebündelt und kann nicht von Services importiert werden. Stattdessen ist der injizierte Router-Parameter in der Routes-Funktion zu verwenden.

Scaffolding eines neuen Services

Mit dem Scaffolding-Tool wird ein neuer Service mit der korrekten Verzeichnisstruktur und dem Manifest erzeugt:

npx @wincc-oa/create-backend-service my-service

Dadurch wird erstellt:

my-service/
  package.json       (vorbefüllt mit "webserverjs-service"-Schlüssel)
  src/
    exampleHandler.ts
    exampleRoutes.ts
  dist/              (vorkompiliert, bereit zum Laden)
    exampleHandler.js
    exampleRoutes.js

Das generierte Verzeichnis wird in javascript/webserver-js/services/ im Projekt platziert. Der Service wird automatisch erkannt und geladen.

Beispiel: Weather Alerts Service

Das folgende Beispiel zeigt einen vollständigen dynamischen Service, der eine Echtzeit-Wetterwarnung-Integration bereitstellt.

Verzeichnisstruktur:

javascript/webserver-js/services/
  weather-alerts/
    package.json
    dist/
      weatherAlertHandler.js
      weatherRoutes.js
    src/
      weatherAlertHandler.ts
      weatherRoutes.ts

package.json:

{
  "name": "weather-alerts",
  "version": "1.0.0",
  "description": "Weather alert integration for SCADA dashboards",
  "dependencies": {
    "node-fetch": "^3.0.0"
  },
  "webserverjs-service": {
    "version": 1,
    "requestHandlers": [
      {
        "source": "weatherAlertHandler.js",
        "className": "WeatherAlertHandler"
      }
    ],
    "httpEndpoints": [
      {
        "mountPath": "/weather/current",
        "source": "weatherRoutes.js",
        "routesFunction": "routes"
      }
    ],
    "acl": [
      {
        "path": "/weather/*",
        "allowUsers": "*"
      }
    ]
  }
}

src/weatherAlertHandler.ts:

import { WsjRequestHandlerBase, WsjRequestResult,
         WsjConnectionContext } from '@wincc-oa/backend';

export class WeatherAlertHandler extends WsjRequestHandlerBase {
  public get prefix(): string {
    return 'weather.alerts.';
  }

  public async handleRequest(
    command: string,
    params: Record<string, unknown>,
    result: WsjRequestResult,
    context: WsjConnectionContext
  ): Promise<WsjRequestResult> {
    switch (command) {
      case 'current':
        const fetch = (await import('node-fetch')).default;
        const response = await fetch(
          'https://api.weather.gov/alerts/active'
        );
        const data = await response.json();
        result.setSuccess(data);
        return result;
      default:
        return super.handleRequest(command, params, result, context);
    }
  }
}

src/weatherRoutes.ts:

export function routes(Router: any) {
  const router = Router();

  router.get('/', async (req: any, res: any) => {
    const fetch = (await import('node-fetch')).default;
    const response = await fetch(
      `https://api.weather.gov/points/${req.query.lat},${req.query.lon}`
    );
    const data = await response.json();
    res.json(data);
  });

  return router;
}

Debugging

Detailliertes Logging des Service-Lebenszyklus wird mit dem Debug-Flag WSJ_SVC aktiviert:

-dbg WSJ_SVC

Dadurch werden Trace-Meldungen für Service-Erkennung, Laden, Entladen, File-Watcher-Ereignisse und ACL-Registrierung ausgegeben.

Service-Status-Endpunkt

Der integrierte Endpunkt GET /_services gibt den Status aller erkannten Services zurück:

{
  "services": [
    {
      "name": "my-alarm-export",
      "state": "running",
      "version": 1,
      "handlers": ["alarms.export."],
      "endpoints": ["/alarms/export"],
      "loadedAt": "2026-03-26T10:15:30.000Z",
      "source": "/path/to/services/my-alarm-export"
    }
  ]
}

Dieser Endpunkt erfordert Authentifizierung, wenn die Zugriffssteuerung aktiviert ist.

Konfiguration

Das Verhalten des Service-Ladens wird im Abschnitt [webserverjs] der Projektkonfigurationsdatei konfiguriert. Details siehe Konfiguration dynamischer Services.

Migration von statischen Erweiterungen

Bestehende Kunden, die das CustomerDashboardServer-Subclass-Muster verwenden, werden weiterhin unverändert unterstützt. Dynamische Services sind additiv und ersetzen den statischen Erweiterungsmechanismus nicht.

Um einen statischen Handler schrittweise zu einem dynamischen Service zu migrieren:

  1. Ein Service-Verzeichnis mit einer package.json erstellen, die den "webserverjs-service"-Schlüssel enthält.
  2. Die Handler-Quelldatei in das Verzeichnis src/ des Services verschieben.
  3. Die Handler-Registrierung aus registerStandardHandlers() entfernen.
  4. Den Service erstellen. Der Handler ist jetzt hot-reload-fähig.
Anmerkung:
Die Migration zu dynamischen Services ist nicht möglich für Handler oder Endpunkte, die CTRL-Code verwenden. CTRL-basierte Erweiterungen müssen im statischen Erweiterungsmuster verbleiben.