webserver.js Dynamic Services

Dynamic services extend webserver.js with self-contained, hot-reloadable modules that provide request handlers, HTTP endpoints, and access control without modifying the server core or restarting the manager.

Overview

A dynamic service is a self-contained module that webserver.js discovers, loads, and manages at runtime. Each service can provide:

  • Request handlers — WebSocket message handlers registered in the request handler registry.
  • HTTP endpoints — Express-style routes mounted on the web server.
  • Access control entries — Declarative ACL rules for the service's endpoints.

Services are loaded from a well-known directory, watched for changes, and reloaded automatically when their compiled output changes. This enables a development workflow where code changes take effect without restarting the webserver.js manager.

Dynamic services coexist with the static extension pattern described in webserver.js Customer Extensions. Static extensions (server subclassing) are suited for core customizations that must always be present. Dynamic services are suited for optional, independently deployable features that benefit from hot-reload.

Service Directory

Services are located in the services/ subdirectory of javascript/webserver-js/ at each level of the WinCC OA project hierarchy (installation, sub-projects, project). The service manager scans all hierarchy levels and merges the results.

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

Each subdirectory that contains a package.json with a "webserverjs-service" key is treated as a service.

Service Manifest

The service manifest is defined inside the service's package.json under the "webserverjs-service" key. It declares what the service provides:

{
  "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 fields:

Field Default Description
version 1 Manifest schema version (must be 1).
outDir "dist" Directory containing the compiled JavaScript output, relative to the service root. Source paths in requestHandlers and httpEndpoints are resolved relative to this directory.
requestHandlers [] Array of WebSocket request handler definitions. Each entry specifies a source file and className.
httpEndpoints [] Array of HTTP endpoint definitions. Each entry specifies a mountPath, source file, and optionally a routesFunction name (defaults to "routes").
acl [] Array of access control entries for the service's HTTP endpoints.

Service Enablement

A services.json file in the services/ directory controls which services are enabled or disabled:

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

Enablement is evaluated per hierarchy level. A services.json at a higher-priority level (project) overrides settings from lower-priority levels (installation). Services not listed in any services.json are enabled by default.

Changes to services.json are detected by the file watcher and take effect immediately — newly enabled services are loaded, and newly disabled services are unloaded.

Service Lifecycle

Each service transitions through the following states:

  1. Discovered — The service directory was found and the manifest was parsed.
  2. Loading — The output directory is verified and JavaScript modules are imported. The initialize() hook is called.
  3. Running — Handlers are registered, HTTP routes are mounted, and ACL entries are set. The service processes requests.
  4. Draining — A code change or disablement was detected. New requests are queued while ongoing requests complete (with a configurable timeout).
  5. Unloading — The dispose() hook is called. Handlers are unregistered, routes are unmounted, and ACL entries are removed.

After unloading, the service returns to the Discovered state if it is being reloaded, or is removed entirely if its directory was deleted.

If a service fails to load (invalid manifest, missing output directory, or initialize() failure), it enters an Error state. The service manager logs the error and continues loading other services. The failed service is retried automatically when its source files change.

The IWsjService Interface

A service module can optionally export a class or object implementing the IWsjService interface for lifecycle management:

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>;
}

Both hooks are optional. If a service only provides declarative handlers and endpoints via the manifest, no code-level lifecycle management is needed.

The WsjServiceContext passed to initialize() provides access to:

  • winccoa — The WinccoaManager instance for datapoint operations.
  • manifest — The parsed service manifest.
  • serviceDir — Absolute path to the service's root directory.
  • acl — The WsjAccessControlList for programmatic ACL management.
  • app — The Express app for advanced routing needs.
  • log — A logger scoped to the service name.

Request Handlers

Dynamic services register WebSocket request handlers through the existing WsjRequestHandlerRegistry. Each handler must extend WsjRequestHandlerBase and define a unique prefix:

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;
  }
}

The handler prefix must be unique across all services. If two services attempt to register the same prefix, the second registration fails and the conflict is logged.

HTTP Endpoints

