**src/assets/config.json**:
-| Key | Value(s) | Default Value | Value type | Description |
-| -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| $schemaVersion | 1 | 1 | integer | Configuration schema version. Set to 1. Files without this field are migrated from v0; deprecated keys are remapped on every load. |
-| supervisionUrls | | [] | string \| string[] | string or strings array containing global connection URIs to OCPP-J servers |
-| supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity | string | supervision urls distribution policy to simulated charging stations |
-| log | | {<br />"enabled": true,<br />"file": "logs/combined.log",<br />"errorFile": "logs/error.log",<br />"statisticsInterval": 60,<br />"level": "info",<br />"console": false,<br />"format": "simple",<br />"rotate": true<br />} | {<br />enabled?: boolean;<br />file?: string;<br />errorFile?: string;<br />statisticsInterval?: number;<br />level?: string;<br />console?: boolean;<br />format?: string;<br />rotate?: boolean;<br />maxFiles?: string \| number;<br />maxSize?: string \| number;<br />} | Log configuration section:<br />- _enabled_: enable logging<br />- _file_: log file relative path<br />- _errorFile_: error log file relative path<br />- _statisticsInterval_: seconds between charging stations statistics output in the logs<br />- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level</br >- _console_: output logs on the console<br />- _format_: [winston](https://github.com/winstonjs/winston) log format<br />- _rotate_: enable daily log files rotation<br />- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options<br />- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options |
-| worker | | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementAddDelay": 0,<br />"elementsPerWorker": 'auto',<br />"poolMinSize": 4,<br />"poolMaxSize": 16<br />} | {<br />processType?: WorkerProcessType;<br />startDelay?: number;<br />elementAddDelay?: number;<br />elementsPerWorker?: number \| 'auto' \| 'all';<br />poolMinSize?: number;<br />poolMaxSize?: number;<br />resourceLimits?: ResourceLimits;<br />} | Worker configuration section:<br />- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)<br />- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)<br />- _elementAddDelay_: milliseconds to wait between charging station add<br />- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)<br />- _poolMinSize_: worker threads pool minimum number of threads</br >- _poolMaxSize_: worker threads pool maximum number of threads<br />- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option |
-| uiServer | | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"accessPolicy": {<br />"requireTlsForNonLoopback": true,<br />"trustedProxies": [],<br />"allowLoopbackProxy": false,<br />"allowedHosts": [],<br />"allowedOrigins": []<br />},<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />} | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />accessPolicy?: {<br />requireTlsForNonLoopback?: boolean;<br />trustedProxies?: string[];<br />allowLoopbackProxy?: boolean;<br />allowedHosts?: string[];<br />allowedOrigins?: string[];<br />};<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />};<br />metrics?: {<br />enabled?: boolean;<br />softSampleCap?: number;<br />};<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'ws', 'mcp' or 'http' (deprecated)<br />- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')<br />- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:<br /> - _requireTlsForNonLoopback_: reject non-loopback plaintext requests; the check honors `X-Forwarded-Proto` or `Forwarded: proto=` from a trusted proxy, non-loopback requests without forwarded protocol headers are denied as `tls-required`<br /> - _trustedProxies_: IPv4 or IPv6 literals of the immediate reverse proxies whose forwarded headers are honored (hostnames and CIDR ranges are not accepted; only single-hop forwarded chains are honored); a compromised entry can bypass per-client rate limiting by varying `X-Forwarded-For`<br /> - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br /> - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br /> - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section<br />- _metrics_: opt-in Prometheus `/metrics` endpoint (HTTP transport only):<br /> - _enabled_: enable the `/metrics` endpoint<br /> - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000) |
-| performanceStorage | | {<br />"enabled": true,<br />"type": "none",<br />} | {<br />enabled?: boolean;<br />type?: string;<br />uri?: string;<br />} | Performance storage configuration section:<br />- _enabled_: enable performance storage<br />- _type_: 'jsonfile', 'mongodb' or 'none'<br />- _uri_: storage URI |
-| stationTemplateUrls | | {}[] | {<br />file: string;<br />numberOfStations: number;<br />provisionedNumberOfStations?: number;<br />}[] | array of charging station templates URIs configuration section:<br />- _file_: charging station configuration template file relative path<br />- _numberOfStations_: template number of stations at startup<br />- _provisionedNumberOfStations_: template provisioned number of stations after startup |
-| persistState | true/false | true | boolean | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START=true` for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned |
+| Key | Value(s) | Default Value | Value type | Description |
+| -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| $schemaVersion | 1 | 1 | integer | Configuration schema version. Set to 1. Files without this field are migrated from v0; deprecated keys are remapped on every load. |
+| supervisionUrls | | [] | string \| string[] | string or strings array containing global connection URIs to OCPP-J servers |
+| supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity | string | supervision urls distribution policy to simulated charging stations |
+| log | | {<br />"enabled": true,<br />"file": "logs/combined.log",<br />"errorFile": "logs/error.log",<br />"statisticsInterval": 60,<br />"level": "info",<br />"console": false,<br />"format": "simple",<br />"rotate": true<br />} | {<br />enabled?: boolean;<br />file?: string;<br />errorFile?: string;<br />statisticsInterval?: number;<br />level?: string;<br />console?: boolean;<br />format?: string;<br />rotate?: boolean;<br />maxFiles?: string \| number;<br />maxSize?: string \| number;<br />} | Log configuration section:<br />- _enabled_: enable logging<br />- _file_: log file relative path<br />- _errorFile_: error log file relative path<br />- _statisticsInterval_: seconds between charging stations statistics output in the logs<br />- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level</br >- _console_: output logs on the console<br />- _format_: [winston](https://github.com/winstonjs/winston) log format<br />- _rotate_: enable daily log files rotation<br />- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options<br />- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options |
+| worker | | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementAddDelay": 0,<br />"elementsPerWorker": 'auto',<br />"poolMinSize": 4,<br />"poolMaxSize": 16<br />} | {<br />processType?: WorkerProcessType;<br />startDelay?: number;<br />elementAddDelay?: number;<br />elementsPerWorker?: number \| 'auto' \| 'all';<br />poolMinSize?: number;<br />poolMaxSize?: number;<br />resourceLimits?: ResourceLimits;<br />} | Worker configuration section:<br />- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)<br />- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)<br />- _elementAddDelay_: milliseconds to wait between charging station add<br />- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)<br />- _poolMinSize_: worker threads pool minimum number of threads</br >- _poolMaxSize_: worker threads pool maximum number of threads<br />- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option |
+| uiServer | | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"accessPolicy": {<br />"requireTlsForNonLoopback": true,<br />"trustedProxies": [],<br />"allowLoopbackProxy": false,<br />"allowedHosts": [],<br />"allowedOrigins": []<br />},<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />} | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />accessPolicy?: {<br />requireTlsForNonLoopback?: boolean;<br />trustedProxies?: string[];<br />allowLoopbackProxy?: boolean;<br />allowedHosts?: string[];<br />allowedOrigins?: string[];<br />};<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />};<br />metrics?: {<br />enabled?: boolean;<br />softSampleCap?: number;<br />};<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'ws', 'mcp' or 'http' (deprecated)<br />- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')<br />- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:<br /> - _requireTlsForNonLoopback_: reject non-loopback plaintext requests; the check honors `X-Forwarded-Proto` or `Forwarded: proto=` from a trusted proxy, non-loopback requests without forwarded protocol headers are denied as `tls-required`<br /> - _trustedProxies_: IPv4 or IPv6 literals of the immediate reverse proxies whose forwarded headers are honored (hostnames and CIDR ranges are not accepted; only single-hop forwarded chains are honored); a compromised entry can bypass per-client rate limiting by varying `X-Forwarded-For`<br /> - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br /> - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br /> - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section<br />- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):<br /> - _enabled_: enable the `/metrics` endpoint<br /> - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000) |
+| performanceStorage | | {<br />"enabled": true,<br />"type": "none",<br />} | {<br />enabled?: boolean;<br />type?: string;<br />uri?: string;<br />} | Performance storage configuration section:<br />- _enabled_: enable performance storage<br />- _type_: 'jsonfile', 'mongodb' or 'none'<br />- _uri_: storage URI |
+| stationTemplateUrls | | {}[] | {<br />file: string;<br />numberOfStations: number;<br />provisionedNumberOfStations?: number;<br />}[] | array of charging station templates URIs configuration section:<br />- _file_: charging station configuration template file relative path<br />- _numberOfStations_: template number of stations at startup<br />- _provisionedNumberOfStations_: template provisioned number of stations after startup |
+| persistState | true/false | true | boolean | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START=true` for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned |
#### Worker process model
- **dynamicPool** (experimental):
Dynamically sized worker pool executing a fixed total number of simulated charging stations
+#### Migration notes
+
+- `uiServer.metrics.enabled=true` now exposes `/metrics` on the listener of the configured `uiServer.type` (`http`, `ws` or `mcp`), inheriting that listener's `accessPolicy` and `authentication`. Operators previously relying on the silent disable when `type='ws'` or `type='mcp'` must set `metrics.enabled=false` (or remove the block) if they do not want the endpoint, and verify firewall posture on the UI server port. `HEAD /metrics` (e.g. `curl -I`) returns identical headers to `GET`, including `Content-Length` measured in UTF-8 bytes of the GET body.
+
### Charging station template configuration
**src/assets/station-templates/\<name\>.json**:
import { getReasonPhrase, StatusCodes } from 'http-status-codes'
import { type IncomingMessage, Server, type ServerResponse } from 'node:http'
import { createServer, type Http2Server } from 'node:http2'
+import { Gauge, type GaugeConfiguration, Registry } from 'prom-client'
import type { IBootstrap } from '../IBootstrap.js'
import type { AbstractUIService } from './ui-services/AbstractUIService.js'
import { BaseError } from '../../exception/index.js'
import {
- ApplicationProtocol,
ApplicationProtocolVersion,
AuthenticationType,
type ChargingStationData,
ConfigurationSection,
+ type ConnectorEntry,
+ type ConnectorStatus,
type ProcedureName,
type ProtocolRequest,
type ProtocolResponse,
DEFAULT_RATE_WINDOW_MS,
isValidCredential,
} from './UIServerSecurity.js'
-import { getUsernameAndPasswordFromAuthorizationToken } from './UIServerUtils.js'
+import { getUsernameAndPasswordFromAuthorizationToken, HttpMethod } from './UIServerUtils.js'
/**
* Outcome of {@link AbstractUIServer.runRequestPrologue}.
const moduleName = 'AbstractUIServer'
+/**
+ * Soft cardinality cap for the Prometheus exposition. When a single scrape
+ * emits more samples than this threshold, a single `logger.warn` is logged
+ * and the response is still served in full. There is no truncation and no
+ * scrape failure; the operator decides whether to disable the endpoint or
+ * scale the threshold.
+ */
+export const METRICS_SOFT_SAMPLE_CAP = 5_000
+
+/**
+ * Stable substring prefix of the soft-cap warn message emitted by
+ * {@link AbstractUIServer.runMetricsScrape}. Exported so regression specs
+ * filter `logger.warn` calls without hard-coding the marketing wording.
+ */
+export const METRICS_SOFT_CAP_WARN_PREFIX = 'Prometheus scrape produced'
+
+/**
+ * Frozen tuple of every label name a Prometheus gauge defined by
+ * {@link AbstractUIServer.buildMetricsRegistry} is permitted to emit.
+ * `Object.freeze` is honest on arrays (blocks index assignment, `push`,
+ * `length`); on `Set` it is a no-op against `.add`/`.delete`. The
+ * `as const` annotation pins the literal types. Co-located with
+ * {@link METRICS_SOFT_SAMPLE_CAP} as the canonical PII surface: adding a
+ * label here is the single, explicit PR action that admits it into the
+ * exposition. O(1) membership goes through
+ * {@link isMetricsAllowedLabelName}.
+ */
+export const METRICS_ALLOWED_LABEL_NAMES = Object.freeze([
+ 'availability',
+ 'connector_id',
+ 'connector_type',
+ 'current_out_type',
+ 'error_code',
+ 'evse_id',
+ 'firmware_version',
+ 'hash_id',
+ 'model',
+ 'ocpp_version',
+ 'status',
+ 'template',
+ 'vendor',
+ 'version',
+] as const)
+
+/** Label name admitted by {@link METRICS_ALLOWED_LABEL_NAMES}. */
+export type MetricsAllowedLabelName = (typeof METRICS_ALLOWED_LABEL_NAMES)[number]
+
+const metricsAllowedLabelNameSet: ReadonlySet<MetricsAllowedLabelName> = new Set(
+ METRICS_ALLOWED_LABEL_NAMES
+)
+
+/**
+ * O(1) type-guard predicate over {@link METRICS_ALLOWED_LABEL_NAMES}.
+ * @param name Candidate label name.
+ * @returns `true` iff `name` is one of the admitted label names.
+ */
+export const isMetricsAllowedLabelName = (name: string): name is MetricsAllowedLabelName =>
+ (metricsAllowedLabelNameSet as ReadonlySet<string>).has(name)
+
+/**
+ * URL pathname under which Prometheus exposition is served on every UI
+ * transport (`http`, `ws`, `mcp`). The endpoint is opt-in via
+ * `uiServer.metrics.enabled`; the pathname is not configurable. Internal
+ * constant — not part of the public API.
+ */
+const METRICS_PATHNAME = '/metrics'
+
+/**
+ * Subset of {@link AbstractUIServer} consumed by the metrics gauge helpers
+ * and by the inline `simulator_ui_server_known_stations_total` gauge in
+ * {@link AbstractUIServer.buildMetricsRegistry}. Restricting access to this
+ * projection keeps the metrics surface decoupled from the concrete UI
+ * server class and from `this`-rebound contexts inside `collect()`
+ * callbacks.
+ */
+interface ChargingStationDataProvider {
+ getChargingStationsCount(): number
+ listChargingStationData(): ChargingStationData[]
+}
+
export abstract class AbstractUIServer {
protected readonly httpServer: Http2Server | Server
protected readonly rateLimiter: ReturnType<typeof createRateLimiter>
private readonly chargingStations: Map<string, ChargingStationData>
private readonly chargingStationTemplates: Set<string>
private clientNotificationDebounceTimer: ReturnType<typeof setTimeout> | undefined
+ private metricsRegistry?: Registry
+ /**
+ * Per-scrape sample counter. Reset to 0 at the start of each scrape and
+ * snapshotted into a scrape-local `sampleCount` BEFORE the first `await`
+ * (see {@link runMetricsScrape}). Mutated by the `accountSamples`
+ * closure captured by every `collect()` callback in {@link buildMetricsRegistry}.
+ * Concurrency safety has two layers:
+ * 1. Within a `start()` cycle, {@link metricsScrapeChain} serializes
+ * scrapes so no two `reset → Registry.metrics()` interleave their
+ * counter mutations.
+ * 2. Across `stop()`/`start()` cycles, where {@link start} reseats the
+ * chain to `Promise.resolve()`, the pre-yield local capture in
+ * {@link runMetricsScrape} frees the soft-cap branch from
+ * cross-cycle reads of this field.
+ * Both layers also require the `prom-client` 15.x sync prefix to remain
+ * yield-free: `Registry.metrics()` iterates metrics and invokes each
+ * `Gauge.get()` (driving its `collect()` callback) synchronously before
+ * its first `await`. Every `collect()` callback must therefore be
+ * synchronous, and a future `prom-client` release that adds a pre-map
+ * `await` (lazy hooks, OTEL bridge, async default-label resolution)
+ * would invalidate the pre-yield capture and require revisiting layer 2.
+ */
+ private metricsSampleCount = 0
+ private metricsScrapeChain: Promise<void> = Promise.resolve()
+ private transportAttached = false
public constructor (
protected readonly uiServerConfiguration: UIServerConfiguration,
}
}
- public abstract start (): void
+ /**
+ * Bring the UI server up exactly once. Template-method that builds the
+ * Prometheus registry (gated by `uiServer.metrics.enabled`), invokes
+ * the subclass {@link attachTransport} hook, then starts the shared
+ * HTTP server. The ordering — registry BEFORE listener BEFORE
+ * `listen()` — combined with the one-shot {@link transportAttached}
+ * guard guarantees no caller can reorder, skip, or re-run transport
+ * attachment. The guard is set BEFORE {@link attachTransport} so a
+ * throwing hook cannot leave a half-attached server that admits a
+ * silent retry; on failure any partially-registered listeners on
+ * `httpServer` are stripped, the guard is rolled back, and the throw
+ * propagates. A second `start()` without an intervening `stop()`
+ * throws {@link BaseError}; recovery requires the explicit
+ * `stop()` → `start()` cycle.
+ */
+ public start (): void {
+ if (this.transportAttached) {
+ throw new BaseError(
+ `${this.uiServerType} start() invoked twice; attachTransport() is one-shot`
+ )
+ }
+ // Fresh lifecycle: drop the chain accreted across prior stop() cycles
+ // so captured-registry closures are not retained for the lifetime of
+ // the server instance. The prior chain's scheduled registry.clear()
+ // still runs against its own captured registry reference.
+ this.metricsScrapeChain = Promise.resolve()
+ this.buildMetricsRegistryIfEnabled()
+ this.transportAttached = true
+ try {
+ this.attachTransport()
+ } catch (error) {
+ try {
+ this.detachTransport()
+ } catch (detachError) {
+ logger.error(
+ `${this.logPrefix(moduleName, 'start')} Error during detachTransport() on attach failure, continuing rollback:`,
+ detachError
+ )
+ }
+ this.httpServer.removeAllListeners('request')
+ this.httpServer.removeAllListeners('upgrade')
+ this.metricsRegistry = undefined
+ this.transportAttached = false
+ throw error
+ }
+ this.startHttpServer()
+ }
+ /**
+ * Bring the UI server down and release any Prometheus registry held on
+ * this instance. Two independent orderings coexist:
+ *
+ * 1. The registry clear is **scheduled** onto
+ * `metricsScrapeChain.finally` so any in-flight scrape finishes
+ * BEFORE `registry.clear()` runs. The terminal
+ * `.catch(() => undefined)` keeps the chain field pointing at a
+ * handled promise so a late rejection cannot escape.
+ * 2. The subclass {@link detachTransport} hook and the shared HTTP
+ * server shutdown run **synchronously** in body order (detach →
+ * close → stop services → clear handlers/caches → reset
+ * {@link transportAttached}). They do NOT wait for the scheduled
+ * registry clear; subclass overrides MUST NOT touch state read by
+ * `collect()` callbacks still in the scheduled clear chain.
+ *
+ * A throw from {@link detachTransport} or from a single
+ * `uiService.stop()` is caught and logged so downstream teardown
+ * (`stopHttpServer`, remaining services, handlers, caches) still runs.
+ * Any other unexpected throw inside the teardown body still
+ * propagates, but {@link transportAttached} is reset in a `finally`
+ * block so a subsequent `start()` is never permanently locked out.
+ */
public stop (): void {
- clearTimeout(this.clientNotificationDebounceTimer)
- this.stopHttpServer()
- for (const uiService of this.uiServices.values()) {
- uiService.stop()
+ try {
+ clearTimeout(this.clientNotificationDebounceTimer)
+ this.clientNotificationDebounceTimer = undefined
+ if (this.metricsRegistry !== undefined) {
+ const registry = this.metricsRegistry
+ this.metricsScrapeChain = this.metricsScrapeChain
+ .finally(() => {
+ registry.clear()
+ })
+ .catch(() => undefined)
+ this.metricsRegistry = undefined
+ }
+ // detachTransport() / uiService.stop() are subclass hooks — see
+ // the "throw from detachTransport()" paragraph in the JSDoc above
+ // for the catch-and-log rationale.
+ try {
+ this.detachTransport()
+ } catch (error) {
+ logger.error(
+ `${this.logPrefix(moduleName, 'stop')} Error during detachTransport(), continuing teardown:`,
+ error
+ )
+ }
+ this.stopHttpServer()
+ for (const uiService of this.uiServices.values()) {
+ try {
+ uiService.stop()
+ } catch (error) {
+ logger.error(
+ `${this.logPrefix(moduleName, 'stop')} Error during uiService.stop(), continuing teardown:`,
+ error
+ )
+ }
+ }
+ this.uiServices.clear()
+ this.responseHandlers.clear()
+ this.clearCaches()
+ } finally {
+ this.transportAttached = false
}
- this.uiServices.clear()
- this.responseHandlers.clear()
- this.clearCaches()
}
+ /**
+ * Attach the transport's request and/or upgrade listener(s) on
+ * `this.httpServer`. Implementations MUST NOT invoke
+ * `httpServer.listen()` — the template {@link start} owns lifecycle
+ * ordering. Called exactly once per `start()` cycle; the inverse hook
+ * is {@link detachTransport}.
+ */
+ protected abstract attachTransport (): void
+
protected authenticate (req: IncomingMessage): boolean {
if (this.uiServerConfiguration.authentication?.enabled !== true) {
return true
return false
}
+ /**
+ * Build and memoize the Prometheus {@link Registry} on the first
+ * `start()` when `uiServer.metrics.enabled === true`. Idempotent: a
+ * second call after the registry has been built is a no-op. The
+ * registry is released by {@link stop} via `metricsScrapeChain.finally`.
+ * The `/metrics` route itself is mounted by {@link tryServeMetrics} from
+ * each transport listener; this method owns the registry only.
+ */
+ protected buildMetricsRegistryIfEnabled (): void {
+ if (this.uiServerConfiguration.metrics?.enabled !== true) {
+ return
+ }
+ if (this.metricsRegistry !== undefined) {
+ return
+ }
+ this.metricsRegistry = this.buildMetricsRegistry()
+ }
+
+ /**
+ * Destroy the underlying HTTP/1.1 socket after a denial when the
+ * client has not finished writing. "Pending" = HTTP/1.1 request whose
+ * body the client has not finished sending (`req.complete === false`).
+ * HTTP/2 streams self-close via `res.end()`; calling `req.destroy()`
+ * on an HTTP/2 stream is redundant and may raise
+ * `ERR_HTTP2_INVALID_STREAM` on some Node versions. The `req.complete`
+ * guard skips teardown once the client has finished writing (the
+ * socket is owned by the keep-alive pool).
+ * @param req Incoming HTTP request.
+ */
+ protected destroyHttp1SocketIfPending (req: IncomingMessage): void {
+ if (req.httpVersionMajor >= 2) {
+ return
+ }
+ if (!req.complete) {
+ req.destroy()
+ }
+ }
+
+ /**
+ * Symmetric inverse of {@link attachTransport}. Default no-op because
+ * `httpServer.removeAllListeners()` in `stopHttpServer` already strips
+ * every listener installed on the shared server. Subclasses with
+ * transport-specific resources outliving the HTTP listener override
+ * to release them. Invoked from {@link stop} BEFORE `stopHttpServer()`;
+ * see {@link stop} point 2 for the constraint on overrides w.r.t. the
+ * scheduled registry clear chain.
+ */
+ protected detachTransport (): void {
+ /* Default: no resources to release */
+ }
+
/**
* Connection-close header to attach on denial responses.
*
: { Connection: 'close' }
}
+ /**
+ * Accessor for the lazily-built Prometheus {@link Registry}. Returns
+ * `undefined` when `metrics.enabled !== true` or before
+ * {@link buildMetricsRegistryIfEnabled} has run. Subclasses MUST NOT
+ * mutate the registry; lifecycle is owned by {@link stop}.
+ * @returns The active metrics registry, or `undefined` when disabled.
+ */
+ protected getMetricsRegistry (): Registry | undefined {
+ return this.metricsRegistry
+ }
+
protected getUnauthorizedDenial (): {
headers: Readonly<Record<string, string>>
reasonPhrase: string
}
}
+ /**
+ * Render the Prometheus exposition for a `/metrics` GET or HEAD request.
+ *
+ * Schedules the scrape on the serial {@link runMetricsScrape} chain and
+ * converts any rejection into `HTTP 500 Internal Server Error` (only
+ * when the response is still writable — a partial write is left
+ * untouched).
+ *
+ * Pre-conditions enforced by {@link tryServeMetrics}: `metrics.enabled`
+ * is true, the request method is `GET` or `HEAD`, the pathname is
+ * `/metrics`, and the prologue + authentication have already passed.
+ *
+ * The `registry === undefined` branch is a defensive 404 against a late
+ * {@link stop} that nulled the registry while this request was in
+ * flight. HEAD responses send identical headers to GET (including
+ * `Content-Length`) with an empty body per RFC 9110 §9.3.2.
+ * @param req HTTP request (used to detect HEAD).
+ * @param res Server response to end with the exposition body (or empty
+ * for HEAD / on error).
+ */
+ protected handleMetricsHttpRequest (req: IncomingMessage, res: ServerResponse): void {
+ const registry = this.metricsRegistry
+ if (registry === undefined) {
+ this.renderDenial(res, {
+ reasonPhrase: getReasonPhrase(StatusCodes.NOT_FOUND),
+ status: StatusCodes.NOT_FOUND,
+ })
+ return
+ }
+ this.runMetricsScrape(req, res, registry).catch((error: unknown) => {
+ logger.error(
+ `${this.logPrefix(moduleName, 'handleMetricsHttpRequest')} Metrics handler error:`,
+ error
+ )
+ if (!res.headersSent && !res.writableEnded) {
+ res.writeHead(StatusCodes.INTERNAL_SERVER_ERROR, { 'Content-Type': 'text/plain' }).end()
+ }
+ })
+ }
+
+ protected isMetricsRequest (req: IncomingMessage): boolean {
+ if (req.method !== HttpMethod.GET && req.method !== HttpMethod.HEAD) {
+ return false
+ }
+ const rawUrl = req.url ?? ''
+ try {
+ const { pathname } = new URL(rawUrl, 'http://localhost')
+ return pathname === METRICS_PATHNAME
+ } catch {
+ return false
+ }
+ }
+
protected notifyClients (): void {
// No-op by default — subclasses with push capability override this
}
.end(`${payload.status.toString()} ${payload.reasonPhrase}`)
}
+ /**
+ * Emit a 404 denial then tear down the request socket if the client
+ * did not finish writing. Used by transports that mount `/metrics`
+ * next to a single non-default route — every other path is a 404.
+ * Ordering is fixed: response is rendered first, then `req.destroy()`,
+ * so observers of `'finish'` see the response before the socket
+ * teardown.
+ * @param req Incoming HTTP request.
+ * @param res Server response.
+ */
+ protected renderNotFoundAndDestroy (req: IncomingMessage, res: ServerResponse): void {
+ this.renderDenial(res, {
+ reasonPhrase: getReasonPhrase(StatusCodes.NOT_FOUND),
+ status: StatusCodes.NOT_FOUND,
+ })
+ this.destroyHttp1SocketIfPending(req)
+ }
+
/**
* Run the access-policy + rate-limit prologue for a request.
*
}
}
+ /**
+ * Single-gate dispatcher for the Prometheus `/metrics` endpoint, shared
+ * by every transport that mounts an HTTP request listener. Encapsulates
+ * the `metrics.enabled` gate, the path predicate
+ * ({@link isMetricsRequest}), authentication, and delegation to
+ * {@link handleMetricsHttpRequest}. All concrete UI servers MUST call
+ * this between {@link runRequestPrologue} and their transport-specific
+ * dispatch so gating, ordering, and auth coverage stay identical across
+ * transports.
+ * @param req Incoming HTTP request (post-prologue).
+ * @param res Server response.
+ * @returns `true` when the helper has written a response (served or
+ * denied); the caller MUST then return. `false` when the request is
+ * not for `/metrics` and the caller should continue its own routing.
+ */
+ protected tryServeMetrics (req: IncomingMessage, res: ServerResponse): boolean {
+ if (this.uiServerConfiguration.metrics?.enabled !== true) {
+ return false
+ }
+ if (!this.isMetricsRequest(req)) {
+ return false
+ }
+ if (!this.authenticate(req)) {
+ this.renderDenial(res, this.getUnauthorizedDenial())
+ return true
+ }
+ this.handleMetricsHttpRequest(req, res)
+ return true
+ }
+
+ /**
+ * Build the Prometheus `Registry` populated with every gauge exposed by
+ * the simulator. Each gauge declares an explicit source field via its
+ * `collect()` callback; there is no generic property iteration so adding
+ * a new field on `ChargingStationData` does NOT silently expose it
+ * (PII allowlist invariant). All `collect()` callbacks registered here
+ * are synchronous; an async `collect` would let `prom-client` interleave
+ * them within a single `Registry.metrics()` call, racing on
+ * {@link metricsSampleCount}.
+ * @returns The populated registry; `Registry.metrics()` renders the body.
+ */
+ private buildMetricsRegistry (): Registry {
+ const registry = new Registry()
+ const bootstrap = this.getBootstrap()
+ const provider: ChargingStationDataProvider = {
+ getChargingStationsCount: this.getChargingStationsCount.bind(this),
+ listChargingStationData: this.listChargingStationData.bind(this),
+ }
+ const accountSamples = (n: number): void => {
+ this.metricsSampleCount += n
+ }
+
+ /** Global gauges. */
+
+ defineGauge(registry, {
+ collect (this: Gauge<'version'>) {
+ this.reset()
+ this.labels({ version: bootstrap.getState().version }).set(1)
+ accountSamples(1)
+ },
+ help: 'Simulator process information.',
+ labelNames: ['version'] as const,
+ name: 'simulator_info',
+ })
+
+ defineGauge(registry, {
+ collect (this: Gauge) {
+ this.reset()
+ this.set(bootstrap.getState().started ? 1 : 0)
+ accountSamples(1)
+ },
+ help: '1 when the simulator is started, 0 otherwise.',
+ name: 'simulator_started',
+ })
+
+ defineGauge(registry, {
+ collect (this: Gauge) {
+ this.reset()
+ this.set(bootstrap.getState().templateStatistics.size)
+ accountSamples(1)
+ },
+ help: 'Number of charging station templates configured.',
+ name: 'simulator_charging_station_templates_total',
+ })
+
+ const stationAggregates = [
+ [
+ 'simulator_charging_stations_configured_total',
+ 'configured',
+ 'Number of charging stations configured across all templates.',
+ ],
+ [
+ 'simulator_charging_stations_provisioned_total',
+ 'provisioned',
+ 'Number of charging stations provisioned across all templates.',
+ ],
+ [
+ 'simulator_charging_stations_added_total',
+ 'added',
+ 'Number of charging stations added in the current process.',
+ ],
+ [
+ 'simulator_charging_stations_started_total',
+ 'started',
+ 'Number of charging stations currently started.',
+ ],
+ ] as const
+ for (const [name, key, help] of stationAggregates) {
+ defineGauge(registry, {
+ collect (this: Gauge) {
+ let total = 0
+ for (const t of bootstrap.getState().templateStatistics.values()) {
+ total += t[key]
+ }
+ this.reset()
+ this.set(total)
+ accountSamples(1)
+ },
+ help,
+ name,
+ })
+ }
+
+ defineGauge(registry, {
+ collect (this: Gauge) {
+ this.reset()
+ this.set(provider.getChargingStationsCount())
+ accountSamples(1)
+ },
+ help: 'Number of charging station snapshots cached on the UI server.',
+ name: 'simulator_ui_server_known_stations_total',
+ })
+
+ for (const [name, key] of [
+ ['simulator_template_added', 'added'],
+ ['simulator_template_configured', 'configured'],
+ ['simulator_template_provisioned', 'provisioned'],
+ ['simulator_template_started', 'started'],
+ ] as const) {
+ defineGauge(registry, {
+ collect (this: Gauge<'template'>) {
+ this.reset()
+ for (const [templateName, t] of bootstrap.getState().templateStatistics) {
+ this.labels({ template: templateName }).set(t[key])
+ accountSamples(1)
+ }
+ },
+ help: `Per-template '${key}' charging stations counter.`,
+ labelNames: ['template'] as const,
+ name,
+ })
+ }
+
+ /** Per charging station gauges. */
+
+ defineGauge(registry, {
+ collect (
+ this: Gauge<
+ 'current_out_type' | 'firmware_version' | 'hash_id' | 'model' | 'ocpp_version' | 'vendor'
+ >
+ ) {
+ this.reset()
+ for (const data of provider.listChargingStationData()) {
+ this.labels({
+ current_out_type: stringLabel(data.stationInfo.currentOutType),
+ firmware_version: stringLabel(data.stationInfo.firmwareVersion),
+ hash_id: data.stationInfo.hashId,
+ model: stringLabel(data.stationInfo.chargePointModel),
+ ocpp_version: stringLabel(data.stationInfo.ocppVersion),
+ vendor: stringLabel(data.stationInfo.chargePointVendor),
+ }).set(1)
+ accountSamples(1)
+ }
+ },
+ help: 'Static information for the charging station (vendor / model / firmware / ocpp).',
+ labelNames: [
+ 'hash_id',
+ 'vendor',
+ 'model',
+ 'firmware_version',
+ 'ocpp_version',
+ 'current_out_type',
+ ] as const,
+ name: 'simulator_station_info',
+ })
+
+ addPerStationBoolean(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_started',
+ '1 when the charging station is started, 0 otherwise.',
+ data => data.started
+ )
+
+ addPerStationNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_ws_state',
+ 'WebSocket readyState (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED).',
+ data => data.wsState
+ )
+
+ addPerStationNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_connectors_total',
+ 'Number of connectors of the charging station.',
+ countConnectors
+ )
+
+ addPerStationNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_evses_total',
+ 'Number of EVSEs of the charging station.',
+ data => data.evses.length
+ )
+
+ addPerStationNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_max_power_watts',
+ 'Maximum power of the charging station, in Watts.',
+ data => data.stationInfo.maximumPower
+ )
+
+ addPerStationNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_max_amperage_amperes',
+ 'Maximum amperage of the charging station, in Amperes.',
+ data => data.stationInfo.maximumAmperage
+ )
+
+ addPerStationNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_voltage_out_volts',
+ 'Voltage output of the charging station, in Volts.',
+ data => data.stationInfo.voltageOut
+ )
+
+ addPerStationNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_data_timestamp_seconds',
+ 'Unix epoch (seconds) at which the charging station snapshot was emitted.',
+ data => Math.floor(data.timestamp / 1000)
+ )
+
+ addPerStationStatusInfo(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_boot_status_info',
+ 'BootNotification status (one-hot).',
+ data => data.bootNotificationResponse?.status
+ )
+
+ addPerStationNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_boot_heartbeat_interval_seconds',
+ 'BootNotification heartbeat interval, in seconds.',
+ data => data.bootNotificationResponse?.interval
+ )
+
+ addPerStationBoolean(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_atg_enabled',
+ '1 when the ATG is enabled in configuration, 0 otherwise.',
+ data => data.automaticTransactionGenerator?.automaticTransactionGenerator?.enable === true
+ )
+
+ addPerStationStatusInfo(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_diagnostics_status_info',
+ 'Most recent DiagnosticsStatusNotification status (one-hot).',
+ data => data.stationInfo.diagnosticsStatus
+ )
+
+ addPerStationStatusInfo(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_firmware_status_info',
+ 'Most recent FirmwareStatusNotification status (one-hot).',
+ data => data.stationInfo.firmwareStatus
+ )
+
+ addPerStationNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_station_ocpp_config_keys_total',
+ 'Number of OCPP configuration keys advertised by the charging station.',
+ data => data.ocppConfiguration.configurationKey?.length ?? 0
+ )
+
+ /** Per connector gauges. */
+
+ addConnectorOneHot(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_status_info',
+ 'Connector status (one-hot).',
+ 'status',
+ cs => cs.status
+ )
+ addConnectorOneHot(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_boot_status_info',
+ 'Connector boot status (one-hot).',
+ 'status',
+ cs => cs.bootStatus
+ )
+ addConnectorOneHot(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_availability_info',
+ 'Connector availability (one-hot).',
+ 'availability',
+ cs => cs.availability
+ )
+ addConnectorOneHot(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_error_code_info',
+ 'Connector OCPP error code (one-hot).',
+ 'error_code',
+ cs => cs.errorCode
+ )
+ addConnectorOneHot(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_type_info',
+ 'Connector physical type (one-hot).',
+ 'connector_type',
+ cs => cs.type
+ )
+
+ addConnectorBoolean(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_locked',
+ '1 when the connector is locked, 0 otherwise.',
+ cs => cs.locked === true
+ )
+ addConnectorBoolean(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_transaction_started',
+ '1 when a transaction is currently started on the connector.',
+ cs => cs.transactionStarted === true
+ )
+ addConnectorBoolean(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_transaction_pending',
+ '1 when a transaction is pending on the connector.',
+ cs => cs.transactionPending === true
+ )
+ addConnectorBoolean(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_transaction_remote_started',
+ '1 when the current transaction was remote-started.',
+ cs => cs.transactionRemoteStarted === true
+ )
+ addConnectorBoolean(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_reservation_active',
+ '1 when an active reservation is set on the connector.',
+ cs => cs.reservation != null
+ )
+
+ addConnectorNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_transaction_seq_no',
+ 'Last transaction event sequence number sent on the connector.',
+ cs => cs.transactionSeqNo
+ )
+ addConnectorNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_transaction_event_queue_size',
+ 'Number of pending transaction events queued on the connector.',
+ cs => cs.transactionEventQueue?.length ?? 0
+ )
+ addConnectorNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_transaction_id',
+ 'Numeric transaction id of the active transaction on the connector. NEVER used as a label (cardinality).',
+ cs => (typeof cs.transactionId === 'number' ? cs.transactionId : undefined)
+ )
+ addConnectorNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_transaction_start_seconds',
+ 'Unix epoch (seconds) at which the active transaction started on the connector.',
+ cs =>
+ cs.transactionStart != null ? Math.floor(cs.transactionStart.getTime() / 1000) : undefined
+ )
+ addConnectorNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_transaction_energy_active_import_register_wh',
+ 'Active energy imported during the current transaction, in Wh.',
+ cs => cs.transactionEnergyActiveImportRegisterValue
+ )
+ addConnectorNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_energy_active_import_register_wh',
+ 'Cumulative active energy imported by the connector meter, in Wh.',
+ cs => cs.energyActiveImportRegisterValue
+ )
+ addConnectorNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_max_power_watts',
+ 'Maximum power of the connector, in Watts.',
+ cs => cs.maximumPower
+ )
+ addConnectorNumeric(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_charging_profiles_total',
+ 'Number of charging profiles installed on the connector.',
+ cs => cs.chargingProfiles?.length ?? 0
+ )
+ addConnectorNumericFromEntry(
+ registry,
+ accountSamples,
+ provider,
+ 'simulator_connector_evse_id',
+ 'EVSE id the connector belongs to.',
+ entry => entry.evseId
+ )
+
+ return registry
+ }
+
private isBasicAuthEnabled (): boolean {
return (
this.uiServerConfiguration.authentication?.enabled === true &&
)
}
+ /**
+ * Schedule a `/metrics` scrape onto {@link metricsScrapeChain} so concurrent
+ * scrape requests serialize through a single FIFO chain (preserves the
+ * {@link metricsSampleCount} invariant). The function itself is synchronous —
+ * the inner async work runs in a `.then()` continuation and rejections
+ * propagate to the returned promise, which the caller's `.catch()`
+ * converts to HTTP 500. The exposition body is omitted on HEAD per
+ * RFC 9110 §9.3.2; HEAD response headers are identical to GET, including
+ * `Content-Length` set to the byte length of the body GET would emit.
+ * @param req HTTP request (used to detect HEAD).
+ * @param res HTTP response to end with the exposition body.
+ * @param registry Source Prometheus registry.
+ * @returns The chained scrape promise.
+ */
+ private runMetricsScrape (
+ req: IncomingMessage,
+ res: ServerResponse,
+ registry: Registry
+ ): Promise<void> {
+ this.metricsScrapeChain = this.metricsScrapeChain
+ .catch(() => undefined)
+ .then(async () => {
+ this.metricsSampleCount = 0
+ // prom-client's sync prefix runs every collect() (each writing
+ // this.metricsSampleCount via accountSamples) before its first
+ // internal await. Snapshot the counter here, BEFORE our own
+ // await yields, so a peer scrape dispatched after a sync
+ // stop()→start() (which reseats metricsScrapeChain) cannot
+ // overwrite the count we read for the soft-cap branch. See the
+ // metricsSampleCount field JSDoc for the full invariant.
+ const metricsPromise = registry.metrics()
+ const sampleCount = this.metricsSampleCount
+ const rawBody: string = await metricsPromise
+ const cap = this.uiServerConfiguration.metrics?.softSampleCap ?? METRICS_SOFT_SAMPLE_CAP
+ if (sampleCount > cap) {
+ logger.warn(
+ `${this.logPrefix(moduleName, 'runMetricsScrape')} ` +
+ `${METRICS_SOFT_CAP_WARN_PREFIX} ${sampleCount.toString()} samples ` +
+ `(soft cap ${cap.toString()})`
+ )
+ }
+ if (!res.headersSent && !res.writableEnded) {
+ const contentLength = Buffer.byteLength(rawBody, 'utf8').toString()
+ const isHead = req.method === HttpMethod.HEAD
+ res
+ .writeHead(StatusCodes.OK, {
+ 'Content-Length': contentLength,
+ 'Content-Type': registry.contentType,
+ })
+ .end(isHead ? undefined : rawBody)
+ }
+ return undefined
+ })
+ return this.metricsScrapeChain
+ }
+
+ /**
+ * Strips listeners before `httpServer.close()`. The order releases
+ * subscribers even if `close()` throws, and lets {@link detachTransport}
+ * stay a default no-op. Subclasses MUST NOT attach `httpServer` listeners
+ * whose side-effects must run after shutdown.
+ */
private stopHttpServer (): void {
if (this.httpServer.listening) {
- this.httpServer.close()
this.httpServer.removeAllListeners()
+ this.httpServer.close()
}
}
const isWildcard =
configuredHost === '' || configuredHost === '0.0.0.0' || configuredHost === '::'
- if (
- this.uiServerConfiguration.metrics?.enabled === true &&
- this.uiServerConfiguration.type !== ApplicationProtocol.HTTP
- ) {
- logger.warn(
- `${this.logPrefix(
- moduleName,
- 'constructor'
- )} metrics.enabled=true is honored only when uiServer.type='http'; current type='${
- this.uiServerConfiguration.type ?? 'undefined'
- }'. The /metrics endpoint will not be served. Set uiServer.type='http' to expose metrics.`
- )
- }
-
if (isWildcard && allowedHosts.length === 0) {
logger.warn(
`${this.logPrefix(
}
}
}
+
+/**
+ * Construct and register a Prometheus `Gauge` whose `labelNames` are narrowed
+ * to a string-literal union via `as const`. The returned reference is owned
+ * by `registry` (lifecycle managed by `Registry.clear()`); callers may ignore
+ * it because each gauge's `collect()` callback receives the gauge as its
+ * `this` binding. The `L = never` default (stricter than `prom-client`'s own
+ * `Gauge<T extends string = string>`) forbids passing `labelNames` for
+ * unlabeled gauges, catching mismatches at compile time.
+ * @param registry Destination registry; auto-injected into `registers`.
+ * @param config Gauge configuration WITHOUT `registers`.
+ * @returns The constructed `Gauge<L>`.
+ */
+const defineGauge = <L extends string = never>(
+ registry: Registry,
+ config: Omit<GaugeConfiguration<L>, 'registers'>
+): Gauge<L> => new Gauge<L>({ ...config, registers: [registry] })
+
+const stringLabel = (value: string | undefined): string => value ?? ''
+
+const addPerStationNumeric = (
+ registry: Registry,
+ account: (n: number) => void,
+ server: ChargingStationDataProvider,
+ name: string,
+ help: string,
+ pick: (data: ChargingStationData) => number | undefined
+): void => {
+ defineGauge(registry, {
+ collect (this: Gauge<'hash_id'>) {
+ this.reset()
+ for (const data of server.listChargingStationData()) {
+ const v = pick(data)
+ if (typeof v === 'number') {
+ this.labels({ hash_id: data.stationInfo.hashId }).set(v)
+ account(1)
+ }
+ }
+ },
+ help,
+ labelNames: ['hash_id'] as const,
+ name,
+ })
+}
+
+const addPerStationBoolean = (
+ registry: Registry,
+ account: (n: number) => void,
+ server: ChargingStationDataProvider,
+ name: string,
+ help: string,
+ pick: (data: ChargingStationData) => boolean
+): void => {
+ defineGauge(registry, {
+ collect (this: Gauge<'hash_id'>) {
+ this.reset()
+ for (const data of server.listChargingStationData()) {
+ this.labels({ hash_id: data.stationInfo.hashId }).set(pick(data) ? 1 : 0)
+ account(1)
+ }
+ },
+ help,
+ labelNames: ['hash_id'] as const,
+ name,
+ })
+}
+
+const addPerStationStatusInfo = (
+ registry: Registry,
+ account: (n: number) => void,
+ server: ChargingStationDataProvider,
+ name: string,
+ help: string,
+ pick: (data: ChargingStationData) => string | undefined
+): void => {
+ defineGauge(registry, {
+ collect (this: Gauge<'hash_id' | 'status'>) {
+ this.reset()
+ for (const data of server.listChargingStationData()) {
+ const v = pick(data)
+ if (typeof v === 'string') {
+ this.labels({ hash_id: data.stationInfo.hashId, status: v }).set(1)
+ account(1)
+ }
+ }
+ },
+ help,
+ labelNames: ['hash_id', 'status'] as const,
+ name,
+ })
+}
+
+/**
+ * Connector count under the OCPP 1.6 (`data.connectors`) vs OCPP 2.0.x
+ * (`data.evses[*].evseStatus.connectors`) source split. The two sources
+ * are mutually exclusive: `buildConnectorEntries` guarantees
+ * `data.connectors` is empty when `data.evses` is populated; never sum
+ * them. Also used by {@link iterateConnectors}.
+ * @param data Charging station snapshot.
+ * @returns Connector count under the active mode.
+ */
+const countConnectors = (data: ChargingStationData): number =>
+ data.connectors.length > 0
+ ? data.connectors.length
+ : data.evses.reduce((n, evse) => n + evse.evseStatus.connectors.size, 0)
+
+/**
+ * Iterate connectors under the same OCPP 1.6 vs OCPP 2.0.x source split as
+ * {@link countConnectors}: yields entries from `data.connectors` when
+ * non-empty, otherwise from `data.evses[*].evseStatus.connectors`. The two
+ * sources are mutually exclusive; never sum them.
+ * @param data Charging station snapshot.
+ * @yields {ConnectorEntry} A connector entry under the active mode.
+ */
+const iterateConnectors = function * (data: ChargingStationData): Generator<ConnectorEntry> {
+ if (data.connectors.length > 0) {
+ for (const entry of data.connectors) {
+ yield entry
+ }
+ return
+ }
+ for (const evse of data.evses) {
+ for (const [connectorId, connectorStatus] of evse.evseStatus.connectors) {
+ yield { connectorId, connectorStatus, evseId: evse.evseId }
+ }
+ }
+}
+
+type ConnectorOneHotLabel = 'availability' | 'connector_type' | 'error_code' | 'status'
+
+const addConnectorOneHot = (
+ registry: Registry,
+ account: (n: number) => void,
+ server: ChargingStationDataProvider,
+ name: string,
+ help: string,
+ labelName: ConnectorOneHotLabel,
+ pick: (cs: ConnectorStatus) => string | undefined
+): void => {
+ defineGauge<'connector_id' | 'hash_id' | ConnectorOneHotLabel>(registry, {
+ collect (this: Gauge<'connector_id' | 'hash_id' | ConnectorOneHotLabel>) {
+ this.reset()
+ for (const data of server.listChargingStationData()) {
+ for (const entry of iterateConnectors(data)) {
+ const v = pick(entry.connectorStatus)
+ if (typeof v === 'string') {
+ const labels: Partial<
+ Record<'connector_id' | 'hash_id' | ConnectorOneHotLabel, string>
+ > = {}
+ labels.hash_id = data.stationInfo.hashId
+ labels.connector_id = entry.connectorId.toString()
+ labels[labelName] = v
+ this.labels(labels).set(1)
+ account(1)
+ }
+ }
+ }
+ },
+ help,
+ labelNames: ['hash_id', 'connector_id', labelName],
+ name,
+ })
+}
+
+const addConnectorBoolean = (
+ registry: Registry,
+ account: (n: number) => void,
+ server: ChargingStationDataProvider,
+ name: string,
+ help: string,
+ pick: (cs: ConnectorStatus) => boolean
+): void => {
+ defineGauge(registry, {
+ collect (this: Gauge<'connector_id' | 'hash_id'>) {
+ this.reset()
+ for (const data of server.listChargingStationData()) {
+ for (const entry of iterateConnectors(data)) {
+ this.labels({
+ connector_id: entry.connectorId.toString(),
+ hash_id: data.stationInfo.hashId,
+ }).set(pick(entry.connectorStatus) ? 1 : 0)
+ account(1)
+ }
+ }
+ },
+ help,
+ labelNames: ['hash_id', 'connector_id'] as const,
+ name,
+ })
+}
+
+const addConnectorNumeric = (
+ registry: Registry,
+ account: (n: number) => void,
+ server: ChargingStationDataProvider,
+ name: string,
+ help: string,
+ pick: (cs: ConnectorStatus) => number | undefined
+): void => {
+ defineGauge(registry, {
+ collect (this: Gauge<'connector_id' | 'hash_id'>) {
+ this.reset()
+ for (const data of server.listChargingStationData()) {
+ for (const entry of iterateConnectors(data)) {
+ const v = pick(entry.connectorStatus)
+ if (typeof v === 'number') {
+ this.labels({
+ connector_id: entry.connectorId.toString(),
+ hash_id: data.stationInfo.hashId,
+ }).set(v)
+ account(1)
+ }
+ }
+ }
+ },
+ help,
+ labelNames: ['hash_id', 'connector_id'] as const,
+ name,
+ })
+}
+
+const addConnectorNumericFromEntry = (
+ registry: Registry,
+ account: (n: number) => void,
+ server: ChargingStationDataProvider,
+ name: string,
+ help: string,
+ pick: (entry: ConnectorEntry) => number | undefined
+): void => {
+ defineGauge(registry, {
+ collect (this: Gauge<'connector_id' | 'hash_id'>) {
+ this.reset()
+ for (const data of server.listChargingStationData()) {
+ for (const entry of iterateConnectors(data)) {
+ const v = pick(entry)
+ if (typeof v === 'number') {
+ this.labels({
+ connector_id: entry.connectorId.toString(),
+ hash_id: data.stationInfo.hashId,
+ }).set(v)
+ account(1)
+ }
+ }
+ }
+ },
+ help,
+ labelNames: ['hash_id', 'connector_id'] as const,
+ name,
+ })
+}
import { getReasonPhrase, StatusCodes } from 'http-status-codes'
import { createGzip } from 'node:zlib'
-import { Gauge, type GaugeConfiguration, Registry } from 'prom-client'
-import type { ChargingStationData, ConnectorEntry, ConnectorStatus } from '../../types/index.js'
import type { IBootstrap } from '../IBootstrap.js'
import { BaseError } from '../../exception/index.js'
const moduleName = 'UIHttpServer'
-/**
- * Soft cardinality cap for the Prometheus exposition. When a single scrape
- * emits more samples than this threshold, a single `logger.warn` is logged
- * and the response is still served in full. There is no truncation and no
- * scrape failure; the operator decides whether to disable the endpoint or
- * scale the threshold.
- */
-export const METRICS_SOFT_SAMPLE_CAP = 5_000
-
-const METRICS_PATHNAME = '/metrics'
-
-/**
- * Subset of {@link AbstractUIServer} consumed by the metrics gauge helpers
- * and by the inline `simulator_ui_server_known_stations_total` gauge in
- * {@link UIHttpServer.buildMetricsRegistry}. Restricting access to this
- * projection keeps the metrics surface decoupled from the concrete UI
- * server class and from `this`-rebound contexts inside `collect()`
- * callbacks.
- */
-interface ChargingStationDataProvider {
- getChargingStationsCount(): number
- listChargingStationData(): ChargingStationData[]
-}
-
/**
* @deprecated Use UIMCPServer (ApplicationProtocol.MCP) instead. Will be removed in a future major version.
*/
protected override readonly uiServerType = 'UI HTTP Server'
private readonly acceptsGzip: Map<UUIDv4, boolean>
- private metricsRegistry?: Registry
- /**
- * Per-scrape sample counter. Reset to 0 at the start of each scrape and
- * read after `Registry.metrics()` resolves. Mutated by the `accountSamples`
- * closure captured by every `collect()` callback in {@link buildMetricsRegistry}.
- * Concurrency safety relies on {@link metricsScrapeChain}: every scrape
- * (`reset → await Registry.metrics() → read`) runs as a single link in a
- * serial promise chain, so no two scrapes interleave their counter
- * mutations. Removing the chain or making any `collect()` callback `async`
- * breaks this guarantee.
- */
- private metricsSampleCount = 0
- private metricsScrapeChain: Promise<void> = Promise.resolve()
public constructor (
protected override readonly uiServerConfiguration: UIServerConfiguration,
}
/**
- * @deprecated Use UIMCPServer (ApplicationProtocol.MCP) instead. Will be removed in a future major version.
+ * @deprecated Use UIMCPServer (ApplicationProtocol.MCP) instead. Will be
+ * removed in a future major version.
*/
- public start (): void {
+ protected attachTransport (): void {
this.httpServer.on('request', this.requestListener.bind(this))
- if (
- this.uiServerConfiguration.metrics?.enabled === true &&
- this.metricsRegistry === undefined
- ) {
- this.metricsRegistry = this.buildMetricsRegistry()
- }
- this.startHttpServer()
- }
-
- /**
- * Stop the HTTP UI server and release any Prometheus registry held by
- * {@link metricsRegistry}. The registry clear is sequenced AFTER any
- * in-flight scrape via `metricsScrapeChain.finally`; calling
- * `registry.clear()` synchronously would race a running `collect()`
- * callback's `this.reset()`. The terminal `.catch(() => undefined)`
- * guarantees the chain field always points to a handled promise (no
- * `UnhandledPromiseRejection` if the last in-flight scrape rejected and
- * no further scrape is queued).
- */
- public override stop (): void {
- if (this.metricsRegistry !== undefined) {
- const registry = this.metricsRegistry
- this.metricsScrapeChain = this.metricsScrapeChain
- .finally(() => {
- registry.clear()
- })
- .catch(() => undefined)
- this.metricsRegistry = undefined
- }
- super.stop()
- }
-
- /**
- * Build the Prometheus `Registry` populated with every gauge exposed by
- * the simulator. Each gauge declares an explicit source field via its
- * `collect()` callback; there is no generic property iteration so adding
- * a new field on `ChargingStationData` does NOT silently expose it
- * (PII allowlist invariant). All `collect()` callbacks registered here
- * are synchronous; an async `collect` would let `prom-client` interleave
- * them within a single `Registry.metrics()` call, racing on
- * {@link metricsSampleCount}.
- * @returns The populated registry; `Registry.metrics()` renders the body.
- */
- private buildMetricsRegistry (): Registry {
- const registry = new Registry()
- const bootstrap = this.getBootstrap()
- const provider: ChargingStationDataProvider = {
- getChargingStationsCount: this.getChargingStationsCount.bind(this),
- listChargingStationData: this.listChargingStationData.bind(this),
- }
- const accountSamples = (n: number): void => {
- this.metricsSampleCount += n
- }
-
- /** Global gauges. */
-
- defineGauge(registry, {
- collect (this: Gauge<'version'>) {
- this.reset()
- this.labels({ version: bootstrap.getState().version }).set(1)
- accountSamples(1)
- },
- help: 'Simulator process information.',
- labelNames: ['version'] as const,
- name: 'simulator_info',
- })
-
- defineGauge(registry, {
- collect (this: Gauge) {
- this.reset()
- this.set(bootstrap.getState().started ? 1 : 0)
- accountSamples(1)
- },
- help: '1 when the simulator is started, 0 otherwise.',
- name: 'simulator_started',
- })
-
- defineGauge(registry, {
- collect (this: Gauge) {
- this.reset()
- this.set(bootstrap.getState().templateStatistics.size)
- accountSamples(1)
- },
- help: 'Number of charging station templates configured.',
- name: 'simulator_charging_station_templates_total',
- })
-
- // Aggregate counters across templates. Tuple shape: [metric name, key, help].
- // HELP text is preserved verbatim per public-API stability (Prometheus
- // exposition is observable to operators).
- const stationAggregates = [
- [
- 'simulator_charging_stations_configured_total',
- 'configured',
- 'Number of charging stations configured across all templates.',
- ],
- [
- 'simulator_charging_stations_provisioned_total',
- 'provisioned',
- 'Number of charging stations provisioned across all templates.',
- ],
- [
- 'simulator_charging_stations_added_total',
- 'added',
- 'Number of charging stations added in the current process.',
- ],
- [
- 'simulator_charging_stations_started_total',
- 'started',
- 'Number of charging stations currently started.',
- ],
- ] as const
- for (const [name, key, help] of stationAggregates) {
- defineGauge(registry, {
- collect (this: Gauge) {
- let total = 0
- for (const t of bootstrap.getState().templateStatistics.values()) {
- total += t[key]
- }
- this.reset()
- this.set(total)
- accountSamples(1)
- },
- help,
- name,
- })
- }
-
- defineGauge(registry, {
- collect (this: Gauge) {
- this.reset()
- this.set(provider.getChargingStationsCount())
- accountSamples(1)
- },
- help: 'Number of charging station snapshots cached on the UI server.',
- name: 'simulator_ui_server_known_stations_total',
- })
-
- for (const [name, key] of [
- ['simulator_template_added', 'added'],
- ['simulator_template_configured', 'configured'],
- ['simulator_template_provisioned', 'provisioned'],
- ['simulator_template_started', 'started'],
- ] as const) {
- defineGauge(registry, {
- collect (this: Gauge<'template'>) {
- this.reset()
- for (const [templateName, t] of bootstrap.getState().templateStatistics) {
- this.labels({ template: templateName }).set(t[key])
- accountSamples(1)
- }
- },
- help: `Per-template '${key}' charging stations counter.`,
- labelNames: ['template'] as const,
- name,
- })
- }
-
- /** Per charging station gauges. */
-
- defineGauge(registry, {
- collect (
- this: Gauge<
- 'current_out_type' | 'firmware_version' | 'hash_id' | 'model' | 'ocpp_version' | 'vendor'
- >
- ) {
- this.reset()
- for (const data of provider.listChargingStationData()) {
- this.labels({
- current_out_type: stringLabel(data.stationInfo.currentOutType),
- firmware_version: stringLabel(data.stationInfo.firmwareVersion),
- hash_id: data.stationInfo.hashId,
- model: stringLabel(data.stationInfo.chargePointModel),
- ocpp_version: stringLabel(data.stationInfo.ocppVersion),
- vendor: stringLabel(data.stationInfo.chargePointVendor),
- }).set(1)
- accountSamples(1)
- }
- },
- help: 'Static information for the charging station (vendor / model / firmware / ocpp).',
- labelNames: [
- 'hash_id',
- 'vendor',
- 'model',
- 'firmware_version',
- 'ocpp_version',
- 'current_out_type',
- ] as const,
- name: 'simulator_station_info',
- })
-
- addPerStationBoolean(
- registry,
- accountSamples,
- provider,
- 'simulator_station_started',
- '1 when the charging station is started, 0 otherwise.',
- data => data.started
- )
-
- addPerStationNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_station_ws_state',
- 'WebSocket readyState (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED).',
- data => data.wsState
- )
-
- addPerStationNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_station_connectors_total',
- 'Number of connectors of the charging station.',
- countConnectors
- )
-
- addPerStationNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_station_evses_total',
- 'Number of EVSEs of the charging station.',
- data => data.evses.length
- )
-
- addPerStationNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_station_max_power_watts',
- 'Maximum power of the charging station, in Watts.',
- data => data.stationInfo.maximumPower
- )
-
- addPerStationNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_station_max_amperage_amperes',
- 'Maximum amperage of the charging station, in Amperes.',
- data => data.stationInfo.maximumAmperage
- )
-
- addPerStationNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_station_voltage_out_volts',
- 'Voltage output of the charging station, in Volts.',
- data => data.stationInfo.voltageOut
- )
-
- addPerStationNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_station_data_timestamp_seconds',
- 'Unix epoch (seconds) at which the charging station snapshot was emitted.',
- data => Math.floor(data.timestamp / 1000)
- )
-
- addPerStationStatusInfo(
- registry,
- accountSamples,
- provider,
- 'simulator_station_boot_status_info',
- 'BootNotification status (one-hot).',
- data => data.bootNotificationResponse?.status
- )
-
- addPerStationNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_station_boot_heartbeat_interval_seconds',
- 'BootNotification heartbeat interval, in seconds.',
- data => data.bootNotificationResponse?.interval
- )
-
- addPerStationBoolean(
- registry,
- accountSamples,
- provider,
- 'simulator_station_atg_enabled',
- '1 when the ATG is enabled in configuration, 0 otherwise.',
- data => data.automaticTransactionGenerator?.automaticTransactionGenerator?.enable === true
- )
-
- addPerStationStatusInfo(
- registry,
- accountSamples,
- provider,
- 'simulator_station_diagnostics_status_info',
- 'Most recent DiagnosticsStatusNotification status (one-hot).',
- data => data.stationInfo.diagnosticsStatus
- )
-
- addPerStationStatusInfo(
- registry,
- accountSamples,
- provider,
- 'simulator_station_firmware_status_info',
- 'Most recent FirmwareStatusNotification status (one-hot).',
- data => data.stationInfo.firmwareStatus
- )
-
- addPerStationNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_station_ocpp_config_keys_total',
- 'Number of OCPP configuration keys advertised by the charging station.',
- data => data.ocppConfiguration.configurationKey?.length ?? 0
- )
-
- /** Per connector gauges. */
-
- addConnectorOneHot(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_status_info',
- 'Connector status (one-hot).',
- 'status',
- cs => cs.status
- )
- addConnectorOneHot(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_boot_status_info',
- 'Connector boot status (one-hot).',
- 'status',
- cs => cs.bootStatus
- )
- addConnectorOneHot(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_availability_info',
- 'Connector availability (one-hot).',
- 'availability',
- cs => cs.availability
- )
- addConnectorOneHot(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_error_code_info',
- 'Connector OCPP error code (one-hot).',
- 'error_code',
- cs => cs.errorCode
- )
- addConnectorOneHot(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_type_info',
- 'Connector physical type (one-hot).',
- 'connector_type',
- cs => cs.type
- )
-
- addConnectorBoolean(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_locked',
- '1 when the connector is locked, 0 otherwise.',
- cs => cs.locked === true
- )
- addConnectorBoolean(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_transaction_started',
- '1 when a transaction is currently started on the connector.',
- cs => cs.transactionStarted === true
- )
- addConnectorBoolean(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_transaction_pending',
- '1 when a transaction is pending on the connector.',
- cs => cs.transactionPending === true
- )
- addConnectorBoolean(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_transaction_remote_started',
- '1 when the current transaction was remote-started.',
- cs => cs.transactionRemoteStarted === true
- )
- addConnectorBoolean(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_reservation_active',
- '1 when an active reservation is set on the connector.',
- cs => cs.reservation != null
- )
-
- addConnectorNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_transaction_seq_no',
- 'Last transaction event sequence number sent on the connector.',
- cs => cs.transactionSeqNo
- )
- addConnectorNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_transaction_event_queue_size',
- 'Number of pending transaction events queued on the connector.',
- cs => cs.transactionEventQueue?.length ?? 0
- )
- addConnectorNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_transaction_id',
- 'Numeric transaction id of the active transaction on the connector. NEVER used as a label (cardinality).',
- cs => (typeof cs.transactionId === 'number' ? cs.transactionId : undefined)
- )
- addConnectorNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_transaction_start_seconds',
- 'Unix epoch (seconds) at which the active transaction started on the connector.',
- cs =>
- cs.transactionStart != null ? Math.floor(cs.transactionStart.getTime() / 1000) : undefined
- )
- addConnectorNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_transaction_energy_active_import_register_wh',
- 'Active energy imported during the current transaction, in Wh.',
- cs => cs.transactionEnergyActiveImportRegisterValue
- )
- addConnectorNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_energy_active_import_register_wh',
- 'Cumulative active energy imported by the connector meter, in Wh.',
- cs => cs.energyActiveImportRegisterValue
- )
- addConnectorNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_max_power_watts',
- 'Maximum power of the connector, in Watts.',
- cs => cs.maximumPower
- )
- addConnectorNumeric(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_charging_profiles_total',
- 'Number of charging profiles installed on the connector.',
- cs => cs.chargingProfiles?.length ?? 0
- )
- addConnectorNumericFromEntry(
- registry,
- accountSamples,
- provider,
- 'simulator_connector_evse_id',
- 'EVSE id the connector belongs to.',
- entry => entry.evseId
- )
-
- return registry
- }
-
- /**
- * Schedule a `/metrics` scrape onto {@link metricsScrapeChain} so concurrent
- * scrape requests serialize through a single FIFO chain (preserves the
- * {@link metricsSampleCount} invariant). The function itself is synchronous —
- * the inner async work runs in a `.then()` continuation and rejections
- * propagate to the returned promise, which the listener-side `.catch()`
- * converts to HTTP 500.
- * @param res HTTP response to end with the exposition body.
- * @param registry Source Prometheus registry.
- * @returns The chained scrape promise.
- */
- private handleMetricsRequest (res: ServerResponse, registry: Registry): Promise<void> {
- this.metricsScrapeChain = this.metricsScrapeChain
- .catch(() => undefined)
- .then(async () => {
- this.metricsSampleCount = 0
- const body = await registry.metrics()
- const cap = this.uiServerConfiguration.metrics?.softSampleCap ?? METRICS_SOFT_SAMPLE_CAP
- if (this.metricsSampleCount > cap) {
- logger.warn(
- `${this.logPrefix(moduleName, 'handleMetricsRequest')} ` +
- `Prometheus scrape produced ${this.metricsSampleCount.toString()} samples ` +
- `(soft cap ${cap.toString()})`
- )
- }
- if (!res.headersSent && !res.writableEnded) {
- res
- .writeHead(StatusCodes.OK, {
- 'Content-Type': registry.contentType,
- })
- .end(body)
- }
- return undefined
- })
- return this.metricsScrapeChain
}
private async handleRequestBody (
}
}
- private isMetricsRequest (req: IncomingMessage): boolean {
- if (req.method !== HttpMethod.GET && req.method !== HttpMethod.HEAD) {
- return false
- }
- const rawUrl = req.url ?? ''
- try {
- const { pathname } = new URL(rawUrl, 'http://localhost')
- return pathname === METRICS_PATHNAME
- } catch {
- return false
- }
- }
-
private requestListener (req: IncomingMessage, res: ServerResponse): void {
const prologue = this.runRequestPrologue(req)
if (!prologue.ok) {
this.renderDenial(res, prologue)
return
}
- if (!this.authenticate(req)) {
- this.renderDenial(res, this.getUnauthorizedDenial())
+ if (this.tryServeMetrics(req, res)) {
return
}
-
- if (this.metricsRegistry !== undefined && this.isMetricsRequest(req)) {
- const registry = this.metricsRegistry
- this.handleMetricsRequest(res, registry).catch((error: unknown) => {
- logger.error(
- `${this.logPrefix(moduleName, 'requestListener.metrics')} Metrics handler error:`,
- error
- )
- if (!res.headersSent) {
- res.writeHead(StatusCodes.INTERNAL_SERVER_ERROR, { 'Content-Type': 'text/plain' }).end()
- }
- })
+ if (!this.authenticate(req)) {
+ this.renderDenial(res, this.getUnauthorizedDenial())
return
}
}
}
}
-
-/**
- * Construct and register a Prometheus `Gauge` whose `labelNames` are narrowed
- * to a string-literal union via `as const`. The returned reference is owned
- * by `registry` (lifecycle managed by `Registry.clear()`); callers may ignore
- * it because each gauge's `collect()` callback receives the gauge as its
- * `this` binding. The `L = never` default (stricter than `prom-client`'s own
- * `Gauge<T extends string = string>`) forbids passing `labelNames` for
- * unlabeled gauges, catching mismatches at compile time.
- * @param registry Destination registry; auto-injected into `registers`.
- * @param config Gauge configuration WITHOUT `registers`.
- * @returns The constructed `Gauge<L>`.
- */
-const defineGauge = <L extends string = never>(
- registry: Registry,
- config: Omit<GaugeConfiguration<L>, 'registers'>
-): Gauge<L> => new Gauge<L>({ ...config, registers: [registry] })
-
-const stringLabel = (value: string | undefined): string => value ?? ''
-
-const addPerStationNumeric = (
- registry: Registry,
- account: (n: number) => void,
- server: ChargingStationDataProvider,
- name: string,
- help: string,
- pick: (data: ChargingStationData) => number | undefined
-): void => {
- defineGauge(registry, {
- collect (this: Gauge<'hash_id'>) {
- this.reset()
- for (const data of server.listChargingStationData()) {
- const v = pick(data)
- if (typeof v === 'number') {
- this.labels({ hash_id: data.stationInfo.hashId }).set(v)
- account(1)
- }
- }
- },
- help,
- labelNames: ['hash_id'] as const,
- name,
- })
-}
-
-const addPerStationBoolean = (
- registry: Registry,
- account: (n: number) => void,
- server: ChargingStationDataProvider,
- name: string,
- help: string,
- pick: (data: ChargingStationData) => boolean
-): void => {
- defineGauge(registry, {
- collect (this: Gauge<'hash_id'>) {
- this.reset()
- for (const data of server.listChargingStationData()) {
- this.labels({ hash_id: data.stationInfo.hashId }).set(pick(data) ? 1 : 0)
- account(1)
- }
- },
- help,
- labelNames: ['hash_id'] as const,
- name,
- })
-}
-
-const addPerStationStatusInfo = (
- registry: Registry,
- account: (n: number) => void,
- server: ChargingStationDataProvider,
- name: string,
- help: string,
- pick: (data: ChargingStationData) => string | undefined
-): void => {
- defineGauge(registry, {
- collect (this: Gauge<'hash_id' | 'status'>) {
- this.reset()
- for (const data of server.listChargingStationData()) {
- const v = pick(data)
- if (typeof v === 'string') {
- this.labels({ hash_id: data.stationInfo.hashId, status: v }).set(1)
- account(1)
- }
- }
- },
- help,
- labelNames: ['hash_id', 'status'] as const,
- name,
- })
-}
-
-/**
- * Connector count under the OCPP 1.6 (`data.connectors`) vs OCPP 2.0.x
- * (`data.evses[*].evseStatus.connectors`) source split. The two sources
- * are mutually exclusive: `buildConnectorEntries` guarantees
- * `data.connectors` is empty when `data.evses` is populated; never sum
- * them. Also used by {@link iterateConnectors}.
- * @param data Charging station snapshot.
- * @returns Connector count under the active mode.
- */
-const countConnectors = (data: ChargingStationData): number =>
- data.connectors.length > 0
- ? data.connectors.length
- : data.evses.reduce((n, evse) => n + evse.evseStatus.connectors.size, 0)
-
-/**
- * Iterate connectors under the same OCPP 1.6 vs OCPP 2.0.x source split as
- * {@link countConnectors}: yields entries from `data.connectors` when
- * non-empty, otherwise from `data.evses[*].evseStatus.connectors`. The two
- * sources are mutually exclusive; never sum them.
- * @param data Charging station snapshot.
- * @yields {ConnectorEntry} A connector entry under the active mode.
- */
-const iterateConnectors = function * (data: ChargingStationData): Generator<ConnectorEntry> {
- if (data.connectors.length > 0) {
- for (const entry of data.connectors) {
- yield entry
- }
- return
- }
- for (const evse of data.evses) {
- for (const [connectorId, connectorStatus] of evse.evseStatus.connectors) {
- yield { connectorId, connectorStatus, evseId: evse.evseId }
- }
- }
-}
-
-type ConnectorOneHotLabel = 'availability' | 'connector_type' | 'error_code' | 'status'
-
-const addConnectorOneHot = (
- registry: Registry,
- account: (n: number) => void,
- server: ChargingStationDataProvider,
- name: string,
- help: string,
- labelName: ConnectorOneHotLabel,
- pick: (cs: ConnectorStatus) => string | undefined
-): void => {
- defineGauge<'connector_id' | 'hash_id' | ConnectorOneHotLabel>(registry, {
- collect (this: Gauge<'connector_id' | 'hash_id' | ConnectorOneHotLabel>) {
- this.reset()
- for (const data of server.listChargingStationData()) {
- for (const entry of iterateConnectors(data)) {
- const v = pick(entry.connectorStatus)
- if (typeof v === 'string') {
- const labels: Partial<
- Record<'connector_id' | 'hash_id' | ConnectorOneHotLabel, string>
- > = {}
- labels.hash_id = data.stationInfo.hashId
- labels.connector_id = entry.connectorId.toString()
- labels[labelName] = v
- this.labels(labels).set(1)
- account(1)
- }
- }
- }
- },
- help,
- labelNames: ['hash_id', 'connector_id', labelName],
- name,
- })
-}
-
-const addConnectorBoolean = (
- registry: Registry,
- account: (n: number) => void,
- server: ChargingStationDataProvider,
- name: string,
- help: string,
- pick: (cs: ConnectorStatus) => boolean
-): void => {
- defineGauge(registry, {
- collect (this: Gauge<'connector_id' | 'hash_id'>) {
- this.reset()
- for (const data of server.listChargingStationData()) {
- for (const entry of iterateConnectors(data)) {
- this.labels({
- connector_id: entry.connectorId.toString(),
- hash_id: data.stationInfo.hashId,
- }).set(pick(entry.connectorStatus) ? 1 : 0)
- account(1)
- }
- }
- },
- help,
- labelNames: ['hash_id', 'connector_id'] as const,
- name,
- })
-}
-
-const addConnectorNumeric = (
- registry: Registry,
- account: (n: number) => void,
- server: ChargingStationDataProvider,
- name: string,
- help: string,
- pick: (cs: ConnectorStatus) => number | undefined
-): void => {
- defineGauge(registry, {
- collect (this: Gauge<'connector_id' | 'hash_id'>) {
- this.reset()
- for (const data of server.listChargingStationData()) {
- for (const entry of iterateConnectors(data)) {
- const v = pick(entry.connectorStatus)
- if (typeof v === 'number') {
- this.labels({
- connector_id: entry.connectorId.toString(),
- hash_id: data.stationInfo.hashId,
- }).set(v)
- account(1)
- }
- }
- }
- },
- help,
- labelNames: ['hash_id', 'connector_id'] as const,
- name,
- })
-}
-
-const addConnectorNumericFromEntry = (
- registry: Registry,
- account: (n: number) => void,
- server: ChargingStationDataProvider,
- name: string,
- help: string,
- pick: (entry: ConnectorEntry) => number | undefined
-): void => {
- defineGauge(registry, {
- collect (this: Gauge<'connector_id' | 'hash_id'>) {
- this.reset()
- for (const data of server.listChargingStationData()) {
- for (const entry of iterateConnectors(data)) {
- const v = pick(entry)
- if (typeof v === 'number') {
- this.labels({
- connector_id: entry.connectorId.toString(),
- hash_id: data.stationInfo.hashId,
- }).set(v)
- account(1)
- }
- }
- }
- },
- help,
- labelNames: ['hash_id', 'connector_id'] as const,
- name,
- })
-}
}
}
- public start (): void {
+ public override stop (): void {
+ for (const [uuid, pending] of [...this.pendingMcpRequests]) {
+ clearTimeout(pending.timeout)
+ this.pendingMcpRequests.delete(uuid)
+ pending.reject(new BaseError('Server stopping'))
+ }
+ super.stop()
+ }
+
+ protected attachTransport (): void {
const version = ProtocolVersion['0.0.1']
this.registerProtocolVersionUIService(version)
this.service = this.uiServices.get(version)
this.renderDenial(res, prologue)
return
}
-
+ if (this.tryServeMetrics(req, res)) {
+ return
+ }
const url = new URL(req.url ?? '/', 'http://localhost')
- // Path filter runs before authenticate so unknown paths return 404
- // without revealing whether authentication would have succeeded.
if (url.pathname !== '/mcp') {
- this.renderDenial(res, {
- reasonPhrase: getReasonPhrase(StatusCodes.NOT_FOUND),
- status: StatusCodes.NOT_FOUND,
- })
- if (!req.complete) {
- req.destroy()
- }
+ this.renderNotFoundAndDestroy(req, res)
return
}
)
})
})
-
- this.startHttpServer()
- }
-
- public override stop (): void {
- for (const [uuid, pending] of [...this.pendingMcpRequests]) {
- clearTimeout(pending.timeout)
- this.pendingMcpRequests.delete(uuid)
- pending.reject(new BaseError('Server stopping'))
- }
- super.stop()
}
protected getSchemaBaseDir (): string {
-import type { IncomingMessage } from 'node:http'
+import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Duplex } from 'node:stream'
import { getReasonPhrase, StatusCodes } from 'http-status-codes'
}
}
- public start (): void {
+ protected attachTransport (): void {
this.webSocketServer.on('connection', (ws: WebSocket, _req: IncomingMessage): void => {
const protocol = ws.protocol
const protocolAndVersion = getProtocolAndVersion(protocol)
}
})
})
+ this.httpServer.on('request', (req: IncomingMessage, res: ServerResponse): void => {
+ const prologue = this.runRequestPrologue(req)
+ if (!prologue.ok) {
+ this.renderDenial(res, prologue)
+ return
+ }
+ if (this.tryServeMetrics(req, res)) {
+ return
+ }
+ this.renderNotFoundAndDestroy(req, res)
+ })
this.httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
const onSocketError = (error: Error): void => {
logger.error(
)
}
})
- this.startHttpServer()
+ }
+
+ /**
+ * Symmetric inverse of {@link attachTransport}. The `connection` listener
+ * is registered on the constructor-scoped `webSocketServer` emitter, which
+ * outlives `httpServer` and is therefore NOT swept by `stopHttpServer()`'s
+ * `removeAllListeners()`. Strip it here so a `stop()` → `start()` cycle
+ * does not accumulate duplicate dispatchers.
+ */
+ protected override detachTransport (): void {
+ this.webSocketServer.removeAllListeners('connection')
}
protected override notifyClients (): void {
/**
* UIServerMetricsConfiguration — opt-in Prometheus /metrics endpoint
- * served by `UIHttpServer`. Honored only when the parent UI server is
- * running on the HTTP transport (Prometheus is HTTP-only by spec).
+ * served by every UI transport (`http`, `ws`, `mcp`). On `ws` and `mcp`
+ * the endpoint co-mounts on the same listener as the primary transport
+ * (the underlying `Http2Server | Server` allocated by `AbstractUIServer`)
+ * and inherits its `accessPolicy`, rate-limit, and `authentication`
+ * (issue #1917).
*
* `softSampleCap` (optional, default `METRICS_SOFT_SAMPLE_CAP` = 5000)
* is the soft cardinality cap above which a single `logger.warn` is
const res = new MockServerResponse()
try {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
gatedServer.start()
httpServer.emit('request', req, res)
} finally {
const res = new MockServerResponse()
try {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
gatedServer.start()
httpServer.emit('request', denyingReq, res)
} finally {
import assert from 'node:assert/strict'
import { dirname, join } from 'node:path'
import { Readable } from 'node:stream'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, type mock } from 'node:test'
import { fileURLToPath } from 'node:url'
import type {
ProtocolResponse,
RequestPayload,
ResponsePayload,
+ TemplateStatistics,
UIServerConfiguration,
} from '../../../src/types/index.js'
} from '../../../src/charging-station/ui-server/UIServerSecurity.js'
import {
ApplicationProtocol,
+ AuthenticationType,
OCPPVersion,
ProcedureName,
ResponseStatus,
} from '../../helpers/TestLifecycleHelpers.js'
import { TEST_HASH_ID, TEST_HASH_ID_2, TEST_UUID, TEST_UUID_2 } from './UIServerTestConstants.js'
import {
+ awaitFinish,
createMockBootstrap,
createMockChargingStationDataWithVersion,
createMockIncomingMessage,
createMockUIServerConfiguration,
createMockUIServerConfigurationWithAuth,
+ drainResponses,
+ extractGaugeValue,
MockServerResponse,
} from './UIServerTestUtils.js'
).call(this, res, statusCode, headers)
}
+ public emitRequest (req: IncomingMessage, res: MockServerResponse): void {
+ const httpServer = Reflect.get(this, 'httpServer') as {
+ emit: (eventName: string, req: IncomingMessage, res: MockServerResponse) => boolean
+ }
+ httpServer.emit('request', req, res)
+ }
+
public getPendingMcpRequest (uuid: string):
| undefined
| {
return (Reflect.get(this, 'pendingMcpRequests') as Map<string, unknown>).size
}
+ public mockListen (t: { mock: { method: typeof mock.method } }): void {
+ const httpServer = Reflect.get(this, 'httpServer') as object
+ t.mock.method(httpServer as never, 'listen' as never, ((): unknown => httpServer) as never)
+ }
+
protected override getSchemaBaseDir (): string {
return join(
dirname(fileURLToPath(import.meta.url)),
const createMcpServerConfig = () =>
createMockUIServerConfiguration({ type: ApplicationProtocol.MCP })
+const createMcpMetricsConfig = (
+ overrides: Partial<UIServerConfiguration> = {}
+): UIServerConfiguration =>
+ createMockUIServerConfiguration({
+ metrics: { enabled: true },
+ options: { host: '127.0.0.1', port: 0 },
+ type: ApplicationProtocol.MCP,
+ ...overrides,
+ })
+
+const buildMcpMetricsRequest = (overrides: Partial<IncomingMessage> = {}): IncomingMessage =>
+ createMockIncomingMessage({
+ complete: true,
+ headers: { host: 'localhost' },
+ method: 'GET',
+ socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+ url: '/metrics',
+ ...overrides,
+ })
+
+const enrichBootstrapForMetrics = (server: TestableUIMCPServer, version = '4.9.0'): void => {
+ const bootstrap = server.getBootstrap()
+ const templateStats: TemplateStatistics = {
+ added: 1,
+ configured: 5,
+ indexes: new Set([0]),
+ provisioned: 2,
+ started: 1,
+ }
+ Reflect.set(bootstrap, 'getState', () => ({
+ configuration: undefined,
+ started: true,
+ templateStatistics: new Map<string, TemplateStatistics>([['test-template', templateStats]]),
+ version,
+ }))
+}
+
/**
* Assert that a CallToolResult is an error containing the expected substring.
* @param result - MCP tool result to validate
}
})
})
+
+ await describe('metrics endpoint when uiServer.type=mcp (issue #1917)', async () => {
+ await it('should serve Prometheus exposition on GET /metrics when enabled', async t => {
+ const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig())
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(buildMcpMetricsRequest(), res)
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 200)
+ assert.match(res.headers['Content-Type'] ?? '', /^text\/plain;\s*version=0\.0\.4/)
+ assert.strictEqual(
+ extractGaugeValue(res.body ?? '', 'simulator_started'),
+ 1,
+ 'simulator_started must be 1 from the enrichBootstrapForMetrics fixture'
+ )
+ assert.match(res.body ?? '', /^# HELP simulator_started /m)
+ assert.match(res.body ?? '', /^# TYPE simulator_started gauge$/m)
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should return 404 on GET /metrics when metrics.enabled=false (default)', t => {
+ const mcpServer = new TestableUIMCPServer(
+ createMockUIServerConfiguration({
+ options: { host: '127.0.0.1', port: 0 },
+ type: ApplicationProtocol.MCP,
+ })
+ )
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(buildMcpMetricsRequest(), res)
+ assert.strictEqual(res.statusCode, 404)
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should return 404 on POST /metrics (transport-method parity with HTTP/WS)', t => {
+ const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig())
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(buildMcpMetricsRequest({ method: 'POST' }), res)
+ assert.strictEqual(res.statusCode, 404)
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should still return 404 on unknown paths after /metrics is enabled', t => {
+ const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig())
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(
+ buildMcpMetricsRequest({
+ method: 'GET',
+ url: '/unknown',
+ }),
+ res
+ )
+ assert.strictEqual(res.statusCode, 404)
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should inherit AccessPolicy denial — 403 on non-loopback without TLS', t => {
+ const mcpServer = new TestableUIMCPServer(
+ createMcpMetricsConfig({
+ accessPolicy: {
+ allowedHosts: ['gateway.example.com'],
+ allowedOrigins: [],
+ allowLoopbackProxy: false,
+ requireTlsForNonLoopback: true,
+ trustedProxies: [],
+ },
+ })
+ )
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(
+ buildMcpMetricsRequest({
+ headers: { host: 'gateway.example.com' },
+ socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never,
+ }),
+ res
+ )
+ assert.strictEqual(res.statusCode, 403)
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should return 401 without credentials when authentication is enabled', t => {
+ const mcpServer = new TestableUIMCPServer(
+ createMcpMetricsConfig({
+ authentication: {
+ enabled: true,
+ password: 'pw',
+ type: AuthenticationType.BASIC_AUTH,
+ username: 'user',
+ },
+ })
+ )
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(buildMcpMetricsRequest(), res)
+ assert.strictEqual(res.statusCode, 401)
+ assert.strictEqual(res.headers['WWW-Authenticate'], 'Basic realm=users')
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should return 200 with valid Basic Auth credentials', async t => {
+ const mcpServer = new TestableUIMCPServer(
+ createMcpMetricsConfig({
+ authentication: {
+ enabled: true,
+ password: 'pw',
+ type: AuthenticationType.BASIC_AUTH,
+ username: 'user',
+ },
+ })
+ )
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const credentials = Buffer.from('user:pw').toString('base64')
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(
+ buildMcpMetricsRequest({
+ headers: { authorization: `Basic ${credentials}`, host: 'localhost' },
+ }),
+ res
+ )
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 200)
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should return 404 (not 401) on GET /metrics when metrics.enabled=false and authentication.enabled=true', t => {
+ const mcpServer = new TestableUIMCPServer(
+ createMockUIServerConfiguration({
+ authentication: {
+ enabled: true,
+ password: 'pw',
+ type: AuthenticationType.BASIC_AUTH,
+ username: 'user',
+ },
+ options: { host: '127.0.0.1', port: 0 },
+ type: ApplicationProtocol.MCP,
+ })
+ )
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(buildMcpMetricsRequest(), res)
+ assert.strictEqual(res.statusCode, 404)
+ assert.strictEqual(res.headers['WWW-Authenticate'], undefined)
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should respond 200 with empty body on HEAD /metrics per RFC 9110 §9.3.2', async t => {
+ const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig())
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(buildMcpMetricsRequest({ method: 'HEAD' }), res)
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 200)
+ assert.match(res.headers['Content-Type'] ?? '', /^text\/plain;\s*version=0\.0\.4/)
+ assert.strictEqual(res.body, undefined, 'HEAD response body must be empty')
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should emit explicit Content-Length on both HEAD and GET /metrics (RFC 9110 §9.3.2 parity)', async t => {
+ const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig())
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ const getRes = new MockServerResponse()
+ const headRes = new MockServerResponse()
+ mcpServer.emitRequest(buildMcpMetricsRequest(), getRes)
+ mcpServer.emitRequest(buildMcpMetricsRequest({ method: 'HEAD' }), headRes)
+ await drainResponses([getRes, headRes])
+ const getLen = Number(getRes.headers['Content-Length'])
+ const headLen = Number(headRes.headers['Content-Length'])
+ assert.strictEqual(
+ getLen,
+ Buffer.byteLength(getRes.body ?? '', 'utf8'),
+ 'GET Content-Length must equal body byte length'
+ )
+ assert.strictEqual(
+ headLen,
+ getLen,
+ 'HEAD Content-Length must equal GET length (RFC 9110 §9.3.2)'
+ )
+ assert.strictEqual(headRes.body, undefined, 'HEAD body must be empty')
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should NOT call req.destroy() on the 404 fallback when req.httpVersionMajor >= 2 (HTTP/2 stream lifecycle)', t => {
+ const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig())
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ let destroyCount = 0
+ const req = buildMcpMetricsRequest({ complete: false, url: '/unknown' })
+ ;(
+ req as IncomingMessage & {
+ destroy: () => IncomingMessage
+ httpVersionMajor: number
+ }
+ ).destroy = () => {
+ destroyCount++
+ return req
+ }
+ ;(req as IncomingMessage & { httpVersionMajor: number }).httpVersionMajor = 2
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(req, res)
+ assert.strictEqual(res.statusCode, 404, 'response still rendered first')
+ assert.strictEqual(
+ destroyCount,
+ 0,
+ 'HTTP/2 streams must not be destroyed by renderNotFoundAndDestroy (stream lifecycle is owned by the Http2Stream)'
+ )
+ } finally {
+ mcpServer.stop()
+ }
+ })
+
+ await it('should NOT call req.destroy() on the 404 fallback when req.httpVersionMajor === 3 (HTTP/3 future-proofing)', t => {
+ const mcpServer = new TestableUIMCPServer(createMcpMetricsConfig())
+ enrichBootstrapForMetrics(mcpServer)
+ mcpServer.mockListen(t)
+ try {
+ mcpServer.start()
+ let destroyCount = 0
+ const req = buildMcpMetricsRequest({ complete: false, url: '/unknown' })
+ ;(
+ req as IncomingMessage & {
+ destroy: () => IncomingMessage
+ httpVersionMajor: number
+ }
+ ).destroy = () => {
+ destroyCount++
+ return req
+ }
+ ;(req as IncomingMessage & { httpVersionMajor: number }).httpVersionMajor = 3
+ const res = new MockServerResponse()
+ mcpServer.emitRequest(req, res)
+ assert.strictEqual(res.statusCode, 404)
+ assert.strictEqual(
+ destroyCount,
+ 0,
+ 'destroyHttp1SocketIfPending must short-circuit on httpVersionMajor >= 2 (locks the `>= 2` semantic against a future `=== 2` regression)'
+ )
+ } finally {
+ mcpServer.stop()
+ }
+ })
+ })
})
* @description End-to-end behavior, security inheritance, PII reject-list, exposition-format escaping and the cardinality soft cap warning.
*/
-import type { IncomingMessage } from 'node:http'
+import type { IncomingMessage, Server } from 'node:http'
import type { mock } from 'node:test'
+import type { Registry } from 'prom-client'
import assert from 'node:assert/strict'
import { once } from 'node:events'
UIServerConfiguration,
} from '../../../src/types/index.js'
-import { AbstractUIServer } from '../../../src/charging-station/ui-server/AbstractUIServer.js'
import {
+ AbstractUIServer,
+ isMetricsAllowedLabelName,
+ METRICS_ALLOWED_LABEL_NAMES,
+ METRICS_SOFT_CAP_WARN_PREFIX,
METRICS_SOFT_SAMPLE_CAP,
- UIHttpServer,
-} from '../../../src/charging-station/ui-server/UIHttpServer.js'
+} from '../../../src/charging-station/ui-server/AbstractUIServer.js'
+import { UIHttpServer } from '../../../src/charging-station/ui-server/UIHttpServer.js'
import { UIWebSocketServer } from '../../../src/charging-station/ui-server/UIWebSocketServer.js'
+import { BaseError } from '../../../src/exception/index.js'
import {
ApplicationProtocol,
AuthenticationType,
httpServer.emit('request', req, res)
}
- public getMetricsRegistry (): unknown {
- return Reflect.get(this, 'metricsRegistry')
+ public override getMetricsRegistry (): Registry | undefined {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ return super.getMetricsRegistry()
}
public mockListen (t: { mock: { method: typeof mock.method } }): void {
...overrides,
})
+const populateLiveState = (
+ server: TestableUIHttpServer
+): { uiSvc: { stop: () => void; stopCalled: number } } => {
+ const uiSvc = {
+ stop (): void {
+ this.stopCalled++
+ },
+ stopCalled: 0,
+ }
+ ;(Reflect.get(server, 'uiServices') as Map<unknown, unknown>).set('1.1', uiSvc)
+ ;(Reflect.get(server, 'responseHandlers') as Map<unknown, unknown>).set('uuid-probe', {})
+ ;(Reflect.get(server, 'chargingStations') as Map<string, unknown>).set('h-probe', {
+ hashId: 'h-probe',
+ })
+ ;(Reflect.get(server, 'chargingStationTemplates') as Set<string>).add('tpl-probe')
+ return { uiSvc }
+}
+
await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
let server: TestableUIHttpServer
await it('should serve Prometheus exposition on GET /metrics when enabled', async t => {
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
enrichBootstrap(plainServer)
plainServer.mockListen(t)
try {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
plainServer.start()
const res = new MockServerResponse()
plainServer.emitRequest(buildMetricsRequest(), res)
enrichBootstrap(offServer)
offServer.mockListen(t)
try {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
offServer.start()
const res = new MockServerResponse()
offServer.emitRequest(buildMetricsRequest(), res)
await it('should serve global gauges from Bootstrap.getState().templateStatistics', async t => {
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
await it('should serve per-station gauges from chargingStations Map', async t => {
server.addStation(buildStationData('station-T5'))
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
await it('should serve per-connector status_info one-hot', async t => {
server.addStation(buildStationData('station-T6'))
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
await it('should reject POST /metrics with non-200 (existing 400 path)', t => {
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest({ method: 'POST' }), res)
enrichBootstrap(gatedServer)
gatedServer.mockListen(t)
try {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
gatedServer.start()
const res = new MockServerResponse()
gatedServer.emitRequest(
await it('should inherit rate-limit — eventual 429 on burst', t => {
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const statuses: (number | undefined)[] = []
for (let i = 0; i < 200; i++) {
enrichBootstrap(authServer)
authServer.mockListen(t)
try {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
authServer.start()
const res = new MockServerResponse()
authServer.emitRequest(buildMetricsRequest(), res)
enrichBootstrap(authServer)
authServer.mockListen(t)
try {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
authServer.start()
const credentials = Buffer.from('user:pw').toString('base64')
const res = new MockServerResponse()
})
)
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
})
)
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
await it('should not register a UUID in responseHandlers', t => {
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
await it('should clear registry on stop()', t => {
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
server.stop()
assert.strictEqual(server.getMetricsRegistry(), undefined)
}
const warnSpy = t.mock.method(logger, 'warn', () => undefined)
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
assert.strictEqual(res.statusCode, 200)
const matchingCalls = warnSpy.mock.calls.filter(call => {
const message: unknown = call.arguments[0]
- return typeof message === 'string' && message.includes('soft cap')
+ return typeof message === 'string' && message.includes(METRICS_SOFT_CAP_WARN_PREFIX)
})
assert.ok(
- matchingCalls.length >= 1,
+ matchingCalls.length > 0,
`Expected at least one logger.warn 'soft cap' call after ${stationCount.toString()} stations; got ${warnSpy.mock.calls.length.toString()} warn calls total`
)
})
await it('should omit simulator_station_ws_state line when wsState is undefined', async t => {
server.addStation(buildStationData('station-Mws', { wsState: undefined }))
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
})
)
server.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
server.start()
const res = new MockServerResponse()
server.emitRequest(buildMetricsRequest(), res)
probeServer.addStation(buildStationData(`station-T19-probe-${i.toString()}`))
}
probeServer.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
probeServer.start()
const probeRes = new MockServerResponse()
probeServer.emitRequest(buildMetricsRequest(), probeRes)
exactServer.addStation(buildStationData(`station-T19-exact-${i.toString()}`))
}
exactServer.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
exactServer.start()
const exactRes = new MockServerResponse()
exactServer.emitRequest(buildMetricsRequest(), exactRes)
await once(exactRes, 'finish')
const exactSoftCapCalls = warnSpy.mock.calls.filter(call => {
const message: unknown = call.arguments[0]
- return typeof message === 'string' && message.includes('soft cap')
+ return typeof message === 'string' && message.includes(METRICS_SOFT_CAP_WARN_PREFIX)
}).length
exactServer.stop()
assert.strictEqual(
belowServer.addStation(buildStationData(`station-T19-below-${i.toString()}`))
}
belowServer.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
belowServer.start()
const belowRes = new MockServerResponse()
belowServer.emitRequest(buildMetricsRequest(), belowRes)
await once(belowRes, 'finish')
const belowSoftCapCalls = warnSpy.mock.calls.filter(call => {
const message: unknown = call.arguments[0]
- return typeof message === 'string' && message.includes('soft cap')
+ return typeof message === 'string' && message.includes(METRICS_SOFT_CAP_WARN_PREFIX)
}).length
belowServer.stop()
assert.ok(
probeServer.addStation(buildStationData(`station-T20-probe-${i.toString()}`))
}
probeServer.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
probeServer.start()
const probeRes = new MockServerResponse()
probeServer.emitRequest(buildMetricsRequest(), probeRes)
concurrentServer.addStation(buildStationData(`station-T20-${i.toString()}`))
}
concurrentServer.mockListen(t)
- // eslint-disable-next-line @typescript-eslint/no-deprecated
+
concurrentServer.start()
const resA = new MockServerResponse()
)
const softCapCalls = warnSpy.mock.calls.filter(call => {
const message: unknown = call.arguments[0]
- return typeof message === 'string' && message.includes('soft cap')
+ return typeof message === 'string' && message.includes(METRICS_SOFT_CAP_WARN_PREFIX)
}).length
assert.strictEqual(
softCapCalls,
)
})
- await it('should fire warnIfMisconfigured when metrics.enabled=true && type=ws', t => {
+ await it('should not warn about transport-restriction when metrics.enabled=true && type=ws (issue #1917)', t => {
const warnSpy = t.mock.method(logger, 'warn', () => undefined)
const wsServer = new UIWebSocketServer(
createMockUIServerConfiguration({
const matchingCalls = warnSpy.mock.calls.filter(call => {
const message: unknown = call.arguments[0]
return (
- typeof message === 'string' && message.includes('metrics') && message.includes('http')
+ typeof message === 'string' &&
+ /metrics\.enabled=true/i.test(message) &&
+ /honored only/i.test(message)
)
})
- assert.ok(
- matchingCalls.length >= 1,
- `Expected logger.warn about metrics.enabled on non-HTTP transport; saw ${warnSpy.mock.calls.length.toString()} total`
+ assert.strictEqual(
+ matchingCalls.length,
+ 0,
+ `Metrics endpoint is now transport-agnostic; transport-restriction warning must not be emitted. Saw ${matchingCalls.length.toString()} matching warn(s).`
)
} finally {
wsServer.stop()
- // satisfy AbstractUIServer reference (no-op outside this scope)
void AbstractUIServer
}
})
+
+ await it('start() runs buildMetricsRegistryIfEnabled THEN attachTransport THEN httpServer.listen (strict template-method ordering)', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ const order: string[] = []
+ const proto = Reflect.getPrototypeOf(server) as {
+ attachTransport: () => void
+ buildMetricsRegistryIfEnabled: () => void
+ }
+ const origBuild = proto.buildMetricsRegistryIfEnabled.bind(server)
+ const origAttach = proto.attachTransport.bind(server)
+ ;(
+ server as unknown as { buildMetricsRegistryIfEnabled: () => void }
+ ).buildMetricsRegistryIfEnabled = () => {
+ order.push('build')
+ origBuild()
+ }
+ ;(server as unknown as { attachTransport: () => void }).attachTransport = () => {
+ order.push('attach')
+ origAttach()
+ }
+ const httpServer = Reflect.get(server, 'httpServer') as {
+ listen: (...args: unknown[]) => unknown
+ }
+ t.mock.method(
+ httpServer as never,
+ 'listen' as never,
+ ((): unknown => {
+ order.push('listen')
+ return httpServer
+ }) as never
+ )
+ try {
+ server.start()
+ assert.deepStrictEqual(
+ order,
+ ['build', 'attach', 'listen'],
+ 'strict template-method ordering: build → attach → listen'
+ )
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('start() is one-shot — a second call throws BaseError and does not re-attach', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const registry1 = server.getMetricsRegistry()
+ const httpServer = Reflect.get(server, 'httpServer') as {
+ listenerCount: (event: string) => number
+ }
+ const listeners1 = httpServer.listenerCount('request')
+ assert.throws(
+ () => {
+ server.start()
+ },
+ (err: unknown) => err instanceof BaseError,
+ 'second start() must throw BaseError'
+ )
+ assert.strictEqual(
+ server.getMetricsRegistry(),
+ registry1,
+ 'metricsRegistry reference must be preserved after the rejected second start()'
+ )
+ assert.strictEqual(
+ httpServer.listenerCount('request'),
+ listeners1,
+ 'request listeners must not be doubled by the rejected second start()'
+ )
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('Content-Length equals UTF-8 byte count of body, not codepoint count (non-ASCII version label)', async t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server, '1.0.0-✓')
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(buildMetricsRequest(), res)
+ await once(res, 'finish')
+ const body = res.body ?? ''
+ assert.ok(body.includes('1.0.0-✓'), 'non-ASCII version must propagate into exposition body')
+ const checkMarkOccurrences = (body.match(/✓/gu) ?? []).length
+ assert.ok(checkMarkOccurrences >= 1, 'at least one ✓ must appear in the body')
+ const charLen = body.length
+ const byteLen = Buffer.byteLength(body, 'utf8')
+ assert.notStrictEqual(charLen, byteLen, 'non-ASCII body must have byteLen !== charLen')
+ assert.strictEqual(
+ byteLen - charLen,
+ checkMarkOccurrences * 2,
+ 'each ✓ contributes +2 bytes (U+2713 = 3-byte UTF-8 vs 1 codepoint)'
+ )
+ assert.strictEqual(
+ Number(res.headers['Content-Length']),
+ byteLen,
+ 'Content-Length header must equal UTF-8 byte length, not codepoint count'
+ )
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('stop() invokes detachTransport BEFORE stopHttpServer (lifecycle symmetry)', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ const order: string[] = []
+ const proto = Reflect.getPrototypeOf(server) as {
+ detachTransport: () => void
+ stopHttpServer: () => void
+ }
+ const origDetach = proto.detachTransport.bind(server)
+ const origStopHttp = proto.stopHttpServer.bind(server)
+ ;(server as unknown as { detachTransport: () => void }).detachTransport = (): void => {
+ order.push('detachTransport')
+ origDetach()
+ }
+ ;(server as unknown as { stopHttpServer: () => void }).stopHttpServer = (): void => {
+ order.push('stopHttpServer')
+ origStopHttp()
+ }
+ server.start()
+ server.stop()
+ assert.deepStrictEqual(
+ order,
+ ['detachTransport', 'stopHttpServer'],
+ 'detachTransport must be called before stopHttpServer for symmetric teardown'
+ )
+ })
+
+ await it('handleMetricsHttpRequest error path is a no-op when res.writableEnded after the scrape rejected post-end()', async t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ let writeHeadCount = 0
+ const origWriteHead = res.writeHead.bind(res)
+ ;(
+ res as MockServerResponse & {
+ writeHead: (status: number, headers?: Record<string, string>) => MockServerResponse
+ }
+ ).writeHead = (status: number, headers?: Record<string, string>): MockServerResponse => {
+ writeHeadCount += 1
+ return origWriteHead(status, headers)
+ }
+ Reflect.set(
+ server,
+ 'runMetricsScrape',
+ (_req: IncomingMessage, r: MockServerResponse): Promise<void> => {
+ r.writeHead(200, { 'Content-Type': 'text/plain' }).end('partial')
+ return Promise.reject(new Error('post-end fail'))
+ }
+ )
+ server.emitRequest(buildMetricsRequest(), res)
+ await new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+ await new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+ assert.strictEqual(res.statusCode, 200, 'partial 200 must NOT be rewritten to 500')
+ assert.strictEqual(
+ writeHeadCount,
+ 1,
+ 'writeHead must run exactly once — no double-write after end()'
+ )
+ assert.strictEqual(res.body, 'partial', 'body must not be overwritten by the error path')
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('stop() removes all httpServer listeners AND clears the registry when httpServer.listening === true', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ server.start()
+ const httpServer = Reflect.get(server, 'httpServer') as {
+ close: () => unknown
+ listenerCount: (event: string) => number
+ listening: boolean
+ }
+ t.mock.method(httpServer as never, 'close' as never, ((): unknown => httpServer) as never)
+ Object.defineProperty(httpServer, 'listening', { configurable: true, value: true })
+ const requestListenersBefore = httpServer.listenerCount('request')
+ assert.ok(requestListenersBefore >= 1, 'precondition: request listener attached')
+ assert.notStrictEqual(server.getMetricsRegistry(), undefined)
+ server.stop()
+ assert.strictEqual(
+ httpServer.listenerCount('request'),
+ 0,
+ 'stopHttpServer must removeAllListeners() when listening was true'
+ )
+ assert.strictEqual(
+ server.getMetricsRegistry(),
+ undefined,
+ 'metricsRegistry reference must be released by stop()'
+ )
+ })
+
+ await it('exposition body emits only labels listed in METRICS_ALLOWED_LABEL_NAMES (PII guardian)', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.addStation(buildStationData('station-PII'))
+ server.mockListen(t)
+ try {
+ server.start()
+ const registry = server.getMetricsRegistry()
+ assert(registry !== undefined, 'precondition: registry must be built')
+ const metrics = registry.getMetricsAsArray()
+ const leaked: string[] = []
+ for (const metric of metrics) {
+ const declared = (metric as { labelNames?: readonly string[] }).labelNames ?? []
+ for (const labelName of declared) {
+ if (!isMetricsAllowedLabelName(labelName)) leaked.push(labelName)
+ }
+ }
+ assert.deepStrictEqual(
+ leaked,
+ [],
+ `Label PII allowlist violated. Labels not in METRICS_ALLOWED_LABEL_NAMES: ${leaked.join(', ')}. If intentional, add to METRICS_ALLOWED_LABEL_NAMES with security review.`
+ )
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('METRICS_ALLOWED_LABEL_NAMES is a runtime-immutable frozen tuple', () => {
+ assert.ok(
+ Object.isFrozen(METRICS_ALLOWED_LABEL_NAMES),
+ 'METRICS_ALLOWED_LABEL_NAMES must be Object.frozen (honest on arrays, blocks push/length/index)'
+ )
+ assert.throws(
+ () => {
+ ;(METRICS_ALLOWED_LABEL_NAMES as unknown as string[]).push('rogue')
+ },
+ TypeError,
+ 'push on frozen array must throw TypeError at runtime'
+ )
+ assert.strictEqual(METRICS_ALLOWED_LABEL_NAMES.length, 14)
+ assert.deepStrictEqual(
+ [...METRICS_ALLOWED_LABEL_NAMES].sort(),
+ [
+ 'availability',
+ 'connector_id',
+ 'connector_type',
+ 'current_out_type',
+ 'error_code',
+ 'evse_id',
+ 'firmware_version',
+ 'hash_id',
+ 'model',
+ 'ocpp_version',
+ 'status',
+ 'template',
+ 'vendor',
+ 'version',
+ ].sort()
+ )
+ })
+
+ await it('METRICS_SOFT_SAMPLE_CAP default is 5000 (operator-facing contract; README-documented)', () => {
+ assert.strictEqual(
+ METRICS_SOFT_SAMPLE_CAP,
+ 5000,
+ 'METRICS_SOFT_SAMPLE_CAP default must remain 5000 — Prometheus operators rely on the README-documented soft-warn threshold; any change requires a coordinated docs + release-notes update'
+ )
+ })
+
+ await it('start() → stop() → start() succeeds (lifecycle is repeatable across cycles)', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ let attachCalls = 0
+ const proto = Reflect.getPrototypeOf(server) as { attachTransport: () => void }
+ const origAttach = proto.attachTransport.bind(server)
+ ;(server as unknown as { attachTransport: () => void }).attachTransport = () => {
+ attachCalls += 1
+ origAttach()
+ }
+ try {
+ server.start()
+ const registry1 = server.getMetricsRegistry()
+ const httpServer = Reflect.get(server, 'httpServer') as {
+ close: () => unknown
+ listenerCount: (event: string) => number
+ listening: boolean
+ }
+ t.mock.method(httpServer as never, 'close' as never, ((): unknown => httpServer) as never)
+ Object.defineProperty(httpServer, 'listening', { configurable: true, value: true })
+ const listenerCount1 = httpServer.listenerCount('request')
+ server.stop()
+ assert.doesNotThrow(() => {
+ server.start()
+ }, 'second start() after stop() must NOT throw the one-shot BaseError')
+ assert.strictEqual(attachCalls, 2, 'attachTransport must run on each fresh start()')
+ assert.notStrictEqual(
+ server.getMetricsRegistry(),
+ undefined,
+ 'metricsRegistry must be rebuilt on the second start()'
+ )
+ assert.strictEqual(
+ httpServer.listenerCount('request'),
+ listenerCount1,
+ 'request listeners must not accumulate across start()→stop()→start() cycles'
+ )
+ void registry1
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('start() rolls back transportAttached when attachTransport throws — a subsequent start() succeeds', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ const proto = Reflect.getPrototypeOf(server) as { attachTransport: () => void }
+ const origAttach = proto.attachTransport.bind(server)
+ const httpServer = Reflect.get(server, 'httpServer') as Server
+ let throwOnce = true
+ ;(server as unknown as { attachTransport: () => void }).attachTransport = () => {
+ if (throwOnce) {
+ throwOnce = false
+ httpServer.on('request', () => undefined)
+ httpServer.on('upgrade', () => undefined)
+ throw new Error('attach failed')
+ }
+ origAttach()
+ }
+ try {
+ assert.throws(() => {
+ server.start()
+ }, /attach failed/)
+ assert.strictEqual(
+ Reflect.get(server, 'transportAttached'),
+ false,
+ 'rollback: transportAttached must be false after attachTransport throws'
+ )
+ assert.strictEqual(
+ httpServer.listenerCount('request'),
+ 0,
+ 'rollback: any partially-registered request listeners must be stripped'
+ )
+ assert.strictEqual(
+ httpServer.listenerCount('upgrade'),
+ 0,
+ 'rollback: any partially-registered upgrade listeners must be stripped'
+ )
+ assert.doesNotThrow(() => {
+ server.start()
+ }, 'second start() after rollback must NOT throw the one-shot BaseError')
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('stop() invokes detachTransport synchronously and schedules registry.clear() as a microtask', async t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ server.start()
+ const order: string[] = []
+ const proto = Reflect.getPrototypeOf(server) as { detachTransport: () => void }
+ const origDetach = proto.detachTransport.bind(server)
+ ;(server as unknown as { detachTransport: () => void }).detachTransport = () => {
+ order.push('detach')
+ origDetach()
+ }
+ const registry = server.getMetricsRegistry()
+ assert(registry !== undefined, 'precondition: registry built')
+ const origClear = registry.clear.bind(registry)
+ ;(registry as unknown as { clear: () => void }).clear = () => {
+ order.push('clear')
+ origClear()
+ }
+ server.stop()
+ assert.deepStrictEqual(
+ order,
+ ['detach'],
+ 'detach must run synchronously inside stop(); clear is still scheduled'
+ )
+ await Promise.resolve()
+ await Promise.resolve()
+ assert.deepStrictEqual(
+ order,
+ ['detach', 'clear'],
+ 'registry.clear() must settle on the microtask queue (Promise.finally), NOT setImmediate/setTimeout'
+ )
+ })
+
+ await it('handleMetricsHttpRequest error path is a no-op when writableEnded=true AND headersSent=false (end-without-writeHead)', async t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ let writeHeadCount = 0
+ const origWriteHead = res.writeHead.bind(res)
+ ;(
+ res as MockServerResponse & {
+ writeHead: (status: number, headers?: Record<string, string>) => MockServerResponse
+ }
+ ).writeHead = (status: number, headers?: Record<string, string>): MockServerResponse => {
+ writeHeadCount += 1
+ return origWriteHead(status, headers)
+ }
+ Reflect.set(
+ server,
+ 'runMetricsScrape',
+ (_req: IncomingMessage, r: MockServerResponse): Promise<void> => {
+ r.end('partial')
+ return Promise.reject(new Error('post-end fail'))
+ }
+ )
+ server.emitRequest(buildMetricsRequest(), res)
+ await new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+ await new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+ assert.strictEqual(
+ res.headersSent,
+ false,
+ 'precondition: stub used end() without writeHead → headersSent stays false'
+ )
+ assert.strictEqual(
+ res.writableEnded,
+ true,
+ 'precondition: stub called end() → writableEnded is true'
+ )
+ assert.strictEqual(
+ writeHeadCount,
+ 0,
+ 'error path must NOT writeHead — `!res.writableEnded` is the gate, not `!res.headersSent` alone'
+ )
+ assert.strictEqual(res.body, 'partial', 'body must not be overwritten by the error path')
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('stop() wipes pre-populated maps + caches AND resets transportAttached when detachTransport() throws (caught path)', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ server.start()
+ const { uiSvc } = populateLiveState(server)
+ const httpServer = Reflect.get(server, 'httpServer') as {
+ close: () => unknown
+ listenerCount: (event: string) => number
+ listening: boolean
+ }
+ t.mock.method(httpServer as never, 'close' as never, ((): unknown => httpServer) as never)
+ Object.defineProperty(httpServer, 'listening', { configurable: true, value: true })
+ ;(server as unknown as { detachTransport: () => void }).detachTransport = () => {
+ throw new Error('detach boom')
+ }
+ assert.doesNotThrow(() => {
+ server.stop()
+ }, 'stop() must swallow detachTransport() throws and continue teardown')
+ assert.strictEqual(
+ Reflect.get(server, 'transportAttached'),
+ false,
+ 'inv-1 transportAttached reset even when detachTransport throws'
+ )
+ assert.strictEqual(server.getMetricsRegistry(), undefined, 'inv-2 registry released')
+ assert.strictEqual(uiSvc.stopCalled, 1, 'inv-3 uiService.stop() called exactly once')
+ assert.strictEqual(
+ (Reflect.get(server, 'uiServices') as Map<unknown, unknown>).size,
+ 0,
+ 'inv-4 uiServices cleared'
+ )
+ assert.strictEqual(
+ (Reflect.get(server, 'responseHandlers') as Map<unknown, unknown>).size,
+ 0,
+ 'inv-5 responseHandlers cleared'
+ )
+ assert.strictEqual(
+ (Reflect.get(server, 'chargingStations') as Map<string, unknown>).size,
+ 0,
+ 'inv-6 clearCaches() ran: chargingStations drained'
+ )
+ assert.strictEqual(
+ (Reflect.get(server, 'chargingStationTemplates') as Set<string>).size,
+ 0,
+ 'inv-7 clearCaches() ran: chargingStationTemplates drained'
+ )
+ assert.strictEqual(httpServer.listenerCount('request'), 0, 'inv-8 request listeners stripped')
+ assert.doesNotThrow(() => {
+ server.start()
+ }, 'inv-9 fresh start() must succeed after stop() swallowed a detachTransport throw')
+ server.stop()
+ })
+
+ await it('stop() resets transportAttached in finally{} even when stopHttpServer() rethrows (uncaught path discriminator)', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ server.start()
+ const httpServer = Reflect.get(server, 'httpServer') as {
+ close: () => unknown
+ eventNames: () => (string | symbol)[]
+ listening: boolean
+ }
+ Object.defineProperty(httpServer, 'listening', { configurable: true, value: true })
+ t.mock.method(
+ httpServer as never,
+ 'close' as never,
+ ((): unknown => {
+ throw new Error('close boom')
+ }) as never
+ )
+ assert.throws(
+ () => {
+ server.stop()
+ },
+ /close boom/,
+ 'unprotected stopHttpServer throw must propagate'
+ )
+ assert.strictEqual(
+ httpServer.eventNames().length,
+ 0,
+ 'listener-strip ordering: removeAllListeners() MUST run BEFORE close() so ALL listener kinds (request/connection/clientError/upgrade/error) are stripped even when close() throws'
+ )
+ assert.strictEqual(
+ Reflect.get(server, 'transportAttached'),
+ false,
+ 'finally{} placement: transportAttached reset even when an unprotected step rethrows'
+ )
+ assert.doesNotThrow(() => {
+ server.start()
+ }, 'recovery: fresh start() must succeed after a rethrown stop()')
+ // Cleanup: the close mock is still active; suppress the trailing
+ // throw to avoid bleeding into subsequent tests' diagnostics.
+ Object.defineProperty(httpServer, 'listening', { configurable: true, value: false })
+ server.stop()
+ })
+
+ await it('stop() continues teardown when a uiService.stop() throws (per-iteration protection)', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ server.start()
+ const services = Reflect.get(server, 'uiServices') as Map<unknown, { stop: () => void }>
+ let peerStopped = false
+ services.set('rogue', {
+ stop: () => {
+ throw new Error('service boom')
+ },
+ })
+ services.set('peer', {
+ stop: () => {
+ peerStopped = true
+ },
+ })
+ assert.doesNotThrow(() => {
+ server.stop()
+ }, 'stop() must swallow a rogue uiService.stop()')
+ assert.strictEqual(peerStopped, true, 'peer uiService.stop() ran after rogue threw')
+ assert.strictEqual(services.size, 0, 'uiServices cleared after per-iteration catch')
+ assert.strictEqual(
+ Reflect.get(server, 'transportAttached'),
+ false,
+ 'transportAttached reset after rogue uiService caught'
+ )
+ })
+
+ await it('metricsScrapeChain is re-armed across start()→stop()→start() cycles (monotonic identity sequence)', async t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.addStation(buildStationData('station-chain'))
+ server.mockListen(t)
+ const getChain = (): Promise<void> => Reflect.get(server, 'metricsScrapeChain') as Promise<void>
+ try {
+ server.start()
+ const chain0 = getChain()
+ const r1 = new MockServerResponse()
+ server.emitRequest(buildMetricsRequest(), r1)
+ await once(r1, 'finish')
+ const chainAfterScrape1 = getChain()
+ assert.notStrictEqual(
+ chainAfterScrape1,
+ chain0,
+ 'scrape advances chain identity within a cycle'
+ )
+ server.stop()
+ const chainAfterStop = getChain()
+ assert.notStrictEqual(
+ chainAfterStop,
+ chainAfterScrape1,
+ 'stop() rebinds chain to schedule registry.clear()'
+ )
+ server.start()
+ const chain1 = getChain()
+ assert.notStrictEqual(
+ chain1,
+ chainAfterStop,
+ 'start() reseats chain to a fresh Promise.resolve() (no cross-cycle accumulation)'
+ )
+ const r2 = new MockServerResponse()
+ server.emitRequest(buildMetricsRequest(), r2)
+ await once(r2, 'finish')
+ const chainAfterScrape2 = getChain()
+ const all = [chain0, chainAfterScrape1, chainAfterStop, chain1, chainAfterScrape2]
+ assert.strictEqual(
+ new Set(all).size,
+ all.length,
+ 'chain identity never reused across cycles (monotonic sequence)'
+ )
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('stop() schedules registry.clear() against the captured registry (locks the .finally body, not just chain identity)', async t => {
+ const localServer = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(localServer)
+ localServer.addStation(buildStationData('station-clear'))
+ localServer.mockListen(t)
+ try {
+ localServer.start()
+ const r1 = new MockServerResponse()
+ localServer.emitRequest(buildMetricsRequest(), r1)
+ await once(r1, 'finish')
+ const registry = localServer.getMetricsRegistry()
+ assert.ok(registry !== undefined, 'precondition: registry present before stop()')
+ let clearCalls = 0
+ const originalClear = registry.clear.bind(registry)
+ registry.clear = (): void => {
+ clearCalls += 1
+ originalClear()
+ }
+ const chainBefore = Reflect.get(localServer, 'metricsScrapeChain') as Promise<void>
+ localServer.stop()
+ const chainAfter = Reflect.get(localServer, 'metricsScrapeChain') as Promise<void>
+ assert.notStrictEqual(chainAfter, chainBefore, 'precondition: stop() rebinds the chain')
+ await chainAfter
+ assert.strictEqual(
+ clearCalls,
+ 1,
+ 'stop() MUST invoke registry.clear() exactly once on the CAPTURED registry instance'
+ )
+ assert.strictEqual(
+ localServer.getMetricsRegistry(),
+ undefined,
+ 'registry field released even though clear() ran on the captured handle'
+ )
+ } finally {
+ localServer.stop()
+ }
+ })
+
+ await it('stop() runs registry.clear() even when metricsScrapeChain is in a REJECTED state (locks .finally semantics, not .then)', async t => {
+ const localServer = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(localServer)
+ localServer.addStation(buildStationData('station-rejected-chain'))
+ localServer.mockListen(t)
+ t.mock.method(logger, 'error', () => undefined)
+ try {
+ localServer.start()
+ const registry = localServer.getMetricsRegistry()
+ assert.ok(registry !== undefined, 'precondition: registry present before scrape')
+ registry.metrics = async (): Promise<string> => {
+ await Promise.resolve()
+ throw new Error('scrape boom')
+ }
+ let clearCalls = 0
+ const originalClear = registry.clear.bind(registry)
+ registry.clear = (): void => {
+ clearCalls += 1
+ originalClear()
+ }
+ const res = new MockServerResponse()
+ localServer.emitRequest(buildMetricsRequest(), res)
+ await once(res, 'finish')
+ const rejectedChain = Reflect.get(localServer, 'metricsScrapeChain') as Promise<void>
+ let rejected = false
+ await rejectedChain.catch(() => {
+ rejected = true
+ })
+ assert.strictEqual(
+ rejected,
+ true,
+ 'precondition: scrape chain is in REJECTED state before stop()'
+ )
+ localServer.stop()
+ const chainAfter = Reflect.get(localServer, 'metricsScrapeChain') as Promise<void>
+ await chainAfter
+ assert.strictEqual(
+ clearCalls,
+ 1,
+ 'stop() MUST invoke registry.clear() exactly once even when metricsScrapeChain is rejected (locks `.finally`, not `.then`)'
+ )
+ } finally {
+ localServer.stop()
+ }
+ })
+
+ await it('runMetricsScrape captures sampleCount pre-yield: mutating this.metricsSampleCount across the await does not synthesize a soft-cap warn (MED-1 regression lock)', async t => {
+ const cap = 999_999
+ const sentinel = Number.MAX_SAFE_INTEGER
+ const localServer = new TestableUIHttpServer(
+ createMetricsConfig({ metrics: { enabled: true, softSampleCap: cap } })
+ )
+ enrichBootstrap(localServer)
+ localServer.mockListen(t)
+ const warnSpy = t.mock.method(logger, 'warn', () => undefined)
+ try {
+ localServer.start()
+ const registry = localServer.getMetricsRegistry()
+ assert.ok(registry !== undefined, 'precondition: registry present')
+ let releaseGate = (): void => undefined
+ const gated = new Promise<void>(resolve => {
+ releaseGate = resolve
+ })
+ const originalMetrics = registry.metrics.bind(registry)
+ registry.metrics = async (): Promise<string> => {
+ const inner = originalMetrics()
+ await gated
+ Reflect.set(localServer, 'metricsSampleCount', sentinel)
+ return await inner
+ }
+ const res = new MockServerResponse()
+ localServer.emitRequest(buildMetricsRequest(), res)
+ await new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+ releaseGate()
+ await once(res, 'finish')
+ const scrapeWarns = warnSpy.mock.calls.filter(
+ c =>
+ typeof c.arguments[0] === 'string' &&
+ (c.arguments[0] as string).includes(METRICS_SOFT_CAP_WARN_PREFIX)
+ )
+ assert.strictEqual(
+ scrapeWarns.length,
+ 0,
+ 'soft-cap branch MUST read the pre-yield captured sampleCount; mutating this.metricsSampleCount across the await must not synthesize a warn'
+ )
+ } finally {
+ localServer.stop()
+ }
+ })
+
+ await it('runMetricsScrape fires exactly one soft-cap warn at cap=1 with the captured sample count (MED-1 positive lock)', async t => {
+ const cap = 1
+ const localServer = new TestableUIHttpServer(
+ createMetricsConfig({ metrics: { enabled: true, softSampleCap: cap } })
+ )
+ enrichBootstrap(localServer)
+ localServer.addStation(buildStationData('station-MED1-pos'))
+ localServer.mockListen(t)
+ const warnSpy = t.mock.method(logger, 'warn', () => undefined)
+ try {
+ localServer.start()
+ const res = new MockServerResponse()
+ localServer.emitRequest(buildMetricsRequest(), res)
+ await once(res, 'finish')
+ assert.strictEqual(res.statusCode, 200)
+ const sampleCount = (res.body ?? '')
+ .split('\n')
+ .filter(line => line.length > 0 && !line.startsWith('#')).length
+ assert.ok(
+ sampleCount > cap,
+ `precondition: scrape must produce > cap=${cap.toString()} samples; got ${sampleCount.toString()}`
+ )
+ const scrapeWarns = warnSpy.mock.calls.filter(
+ c =>
+ typeof c.arguments[0] === 'string' &&
+ (c.arguments[0] as string).includes(METRICS_SOFT_CAP_WARN_PREFIX)
+ )
+ assert.strictEqual(
+ scrapeWarns.length,
+ 1,
+ `soft-cap branch MUST fire exactly one warn at cap=${cap.toString()} with > ${cap.toString()} samples; got ${scrapeWarns.length.toString()}`
+ )
+ const message = scrapeWarns[0].arguments[0] as unknown as string
+ assert.ok(
+ message.includes(`${sampleCount.toString()} samples`),
+ `warn message MUST embed the captured sampleCount=${sampleCount.toString()}; got: ${message}`
+ )
+ assert.ok(
+ message.includes(`soft cap ${cap.toString()}`),
+ `warn message MUST embed the configured cap=${cap.toString()}; got: ${message}`
+ )
+ } finally {
+ localServer.stop()
+ }
+ })
+
+ await it('soft-cap branch fires at cap=0 for any non-zero sample count (lower-boundary lock)', async t => {
+ const localServer = new TestableUIHttpServer(
+ createMetricsConfig({ metrics: { enabled: true, softSampleCap: 0 } })
+ )
+ enrichBootstrap(localServer)
+ localServer.mockListen(t)
+ const warnSpy = t.mock.method(logger, 'warn', () => undefined)
+ try {
+ localServer.start()
+ const res = new MockServerResponse()
+ localServer.emitRequest(buildMetricsRequest(), res)
+ await once(res, 'finish')
+ assert.strictEqual(res.statusCode, 200)
+ const scrapeWarns = warnSpy.mock.calls.filter(
+ c =>
+ typeof c.arguments[0] === 'string' &&
+ (c.arguments[0] as string).includes(METRICS_SOFT_CAP_WARN_PREFIX)
+ )
+ assert.strictEqual(
+ scrapeWarns.length,
+ 1,
+ 'soft-cap branch MUST fire at cap=0 for any scrape with > 0 samples (strict-greater-than semantics; guards against an over-eager "cap=0 means disabled" early-exit refactor)'
+ )
+ } finally {
+ localServer.stop()
+ }
+ })
+
+ await it('in-flight scrape survives concurrent stop()', async t => {
+ const localServer = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(localServer)
+ localServer.addStation(buildStationData('station-capture-lock'))
+ localServer.mockListen(t)
+ try {
+ localServer.start()
+ const registry = localServer.getMetricsRegistry()
+ assert.ok(registry !== undefined, 'precondition: registry present')
+ let releaseGate = (): void => undefined
+ const gated = new Promise<void>(resolve => {
+ releaseGate = resolve
+ })
+ const originalMetrics = registry.metrics.bind(registry)
+ registry.metrics = async (): Promise<string> => {
+ const inner = originalMetrics()
+ await gated
+ return await inner
+ }
+ const res = new MockServerResponse()
+ localServer.emitRequest(buildMetricsRequest(), res)
+ await new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+ localServer.stop()
+ assert.strictEqual(
+ localServer.getMetricsRegistry(),
+ undefined,
+ 'precondition: stop() nulled this.metricsRegistry mid-flight'
+ )
+ releaseGate()
+ await once(res, 'finish')
+ assert.strictEqual(
+ res.statusCode,
+ 200,
+ 'in-flight scrape completes 200 — captured const registry survives mid-flight stop()'
+ )
+ assert.match(
+ res.body ?? '',
+ /^# HELP /m,
+ 'response body is the OLD registry exposition (captured const, not the nulled field)'
+ )
+ } finally {
+ localServer.stop()
+ }
+ })
+
+ await it('stop() is idempotent — a second stop() is a safe no-op (no throw, no chain rebind)', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ server.start()
+ server.stop()
+ const chainBefore = Reflect.get(server, 'metricsScrapeChain') as Promise<void>
+ const registryBefore = server.getMetricsRegistry()
+ const attachedBefore = Reflect.get(server, 'transportAttached') as boolean
+ assert.doesNotThrow(() => {
+ server.stop()
+ }, 'second stop() must not throw')
+ assert.strictEqual(
+ server.getMetricsRegistry(),
+ registryBefore,
+ '2nd stop: registry stays undefined'
+ )
+ assert.strictEqual(
+ Reflect.get(server, 'transportAttached'),
+ attachedBefore,
+ '2nd stop: transportAttached stays false'
+ )
+ assert.strictEqual(
+ Reflect.get(server, 'metricsScrapeChain'),
+ chainBefore,
+ '2nd stop: chain MUST NOT be rebound when metricsRegistry is already undefined'
+ )
+ })
+
+ await it('stop() clears the client-notification debounce timer on every invocation (outside the metricsRegistry guard)', t => {
+ const localServer = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(localServer)
+ localServer.mockListen(t)
+ localServer.start()
+ ;(
+ localServer as unknown as { scheduleClientNotification: () => void }
+ ).scheduleClientNotification()
+ const installed = Reflect.get(localServer, 'clientNotificationDebounceTimer') as
+ | NodeJS.Timeout
+ | undefined
+ assert.ok(installed !== undefined, 'precondition: debounce timer installed')
+ const clearedHandles: unknown[] = []
+ const originalClearTimeout = globalThis.clearTimeout
+ globalThis.clearTimeout = ((handle?: NodeJS.Timeout): void => {
+ clearedHandles.push(handle)
+ originalClearTimeout(handle)
+ }) as typeof globalThis.clearTimeout
+ try {
+ // Measure delta within each stop() so that clearTimeout calls
+ // from scheduleClientNotification() (which clears its own stale
+ // handle before installing a fresh one) don't contaminate the
+ // count attributable to stop().
+ const beforeStop1 = clearedHandles.length
+ localServer.stop()
+ const afterStop1 = clearedHandles.length
+ assert.strictEqual(
+ afterStop1 - beforeStop1,
+ 1,
+ '1st stop() MUST invoke clearTimeout exactly once'
+ )
+ assert.strictEqual(
+ clearedHandles[afterStop1 - 1],
+ installed,
+ '1st stop() clears the originally installed handle'
+ )
+ ;(
+ localServer as unknown as { scheduleClientNotification: () => void }
+ ).scheduleClientNotification()
+ const reinstalled = Reflect.get(localServer, 'clientNotificationDebounceTimer') as
+ | NodeJS.Timeout
+ | undefined
+ assert.ok(reinstalled !== undefined, 'precondition: 2nd timer installed after first stop()')
+ const beforeStop2 = clearedHandles.length
+ localServer.stop()
+ const afterStop2 = clearedHandles.length
+ assert.strictEqual(
+ afterStop2 - beforeStop2,
+ 1,
+ '2nd stop() MUST invoke clearTimeout exactly once — the call sits OUTSIDE the metricsRegistry guard'
+ )
+ assert.strictEqual(
+ clearedHandles[afterStop2 - 1],
+ reinstalled,
+ '2nd stop() clears the re-installed handle (no leak across cycles)'
+ )
+ } finally {
+ globalThis.clearTimeout = originalClearTimeout
+ }
+ })
+
+ await it('attachTransport() observes transportAttached === true (one-shot guard set BEFORE hook invocation)', t => {
+ const server = new TestableUIHttpServer(createMetricsConfig())
+ enrichBootstrap(server)
+ server.mockListen(t)
+ let observedDuringAttach: unknown = 'unset'
+ const proto = Reflect.getPrototypeOf(server) as { attachTransport: () => void }
+ const origAttach = proto.attachTransport.bind(server)
+ ;(server as unknown as { attachTransport: () => void }).attachTransport = () => {
+ observedDuringAttach = Reflect.get(server, 'transportAttached')
+ origAttach()
+ }
+ try {
+ server.start()
+ assert.strictEqual(
+ observedDuringAttach,
+ true,
+ 'transportAttached MUST be true while attachTransport runs (guards re-entry / nested start())'
+ )
+ } finally {
+ server.stop()
+ }
+ })
})
*/
import type { IncomingMessage } from 'node:http'
import type { Duplex } from 'node:stream'
+import type { mock } from 'node:test'
-import { EventEmitter } from 'node:events'
+import { EventEmitter, once } from 'node:events'
import type { IBootstrap } from '../../../src/charging-station/IBootstrap.js'
import type {
this.responseHandlers.set(uuid, ws as never)
}
+ public addStation (data: ChargingStationData): void {
+ this.setChargingStationData(data.stationInfo.hashId, data)
+ }
+
+ public emitRequest (req: IncomingMessage, res: MockServerResponse): void {
+ this.httpServer.emit('request', req, res)
+ }
+
public emitUpgrade (req: IncomingMessage, socket: Duplex, head = Buffer.alloc(0)): void {
this.httpServer.emit('upgrade', req, socket, head)
}
return this.uiServices.get(version)
}
+ public getWebSocketServer (): object {
+ return Reflect.get(this, 'webSocketServer')
+ }
+
+ public mockListen (t: { mock: { method: typeof mock.method } }): void {
+ const httpServer = Reflect.get(this, 'httpServer') as object
+ t.mock.method(httpServer as never, 'listen' as never, ((): unknown => httpServer) as never)
+ }
+
/**
* Register a mock UI service for testing.
* @param version - Protocol version string to register
public destroyed = false
public ended = false
public headers: Record<string, string> = {}
+ public headersSent = false
public statusCode?: number
+ public writableEnded = false
private chunks: Buffer[] = []
public destroy (): this {
this.body = this.bodyBuffer.toString('binary')
}
this.ended = true
+ this.writableEnded = true
this.emit('finish')
return this
}
if (headers != null) {
this.headers = headers
}
+ this.headersSent = true
return this
}
}
return [uuid, procedureName, payload]
}
+/**
+ * Bounded multi-response drain. Awaits every `'finish'` event with a
+ * timeout to prevent microtask leaks into the next test's logger mocks,
+ * then flushes the trailing scrape-chain microtasks: `runMetricsScrape`
+ * ends the response inside a `.then()` continuation on
+ * `metricsScrapeChain`; the chain resolution lands one microtask later.
+ * Two `setImmediate` hops guarantee both the continuation and the chain
+ * settlement run before the next test installs its logger mocks.
+ *
+ * The default 5000 ms timeout is sufficient for ≤200 in-flight scrapes
+ * serialized through `metricsScrapeChain`; override at the call site for
+ * larger bursts.
+ *
+ * Throws a diagnostic error naming the pending count when the timeout
+ * fires, so a leaked async scrape is identifiable without grepping the
+ * suite output.
+ * @param responses - Responses whose `'finish'` events must drain
+ * @param opts - Drain options
+ * @param opts.timeoutMs - Upper bound in milliseconds (default 5000)
+ */
+export const drainResponses = async (
+ responses: readonly MockServerResponse[],
+ opts: { timeoutMs?: number } = {}
+): Promise<void> => {
+ const { timeoutMs = 5_000 } = opts
+ const settle = responses.map(r => (r.writableEnded ? Promise.resolve() : once(r, 'finish')))
+ let timer: ReturnType<typeof setTimeout> | undefined
+ const timerPromise = new Promise<'timeout'>(resolve => {
+ timer = setTimeout(() => {
+ resolve('timeout')
+ }, timeoutMs)
+ timer.unref()
+ })
+ try {
+ const outcome = await Promise.race([
+ Promise.allSettled(settle).then(() => 'ok' as const),
+ timerPromise,
+ ])
+ if (outcome === 'timeout') {
+ const pending = responses.filter(r => !r.writableEnded).length
+ throw new Error(
+ `drainResponses: ${pending.toString()}/${responses.length.toString()} response(s) did not finish in ${timeoutMs.toString()}ms`
+ )
+ }
+ await new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+ await new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+ } finally {
+ if (timer !== undefined) clearTimeout(timer)
+ }
+}
+
+/**
+ * Single-response wait with timeout. Replaces bare `await once(res, 'finish')`
+ * call sites that would otherwise hang the whole suite if the scrape promise
+ * rejects.
+ * @param res - Response to await
+ * @param opts - Wait options
+ * @param opts.timeoutMs - Upper bound in milliseconds (default 5000)
+ */
+export const awaitFinish = async (
+ res: MockServerResponse,
+ opts: { timeoutMs?: number } = {}
+): Promise<void> => {
+ await drainResponses([res], opts)
+}
+
+/**
+ * Extract a single gauge value from a Prometheus exposition body. Returns
+ * `undefined` if the gauge is missing or malformed so assertion failures
+ * carry an informative message.
+ * @param body - Raw exposition text
+ * @param name - Gauge metric name (e.g. `'simulator_started'`)
+ * @param labels - Optional label map to disambiguate label sets
+ * @returns The parsed gauge value, or `undefined` if absent or malformed
+ */
+export const extractGaugeValue = (
+ body: string,
+ name: string,
+ labels?: Readonly<Record<string, string>>
+): number | undefined => {
+ const labelExpr =
+ labels === undefined || Object.keys(labels).length === 0
+ ? ''
+ : `\\{${Object.entries(labels)
+ .map(([k, v]) => `${k}="${v.replace(/[\\"]/g, '\\$&')}"`)
+ .join(',')}\\}`
+ const re = new RegExp(`^${name}${labelExpr}\\s+([-0-9.eE+]+)$`, 'm')
+ const m = re.exec(body)
+ return m === null ? undefined : Number(m[1])
+}
+
/**
* Mock UI service behavior mode for testing different request handler scenarios.
*/
* @description Unit tests for WebSocket-based UI server and response handling
*/
+import type { IncomingMessage } from 'node:http'
import type { Duplex } from 'node:stream'
import assert from 'node:assert/strict'
import { afterEach, describe, it } from 'node:test'
-import type { UUIDv4 } from '../../../src/types/index.js'
+import type {
+ ChargingStationData,
+ TemplateStatistics,
+ UIServerConfiguration,
+ UUIDv4,
+} from '../../../src/types/index.js'
-import { ProcedureName, ResponseStatus } from '../../../src/types/index.js'
+import {
+ ApplicationProtocol,
+ AuthenticationType,
+ ConnectorStatusEnum,
+ OCPP16AvailabilityType,
+ OCPPVersion,
+ ProcedureName,
+ ResponseStatus,
+} from '../../../src/types/index.js'
import { logger } from '../../../src/utils/Logger.js'
import { createLoggerMocks, standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
import { TEST_UUID } from './UIServerTestConstants.js'
import {
+ awaitFinish,
createMockIncomingMessage,
createMockUIServerConfiguration,
createMockUIServerConfigurationWithAuth,
createMockUIService,
createMockUIWebSocket,
+ drainResponses,
+ extractGaugeValue,
+ MockServerResponse,
MockUIServiceMode,
MockUpgradeSocket,
TestableUIWebSocketServer,
} from './UIServerTestUtils.js'
+const createWsMetricsConfig = (
+ overrides: Partial<UIServerConfiguration> = {}
+): UIServerConfiguration =>
+ createMockUIServerConfiguration({
+ metrics: { enabled: true },
+ options: { host: '127.0.0.1', port: 0 },
+ type: ApplicationProtocol.WS,
+ ...overrides,
+ })
+
+const buildWsMetricsRequest = (overrides: Partial<IncomingMessage> = {}): IncomingMessage =>
+ createMockIncomingMessage({
+ complete: true,
+ headers: { host: 'localhost' },
+ method: 'GET',
+ socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+ url: '/metrics',
+ ...overrides,
+ })
+
+const enrichBootstrapForMetrics = (server: TestableUIWebSocketServer, version = '4.9.0'): void => {
+ const bootstrap = server.getBootstrap()
+ const templateStats: TemplateStatistics = {
+ added: 1,
+ configured: 5,
+ indexes: new Set([0]),
+ provisioned: 2,
+ started: 1,
+ }
+ Reflect.set(bootstrap, 'getState', () => ({
+ configuration: undefined,
+ started: true,
+ templateStatistics: new Map<string, TemplateStatistics>([['test-template', templateStats]]),
+ version,
+ }))
+}
+
+const buildSimpleStation = (hashId: string): ChargingStationData =>
+ ({
+ connectors: [
+ {
+ connectorId: 1,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.Operative,
+ MeterValues: [],
+ status: ConnectorStatusEnum.Available,
+ transactionStarted: false,
+ },
+ evseId: 1,
+ },
+ ],
+ evses: [],
+ ocppConfiguration: { configurationKey: [] },
+ started: true,
+ stationInfo: {
+ chargePointModel: 'TestModel',
+ chargePointVendor: 'TestVendor',
+ chargingStationId: hashId,
+ hashId,
+ maximumAmperage: 32,
+ maximumPower: 22000,
+ ocppVersion: OCPPVersion.VERSION_16,
+ templateIndex: 0,
+ templateName: 'test-template',
+ },
+ supervisionUrl: 'ws://test.example.com/OCPP16',
+ timestamp: 1_700_000_000_000,
+ wsState: 1,
+ }) as unknown as ChargingStationData
+
await describe('UIWebSocketServer', async () => {
afterEach(() => {
standardCleanup()
server.stop()
}
})
+
+ await it('should not leak webSocketServer "connection" listeners across start→stop→start cycles', t => {
+ const config = createMockUIServerConfiguration({
+ options: { host: '127.0.0.1', port: 0 },
+ type: ApplicationProtocol.WS,
+ })
+ const server = new TestableUIWebSocketServer(config)
+ server.mockListen(t)
+ const wsServer = server.getWebSocketServer() as {
+ listenerCount: (event: string) => number
+ }
+ const baseline = wsServer.listenerCount('connection')
+ try {
+ for (let cycle = 0; cycle < 3; cycle += 1) {
+ server.start()
+ assert.strictEqual(
+ wsServer.listenerCount('connection'),
+ baseline + 1,
+ `cycle ${cycle.toString()}: exactly one 'connection' listener after start()`
+ )
+ server.stop()
+ assert.strictEqual(
+ wsServer.listenerCount('connection'),
+ baseline,
+ `cycle ${cycle.toString()}: 'connection' listeners released after stop() (no leak)`
+ )
+ }
+ } finally {
+ try {
+ server.stop()
+ } catch {
+ /* already stopped */
+ }
+ }
+ })
+
+ await describe('metrics endpoint when uiServer.type=ws (issue #1917)', async () => {
+ await it('should serve Prometheus exposition on GET /metrics when enabled', async t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), res)
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 200)
+ assert.match(res.headers['Content-Type'] ?? '', /^text\/plain;\s*version=0\.0\.4/)
+ assert.strictEqual(
+ extractGaugeValue(res.body ?? '', 'simulator_started'),
+ 1,
+ 'simulator_started must be 1 from the enrichBootstrapForMetrics fixture'
+ )
+ assert.match(res.body ?? '', /^# HELP simulator_started /m)
+ assert.match(res.body ?? '', /^# TYPE simulator_started gauge$/m)
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should return 404 on GET /metrics when metrics.enabled=false (default)', t => {
+ const server = new TestableUIWebSocketServer(
+ createMockUIServerConfiguration({
+ options: { host: '127.0.0.1', port: 0 },
+ type: ApplicationProtocol.WS,
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), res)
+ assert.strictEqual(res.statusCode, 404)
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should inherit AccessPolicy denial — 403 on non-loopback without TLS (F5)', t => {
+ const server = new TestableUIWebSocketServer(
+ createWsMetricsConfig({
+ accessPolicy: {
+ allowedHosts: ['gateway.example.com'],
+ allowedOrigins: [],
+ allowLoopbackProxy: false,
+ requireTlsForNonLoopback: true,
+ trustedProxies: [],
+ },
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(
+ buildWsMetricsRequest({
+ headers: { host: 'gateway.example.com' },
+ socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never,
+ }),
+ res
+ )
+ assert.strictEqual(res.statusCode, 403)
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should return 401 without credentials when authentication is enabled', t => {
+ const server = new TestableUIWebSocketServer(
+ createWsMetricsConfig({
+ authentication: {
+ enabled: true,
+ password: 'pw',
+ type: AuthenticationType.BASIC_AUTH,
+ username: 'user',
+ },
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), res)
+ assert.strictEqual(res.statusCode, 401)
+ assert.strictEqual(res.headers['WWW-Authenticate'], 'Basic realm=users')
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should return 200 with valid Basic Auth credentials', async t => {
+ const server = new TestableUIWebSocketServer(
+ createWsMetricsConfig({
+ authentication: {
+ enabled: true,
+ password: 'pw',
+ type: AuthenticationType.BASIC_AUTH,
+ username: 'user',
+ },
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const credentials = Buffer.from('user:pw').toString('base64')
+ const res = new MockServerResponse()
+ server.emitRequest(
+ buildWsMetricsRequest({
+ headers: { authorization: `Basic ${credentials}`, host: 'localhost' },
+ }),
+ res
+ )
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 200)
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should return 429 under rate-limit burst on /metrics and drain all responses', async t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const responses: MockServerResponse[] = []
+ for (let i = 0; i < 200; i++) {
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), res)
+ responses.push(res)
+ }
+ const sync429 = responses.filter(r => r.statusCode === 429).length
+ assert.ok(
+ sync429 >= 1,
+ `Expected at least one synchronous 429 in burst on /metrics; saw ${sync429.toString()}`
+ )
+ await drainResponses(responses)
+ for (const r of responses) {
+ assert.strictEqual(r.writableEnded, true)
+ }
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should keep WS upgrade handler functional after a /metrics scrape', async t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.addStation(buildSimpleStation('station-ws-after-scrape'))
+ server.mockListen(t)
+ try {
+ server.start()
+ const metricsRes = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), metricsRes)
+ await awaitFinish(metricsRes)
+ assert.strictEqual(metricsRes.statusCode, 200)
+
+ const wss = server.getWebSocketServer() as {
+ handleUpgrade: (...args: unknown[]) => void
+ }
+ const handleUpgradeSpy = t.mock.method(
+ wss,
+ 'handleUpgrade',
+ (_req: unknown, _socket: unknown, _head: unknown, cb: (ws: unknown) => void) => {
+ cb({})
+ }
+ )
+
+ const socket = new MockUpgradeSocket()
+ server.emitUpgrade(
+ createMockIncomingMessage({
+ headers: {
+ connection: 'Upgrade',
+ host: 'localhost',
+ 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==',
+ 'sec-websocket-protocol': 'ui0.0.1',
+ 'sec-websocket-version': '13',
+ upgrade: 'websocket',
+ },
+ socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+ }),
+ socket as unknown as Duplex
+ )
+
+ assert.strictEqual(
+ handleUpgradeSpy.mock.calls.length,
+ 1,
+ 'WebSocketServer.handleUpgrade must run after a metrics scrape'
+ )
+ assert.strictEqual(
+ socket.destroyed,
+ false,
+ 'upgrade socket must not be destroyed by an HTTP-rejection write'
+ )
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should return 404 (not 401) on GET /metrics when metrics.enabled=false and authentication.enabled=true', t => {
+ const server = new TestableUIWebSocketServer(
+ createMockUIServerConfiguration({
+ authentication: {
+ enabled: true,
+ password: 'pw',
+ type: AuthenticationType.BASIC_AUTH,
+ username: 'user',
+ },
+ options: { host: '127.0.0.1', port: 0 },
+ type: ApplicationProtocol.WS,
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), res)
+ assert.strictEqual(res.statusCode, 404)
+ assert.strictEqual(res.headers['WWW-Authenticate'], undefined)
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should respond 200 with empty body on HEAD /metrics per RFC 9110 §9.3.2', async t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest({ method: 'HEAD' }), res)
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 200)
+ assert.match(res.headers['Content-Type'] ?? '', /^text\/plain;\s*version=0\.0\.4/)
+ assert.strictEqual(res.body, undefined, 'HEAD response body must be empty')
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should return 404 on POST /metrics (transport-method parity with HTTP/MCP)', t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest({ method: 'POST' }), res)
+ assert.strictEqual(res.statusCode, 404)
+ assert.strictEqual(res.headers['Content-Type'], 'text/plain')
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should call req.destroy() AFTER renderDenial on the 404 fallback (parity with MCP)', t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const order: string[] = []
+ const req = buildWsMetricsRequest({ complete: false, url: '/unknown' })
+ ;(req as IncomingMessage & { destroy: () => IncomingMessage }).destroy = () => {
+ order.push('destroy')
+ return req
+ }
+ const res = new MockServerResponse()
+ const origEnd = res.end.bind(res)
+ ;(res as MockServerResponse & { end: (data?: string) => MockServerResponse }).end = (
+ data?: string
+ ): MockServerResponse => {
+ order.push('end')
+ return origEnd(data)
+ }
+ server.emitRequest(req, res)
+ assert.strictEqual(res.statusCode, 404)
+ assert.deepStrictEqual(order, ['end', 'destroy'])
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should emit explicit Content-Length on both HEAD and GET /metrics (RFC 9110 §9.3.2 parity)', async t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const getRes = new MockServerResponse()
+ const headRes = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), getRes)
+ server.emitRequest(buildWsMetricsRequest({ method: 'HEAD' }), headRes)
+ await drainResponses([getRes, headRes])
+ const getLen = Number(getRes.headers['Content-Length'])
+ const headLen = Number(headRes.headers['Content-Length'])
+ assert.strictEqual(
+ getLen,
+ Buffer.byteLength(getRes.body ?? '', 'utf8'),
+ 'GET Content-Length must equal body byte length'
+ )
+ assert.strictEqual(
+ headLen,
+ getLen,
+ 'HEAD Content-Length must equal GET length (RFC 9110 §9.3.2)'
+ )
+ assert.strictEqual(headRes.body, undefined, 'HEAD body must be empty')
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should treat req.method=undefined as non-metrics (isMetricsRequest enforces GET/HEAD)', t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const req = buildWsMetricsRequest()
+ ;(req as { method?: string }).method = undefined
+ const res = new MockServerResponse()
+ server.emitRequest(req, res)
+ assert.notStrictEqual(res.statusCode, 200)
+ assert.strictEqual(res.statusCode, 404)
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should return 404 on /metrics after stop() clears the registry', t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ server.stop()
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), res)
+ assert.strictEqual(res.statusCode, 404)
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should NOT call req.destroy() on the 404 fallback when req.complete === true (HTTP/1.1)', t => {
+ const server = new TestableUIWebSocketServer(createWsMetricsConfig())
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ let destroyCount = 0
+ const req = buildWsMetricsRequest({ complete: true, url: '/unknown' })
+ ;(req as IncomingMessage & { destroy: () => IncomingMessage }).destroy = () => {
+ destroyCount++
+ return req
+ }
+ const res = new MockServerResponse()
+ server.emitRequest(req, res)
+ assert.strictEqual(res.statusCode, 404)
+ assert.strictEqual(
+ destroyCount,
+ 0,
+ 'req.destroy() must NOT be called when req.complete === true'
+ )
+ } finally {
+ server.stop()
+ }
+ })
+ })
})