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:
- Discovered — The service directory was found and the manifest was parsed.
- Loading — The output directory is verified and JavaScript modules are
imported. The
initialize()hook is called. - Running — Handlers are registered, HTTP routes are mounted, and ACL entries are set. The service processes requests.
- Draining — A code change or disablement was detected. New requests are queued while ongoing requests complete (with a configurable timeout).
- 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— TheWinccoaManagerinstance for datapoint operations.manifest— The parsed service manifest.serviceDir— Absolute path to the service's root directory.acl— TheWsjAccessControlListfor 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— Iftrue, 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:
- The event is debounced (default: 1000 ms) to batch rapid changes.
- The service enters the Draining state — new requests are queued while ongoing requests complete.
- After all pending requests complete (or after a timeout), the old service is
unloaded (
dispose()is called, handlers and routes are removed). - The new module is loaded,
initialize()is called, and handlers and routes are re-registered. - 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.
"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:
- Declare them in the service's package.json
dependenciesfield. - Run
npm installin 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)
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:
- Create a service directory with a package.json containing
the
"webserverjs-service"key. - Move the handler source file to the service's src/ directory.
- Remove the handler registration from
registerStandardHandlers(). - Build the service. The handler is now hot-reloadable.