HTTP endpoints are Express-style routes defined in a routes function. The service loader injects the Router factory as the first argument, because the HTTP framework used internally is bundled and cannot be imported directly:

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;
}

The function name must match the routesFunction value in the manifest (defaults to "routes"). The routes are mounted at the mountPath specified in the manifest.

Service routes are mounted after all static routes, so static routes always take priority.

Access Control

Each service can declare ACL rules for its HTTP endpoints in the manifest. These rules are registered when the service loads and removed when it unloads:

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

Available ACL fields:

  • path — The URL path pattern to protect (supports wildcards).
  • allowUsers — Users allowed access. Use "*" to require authentication without restricting to specific users.
  • denyUsers — Users explicitly denied access.
  • allowDefault — If true, allows access without authentication (open access).
  • allowUnknown — Allow access for unknown/anonymous users.
  • allowDisabled — Allow access for disabled user accounts.
  • oidcAuthCodeFlow — Enable OIDC authorization code flow for this path.

Hot-Reload

The service manager watches all service output directories for JavaScript file changes (.js, .mjs, .cjs). When a change is detected:

  1. The event is debounced (default: 1000 ms) to batch rapid changes.
  2. The service enters the Draining state — new requests are queued while ongoing requests complete.
  3. After all pending requests complete (or after a timeout), the old service is unloaded (dispose() is called, handlers and routes are removed).
  4. The new module is loaded, initialize() is called, and handlers and routes are re-registered.
  5. Queued requests are processed by the new handlers.

This enables a seamless development workflow: run a TypeScript build tool in watch mode, and changes take effect automatically without restarting the server.

Note:
Changes to package.json are also monitored. If the "webserverjs-service" key changes, the service is reloaded. If package.json is deleted, the service is unloaded.

Building Services

The service manager loads pre-built JavaScript from the output directory. Building (TypeScript compilation, bundling, dependency installation) is the developer's responsibility. Any build tool can be used (tsc, esbuild, rollup, etc.).

If a service requires npm packages:

  1. Declare them in the service's package.json dependencies field.
  2. Run npm install in the service directory before starting the webserver.

The node_modules/ directory persists across server restarts.

Services can import from:

  • Node.js built-ins (node:fs, node:path, etc.)
  • Their own node_modules
  • The webserver.js public API via @wincc-oa/backend
  • winccoa-manager (always available in the host process)
Important:
The HTTP framework (ultimate-express) is bundled into the webserver and cannot be imported by services. Use the injected Router parameter in your routes function instead.

Scaffolding a New Service

Use the scaffolding tool to generate a new service with the correct directory structure and manifest:

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

This creates:

my-service/
  package.json       (pre-filled with "webserverjs-service" key)
  src/
    exampleHandler.ts
    exampleRoutes.ts
  dist/              (pre-compiled, ready to load)
    exampleHandler.js
    exampleRoutes.js

Place the generated directory inside javascript/webserver-js/services/ in your project. The service is discovered and loaded automatically.

Example: Weather Alerts Service

The following example demonstrates a complete dynamic service that provides real-time weather alert integration.

Directory structure:

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

Enable detailed service lifecycle logging with the WSJ_SVC debug flag:

-dbg WSJ_SVC

This produces trace messages for service discovery, loading, unloading, file watcher events, and ACL registration.

Service Status Endpoint

The built-in GET /_services endpoint returns the status of all discovered services:

{
  "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"
    }
  ]
}

This endpoint requires authentication when access control is enabled.

Configuration

Service loading behavior is configured in the [webserverjs] section of the project configuration file. See Dynamic Services Configuration for details.

Migration from Static Extensions

Existing customers using the CustomerDashboardServer subclass pattern continue to work unchanged. Dynamic services are additive and do not replace the static extension mechanism.

To gradually migrate a static handler to a dynamic service:

  1. Create a service directory with a package.json containing the "webserverjs-service" key.
  2. Move the handler source file to the service's src/ directory.
  3. Remove the handler registration from registerStandardHandlers().
  4. Build the service. The handler is now hot-reloadable.
Note:
Migration to dynamic services is not possible for handlers or endpoints that use CTRL code. CTRL-based extensions must remain in the static extension pattern.