]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui-server): expose Prometheus /metrics on ws and mcp transports (#1921)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Tue, 23 Jun 2026 22:35:14 +0000 (00:35 +0200)
committerGitHub <noreply@github.com>
Tue, 23 Jun 2026 22:35:14 +0000 (00:35 +0200)
The opt-in Prometheus `/metrics` endpoint introduced by #1912 was wired
only into `UIHttpServer`. With `uiServer.type` set to `ws` or `mcp`, the
endpoint was silently disabled with a startup warning.

Move the metrics registry, the request handler, and the soft-cap
accounting from `UIHttpServer` up to `AbstractUIServer`, then expose
`/metrics` on the shared `httpServer` from `UIWebSocketServer` and
`UIMCPServer` via a single `tryServeMetrics` template method that
encapsulates the `metrics.enabled` gate, the path predicate, the
authentication step, and the scrape handler. The
`uiServer.metrics.{enabled,softSampleCap}` configuration block is
preserved; the gauge registry is re-used unchanged; and `accessPolicy`,
rate-limiting, and `authentication` apply identically on all three
transports.

The lifecycle is bracketed by a public `start()` template-method on
`AbstractUIServer`; subclasses implement the `attachTransport()`
protected hook and may override the symmetric `detachTransport()` hook.
A one-shot `transportAttached` guard is set BEFORE `attachTransport()`
runs so a throwing hook cannot leave a half-attached server admitting a
silent retry; on failure any partially-registered `'request'` /
`'upgrade'` listeners on `httpServer` are stripped and the guard is
rolled back. `metricsScrapeChain` is reseated to `Promise.resolve()`
at every `start()` so cycle N+1 never awaits cycle N's scheduled
registry clear; in `runMetricsScrape`, the per-scrape
`metricsSampleCount` is captured into a local snapshot BEFORE
`await registry.metrics()` yields, so a peer scrape dispatched after
a sync `stop()`/`start()` cannot overwrite the count the soft-cap
branch reads. The corresponding `stop()` is symmetrically defensive:
both `detachTransport()` and each `uiService.stop()` are wrapped in
per-iteration `try/catch` so a throwing override does not abort
downstream teardown (`stopHttpServer`, remaining services, handlers,
caches), and the `transportAttached = false` reset runs in a `finally`
block so a subsequent `start()` is never permanently locked out — any
other unexpected throw still propagates. A shared
`renderNotFoundAndDestroy(req, res)` helper consolidates the 404
fallback used by the WebSocket and MCP request listeners; the helper
delegates socket teardown to `destroyHttp1SocketIfPending(req)` which
skips `req.destroy()` on HTTP/2 streams (lifecycle owned by the
`Http2Stream`) and on already-complete HTTP/1.1 requests (keep-alive
pool).

No new configuration surface, no new listener: `/metrics` is served on
the same `Http2Server | Server` instance that already backs the
WebSocket protocol upgrade in `UIWebSocketServer` and the `/mcp` route
in `UIMCPServer`. HEAD responses emit an explicit `Content-Length`
equal to the GET body byte length (`Buffer.byteLength(body, 'utf8')`)
and an empty body, per RFC 9110 §9.3.2. A frozen
`METRICS_ALLOWED_LABEL_NAMES` tuple pins the canonical PII surface; the
`isMetricsAllowedLabelName` type-guard predicate is the O(1) lookup,
and a guardian spec asserts every gauge label name in the rendered
registry is admitted.

Operator-visible change: `uiServer.metrics.enabled=true` with
`type='ws'` or `'mcp'` now serves `/metrics` whereas the previous
release silently disabled it. Operators relying on the silent disable
must set `metrics.enabled=false` (or omit the block) and audit firewall
posture on the UI server port. `HEAD /metrics` (e.g. `curl -I`) returns
identical headers to `GET` (including `Content-Length`) with an empty
body. README updated.

Closes #1917
Refs #851, #1912

README.md
src/charging-station/ui-server/AbstractUIServer.ts
src/charging-station/ui-server/UIHttpServer.ts
src/charging-station/ui-server/UIMCPServer.ts
src/charging-station/ui-server/UIWebSocketServer.ts
src/utils/ConfigurationSchema.ts
tests/charging-station/ui-server/UIHttpServer.test.ts
tests/charging-station/ui-server/UIMCPServer.test.ts
tests/charging-station/ui-server/UIMetricsEndpoint.test.ts
tests/charging-station/ui-server/UIServerTestUtils.ts
tests/charging-station/ui-server/UIWebSocketServer.test.ts

index fa2c5dd73fe03f806e5207ba495c82269fef2730..fd5e1cb9cdf32f109089a4ca9450106cd462d47e 100644 (file)
--- a/README.md
+++ b/README.md
@@ -173,17 +173,17 @@ But the modifications to test have to be done to the files in the build target d
 
 **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 />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br />&nbsp;&nbsp;- _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _enabled_: enable the `/metrics` endpoint<br />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br />&nbsp;&nbsp;- _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _enabled_: enable the `/metrics` endpoint<br />&nbsp;&nbsp;- _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
 
@@ -196,6 +196,10 @@ But the modifications to test have to be done to the files in the build target d
 - **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**:
index a86e03409dd8c94a1d7b071be935207a4ee3212f..802d6860c650f4c979064586cf744ca6fef861d0 100644 (file)
@@ -3,17 +3,19 @@ import type { WebSocket } from 'ws'
 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,
@@ -38,7 +40,7 @@ import {
   DEFAULT_RATE_WINDOW_MS,
   isValidCredential,
 } from './UIServerSecurity.js'
-import { getUsernameAndPasswordFromAuthorizationToken } from './UIServerUtils.js'
+import { getUsernameAndPasswordFromAuthorizationToken, HttpMethod } from './UIServerUtils.js'
 
 /**
  * Outcome of {@link AbstractUIServer.runRequestPrologue}.
@@ -67,6 +69,86 @@ const HTTP_REQUEST_TIMEOUT_MS = 30_000
 
 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>
@@ -81,6 +163,31 @@ export abstract class AbstractUIServer {
   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,
@@ -218,19 +325,129 @@ export abstract class AbstractUIServer {
     }
   }
 
-  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
@@ -244,6 +461,57 @@ export abstract class AbstractUIServer {
     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.
    *
@@ -259,6 +527,17 @@ export abstract class AbstractUIServer {
       : { 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
@@ -271,6 +550,59 @@ export abstract class AbstractUIServer {
     }
   }
 
+  /**
+   * 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
   }
@@ -299,6 +631,24 @@ export abstract class AbstractUIServer {
       .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.
    *
@@ -356,6 +706,484 @@ export abstract class AbstractUIServer {
     }
   }
 
+  /**
+   * 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 &&
@@ -407,10 +1235,72 @@ export abstract class AbstractUIServer {
     )
   }
 
+  /**
+   * 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()
     }
   }
 
@@ -423,20 +1313,6 @@ export abstract class AbstractUIServer {
     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(
@@ -456,3 +1332,253 @@ export abstract class AbstractUIServer {
     }
   }
 }
+
+/**
+ * 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,
+  })
+}
index c62fc9bedd7aae1ee5ab5ec49c5b59b91c736b7e..ff6dda0d5da5d206b264124cdf8b2d13a6dbbece 100644 (file)
@@ -2,9 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
 
 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'
@@ -33,30 +31,6 @@ import { HttpMethod, isProtocolAndVersionSupported } from './UIServerUtils.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.
  */
@@ -64,19 +38,6 @@ export class UIHttpServer extends AbstractUIServer {
   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,
@@ -137,528 +98,11 @@ export class UIHttpServer extends AbstractUIServer {
   }
 
   /**
-   * @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 (
@@ -697,41 +141,17 @@ export class UIHttpServer extends AbstractUIServer {
     }
   }
 
-  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
     }
 
@@ -812,253 +232,3 @@ export class UIHttpServer extends AbstractUIServer {
     }
   }
 }
-
-/**
- * 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,
-  })
-}
index fd302a4362ce6cf371af7a833c784a98ef20cecb..9895d1088a4e70a8f7ea7bc7ad5c3613df68cadb 100644 (file)
@@ -109,7 +109,16 @@ export class UIMCPServer extends AbstractUIServer {
     }
   }
 
-  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)
@@ -121,18 +130,12 @@ export class UIMCPServer extends AbstractUIServer {
         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
       }
 
@@ -148,17 +151,6 @@ export class UIMCPServer extends AbstractUIServer {
         )
       })
     })
-
-    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 {
index 2130e4f921d53ecd0a1e2255fdb5f0479e14f48d..be82f942522380e389804b792cf68d25f4a364d0 100644 (file)
@@ -1,4 +1,4 @@
-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'
@@ -124,7 +124,7 @@ export class UIWebSocketServer extends AbstractUIServer {
     }
   }
 
-  public start (): void {
+  protected attachTransport (): void {
     this.webSocketServer.on('connection', (ws: WebSocket, _req: IncomingMessage): void => {
       const protocol = ws.protocol
       const protocolAndVersion = getProtocolAndVersion(protocol)
@@ -182,6 +182,17 @@ export class UIWebSocketServer extends AbstractUIServer {
         }
       })
     })
+    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(
@@ -249,7 +260,17 @@ export class UIWebSocketServer extends AbstractUIServer {
         )
       }
     })
-    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 {
index 919ca3a427544cbabb7b86795656c946d4699d33..c24625e1bd6e8bd67e8c8dfb5d36cf39c36ebe5a 100644 (file)
@@ -214,8 +214,11 @@ const UIServerListenOptionsSchema = z
 
 /**
  * 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
index 22dca1059df441a363c31d6957afcdefcd6d6e1b..117189916b3b571de4003973d4b2e24d4cb00e0c 100644 (file)
@@ -224,7 +224,6 @@ await describe('UIHttpServer', async () => {
     const res = new MockServerResponse()
 
     try {
-      // eslint-disable-next-line @typescript-eslint/no-deprecated
       gatedServer.start()
       httpServer.emit('request', req, res)
     } finally {
@@ -269,7 +268,6 @@ await describe('UIHttpServer', async () => {
     const res = new MockServerResponse()
 
     try {
-      // eslint-disable-next-line @typescript-eslint/no-deprecated
       gatedServer.start()
       httpServer.emit('request', denyingReq, res)
     } finally {
index df26b7a42d622e0a7e01950ca73417272cf248c7..bc3adb8cd148c3bdcb7e974eb489a87fab5d86cc 100644 (file)
@@ -10,13 +10,14 @@ import { StatusCodes } from 'http-status-codes'
 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'
 
@@ -32,6 +33,7 @@ import {
 } from '../../../src/charging-station/ui-server/UIServerSecurity.js'
 import {
   ApplicationProtocol,
+  AuthenticationType,
   OCPPVersion,
   ProcedureName,
   ResponseStatus,
@@ -44,11 +46,14 @@ import {
 } 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'
 
@@ -120,6 +125,13 @@ class TestableUIMCPServer extends UIMCPServer {
     ).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
     | {
@@ -154,6 +166,11 @@ class TestableUIMCPServer extends UIMCPServer {
     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)),
@@ -171,6 +188,43 @@ class TestableUIMCPServer extends UIMCPServer {
 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
@@ -932,4 +986,300 @@ await describe('UIMCPServer', async () => {
       }
     })
   })
+
+  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()
+      }
+    })
+  })
 })
index 34f994e2f2b2291ceeacdd103d8f9a242f511b63..1297850a5d8efb87ef614a7518f984a0f5f75c26 100644 (file)
@@ -3,8 +3,9 @@
  * @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'
@@ -16,12 +17,16 @@ import type {
   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,
@@ -58,8 +63,9 @@ class TestableUIHttpServer extends UIHttpServer {
     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 {
@@ -141,6 +147,24 @@ const buildMetricsRequest = (overrides: Partial<IncomingMessage> = {}): Incoming
     ...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
 
@@ -156,7 +180,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
 
   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)
@@ -174,7 +198,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     enrichBootstrap(plainServer)
     plainServer.mockListen(t)
     try {
-      // eslint-disable-next-line @typescript-eslint/no-deprecated
       plainServer.start()
       const res = new MockServerResponse()
       plainServer.emitRequest(buildMetricsRequest(), res)
@@ -194,7 +217,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     enrichBootstrap(offServer)
     offServer.mockListen(t)
     try {
-      // eslint-disable-next-line @typescript-eslint/no-deprecated
       offServer.start()
       const res = new MockServerResponse()
       offServer.emitRequest(buildMetricsRequest(), res)
@@ -206,7 +228,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
 
   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)
@@ -222,7 +244,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
   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)
@@ -236,7 +258,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
   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)
@@ -253,7 +275,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
 
   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)
@@ -275,7 +297,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     enrichBootstrap(gatedServer)
     gatedServer.mockListen(t)
     try {
-      // eslint-disable-next-line @typescript-eslint/no-deprecated
       gatedServer.start()
       const res = new MockServerResponse()
       gatedServer.emitRequest(
@@ -293,7 +314,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
 
   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++) {
@@ -321,7 +342,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     enrichBootstrap(authServer)
     authServer.mockListen(t)
     try {
-      // eslint-disable-next-line @typescript-eslint/no-deprecated
       authServer.start()
       const res = new MockServerResponse()
       authServer.emitRequest(buildMetricsRequest(), res)
@@ -346,7 +366,6 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     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()
@@ -399,7 +418,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
       })
     )
     server.mockListen(t)
-    // eslint-disable-next-line @typescript-eslint/no-deprecated
+
     server.start()
     const res = new MockServerResponse()
     server.emitRequest(buildMetricsRequest(), res)
@@ -439,7 +458,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
       })
     )
     server.mockListen(t)
-    // eslint-disable-next-line @typescript-eslint/no-deprecated
+
     server.start()
     const res = new MockServerResponse()
     server.emitRequest(buildMetricsRequest(), res)
@@ -457,7 +476,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
 
   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)
@@ -467,7 +486,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
 
   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)
@@ -482,7 +501,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     }
     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)
@@ -490,10 +509,10 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     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`
     )
   })
@@ -501,7 +520,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
   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)
@@ -540,7 +559,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
       })
     )
     server.mockListen(t)
-    // eslint-disable-next-line @typescript-eslint/no-deprecated
+
     server.start()
     const res = new MockServerResponse()
     server.emitRequest(buildMetricsRequest(), res)
@@ -572,7 +591,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
       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)
@@ -593,14 +612,14 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
       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(
@@ -621,14 +640,14 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
       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(
@@ -653,7 +672,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
       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)
@@ -673,7 +692,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
       concurrentServer.addStation(buildStationData(`station-T20-${i.toString()}`))
     }
     concurrentServer.mockListen(t)
-    // eslint-disable-next-line @typescript-eslint/no-deprecated
+
     concurrentServer.start()
 
     const resA = new MockServerResponse()
@@ -701,7 +720,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     )
     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,
@@ -710,7 +729,7 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
     )
   })
 
-  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({
@@ -724,17 +743,1005 @@ await describe('UIHttpServer /metrics endpoint (issue #851)', async () => {
       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()
+    }
+  })
 })
index f7a9f2c62c4c99d1c8c546970bd073f6d4c2f2a1..fca39ea9d1204025f242cbd62e6576cee860250d 100644 (file)
@@ -4,8 +4,9 @@
  */
 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 {
@@ -61,6 +62,14 @@ export class TestableUIWebSocketServer extends UIWebSocketServer {
     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)
   }
@@ -82,6 +91,15 @@ export class TestableUIWebSocketServer extends UIWebSocketServer {
     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
@@ -217,7 +235,9 @@ export class MockServerResponse extends EventEmitter {
   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 {
@@ -233,6 +253,7 @@ export class MockServerResponse extends EventEmitter {
       this.body = this.bodyBuffer.toString('binary')
     }
     this.ended = true
+    this.writableEnded = true
     this.emit('finish')
     return this
   }
@@ -258,6 +279,7 @@ export class MockServerResponse extends EventEmitter {
     if (headers != null) {
       this.headers = headers
     }
+    this.headersSent = true
     return this
   }
 }
@@ -334,6 +356,101 @@ export const createProtocolRequest = (
   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.
  */
index 1700b1b4dea08d047400613467b8f7d4115b16a9..bdb27fca9138cfb5eeb3b961218bc04a55c77ba8 100644 (file)
  * @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()
@@ -335,4 +423,419 @@ await describe('UIWebSocketServer', async () => {
       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()
+      }
+    })
+  })
 })