| 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 |
+| $schemaVersion | 2 | 2 | integer | Configuration schema version. Set to 2. 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 (`ws://`, `wss://`, `http://`, `https://` or `ws+unix://`) |
| 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 |
{
- "$schemaVersion": 1,
+ "$schemaVersion": 2,
"supervisionUrls": ["ws://localhost:8010/OCPP16/5be7fb271014d90008992f06"],
"supervisionUrlDistribution": "round-robin",
"log": {
import { z } from 'zod'
import { ProcedureName } from '../../../types/index.js'
-import { isEmpty } from '../../../utils/index.js'
+import { isEmpty, SupervisionUrlSchema } from '../../../utils/index.js'
/**
* Omit to broadcast. An explicit empty array targets no station and is rejected
.describe('Target physical connector IDs')
/**
- * Validate URL syntax here; the supervision transport checks scheme support
- * at connection time. Avoid throwing refinements so malformed URLs remain
- * validation failures rather than exceptions.
+ * Validate URL syntax and scheme here so a value the supervision transport
+ * cannot dial is rejected at the gate instead of failing per station at
+ * connection time. Shared with the configuration file `supervisionUrls`.
*/
-export const urlField = z.url()
+export const urlField = SupervisionUrlSchema
/** Supervision URL(s) of a charging station, single or array form. */
const supervisionUrlsField = z.union([urlField, z.array(urlField)])
* Bump only on breaking changes (field rename, removal, type narrowing).
* Single authoritative location — concurrent bumps force git merge conflict.
*/
-export const CURRENT_CONFIGURATION_SCHEMA_VERSION = 1
+export const CURRENT_CONFIGURATION_SCHEMA_VERSION = 2
export type MigrationFn = (
config: Record<string, unknown>,
*/
const migrateV0ToV1: MigrationFn = (config, _filePath) => clone(config)
+/**
+ * v1 → v2: pure version-bump. v2 narrows `supervisionUrls` to URLs whose
+ * scheme the `ws` client can dial; values outside that set cannot be
+ * rewritten automatically and are reported by schema validation.
+ * `$schemaVersion` is stamped by `applyConfigurationMigration`.
+ * @param config - source configuration object
+ * @param _filePath - configuration file path (unused)
+ * @returns new configuration object
+ */
+const migrateV1ToV2: MigrationFn = (config, _filePath) => clone(config)
+
/**
* Sequential migration chain. Index `i` migrates a v`i` configuration to v`i+1`.
* To add schema version N+1: append one `migrateV{N}ToV{N+1}` function and
* bump `CURRENT_CONFIGURATION_SCHEMA_VERSION`.
*/
-export const migrationChain: readonly MigrationFn[] = [migrateV0ToV1]
+export const migrationChain: readonly MigrationFn[] = [migrateV0ToV1, migrateV1ToV2]
/**
* Strict integer-string pattern for `$schemaVersion`. Rejects permissive
})
.strict()
+/**
+ * Supervision URL — a URL that the `ws` WebSocket client can dial: scheme
+ * `ws:`, `wss:`, `http:`, `https:` or `ws+unix:`, no fragment identifier, and
+ * a socket path for `ws+unix:`. A scheme-less `host:port/path` string parses
+ * as a URL with the host as its scheme, so syntax validation alone would
+ * accept a value that fails at connection time.
+ * Shared by the configuration file and the UI request payload gate so a value
+ * is accepted by both or neither.
+ */
+export const SupervisionUrlSchema = z
+ .url({
+ error: 'must be a WebSocket URL (ws://, wss://, http://, https:// or ws+unix://)',
+ protocol: /^(?:wss?|https?|ws\+unix)$/,
+ })
+ .refine(value => !URL.canParse(value) || new URL(value).hash === '', {
+ message: 'must not contain a fragment identifier (#...)',
+ })
+ .refine(
+ value => {
+ if (!URL.canParse(value)) {
+ return true
+ }
+ const url = new URL(value)
+ return url.protocol !== 'ws+unix:' || url.pathname !== ''
+ },
+ {
+ message: 'ws+unix:// URLs must include the socket path',
+ }
+ )
+
// ---------------------------------------------------------------
// Top-level configuration schema
// ---------------------------------------------------------------
.union([z.string(), z.array(z.string())])
.optional()
.describe("@deprecated: use 'supervisionUrls' instead"),
- supervisionUrls: z.union([z.string(), z.array(z.string())]).optional(),
+ supervisionUrls: z.union([SupervisionUrlSchema, z.array(SupervisionUrlSchema)]).optional(),
uiServer: UIServerConfigurationSchema.optional(),
uiWebSocketServer: z.unknown().optional().describe("@deprecated: use 'uiServer' instead"),
useWorkerPool: z.boolean().optional().describe("@deprecated: use 'worker.processType' instead"),
LogConfigurationSchema,
StationTemplateUrlSchema,
StorageConfigurationSchema,
+ SupervisionUrlSchema,
UI_SERVER_ACCESS_POLICY_DEFAULTS,
UIServerConfigurationSchema,
UIServerMetricsConfigurationSchema,
}
})
+ await it('should reject a supervision url whose scheme the WebSocket client cannot dial', async () => {
+ const { service } = createServiceContext(2)
+
+ try {
+ for (const url of ['localhost:8010/OCPP16', 'ftp://localhost:8010/OCPP16']) {
+ assertRejectedWith(
+ await dispatchUntrustedPayload(service, ProcedureName.SET_SUPERVISION_URL, {
+ hashIds: [TEST_HASH_ID],
+ url,
+ }),
+ /url.*must be a WebSocket URL/
+ )
+ }
+ assert.strictEqual(service.getBroadcastChannelOutstandingResponseCount(TEST_UUID), 0)
+ } finally {
+ service.stop()
+ }
+ })
+
await it('should accept a websocket supervision url', async () => {
const { service } = createServiceContext(2)
await describe('CURRENT_CONFIGURATION_SCHEMA_VERSION', async () => {
await it('should be a positive integer', () => {
assert.ok(Number.isInteger(CURRENT_CONFIGURATION_SCHEMA_VERSION))
- assert.strictEqual(CURRENT_CONFIGURATION_SCHEMA_VERSION, 1)
+ assert.strictEqual(CURRENT_CONFIGURATION_SCHEMA_VERSION, 2)
})
})
assert.strictEqual(result.foo, 'bar')
})
+ await it('should migrate a v1 configuration to CURRENT without changing its values', () => {
+ const result = applyConfigurationMigration(
+ 1,
+ { foo: 'bar', supervisionUrls: 'ws://localhost:8010/OCPP16' },
+ 'test.json'
+ )
+ assert.strictEqual(result.$schemaVersion, CURRENT_CONFIGURATION_SCHEMA_VERSION)
+ assert.strictEqual(result.foo, 'bar')
+ assert.strictEqual(result.supervisionUrls, 'ws://localhost:8010/OCPP16')
+ })
+
await it('should not mutate the input config (immutability boundary)', () => {
const input = { foo: 'bar' }
const before = JSON.stringify(input)
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { afterEach, describe, it } from 'node:test'
+import { WebSocket } from 'ws'
import {
CURRENT_CONFIGURATION_SCHEMA_VERSION,
} from '../../src/utils/index.js'
import {
ConfigurationSchema,
+ SupervisionUrlSchema,
UI_SERVER_ACCESS_POLICY_DEFAULTS,
WorkerConfigurationSchema,
} from '../../src/utils/index.js'
})
})
+ await describe('SupervisionUrlSchema', async () => {
+ const acceptedUrls = [
+ 'ws://localhost:8010/OCPP16',
+ 'wss://localhost:8010/OCPP16',
+ 'http://localhost:8010/OCPP16',
+ 'https://localhost:8010/OCPP16',
+ 'ws+unix:///tmp/simulator.sock',
+ 'ws+unix:///tmp/simulator.sock:/OCPP16',
+ 'ws:///OCPP16',
+ ]
+ const rejectedUrls = [
+ 'localhost:8010/OCPP16',
+ 'ftp://localhost:8010/OCPP16',
+ 'file:///tmp/simulator.sock',
+ 'not-a-url',
+ '/relative',
+ ]
+
+ await it('should accept every scheme the ws client can dial', () => {
+ for (const url of acceptedUrls) {
+ assert.strictEqual(SupervisionUrlSchema.safeParse(url).success, true, url)
+ }
+ })
+
+ await it('should reject values the ws client cannot dial with a message naming the accepted schemes', () => {
+ for (const url of rejectedUrls) {
+ const result = SupervisionUrlSchema.safeParse(url)
+ if (result.success) {
+ assert.fail(`Expected '${url}' to be rejected`)
+ }
+ assert.match(result.error.issues[0].message, /ws:\/\/, wss:\/\//)
+ }
+ })
+
+ await it('should reject a URL with a fragment identifier', () => {
+ const result = SupervisionUrlSchema.safeParse('ws://localhost:8010/OCPP16#fragment')
+ if (result.success) {
+ assert.fail('Expected a URL with a fragment identifier to be rejected')
+ }
+ assert.match(result.error.issues[0].message, /fragment identifier/)
+ })
+
+ await it('should reject a ws+unix URL without a socket path', () => {
+ for (const url of ['ws+unix://', 'ws+unix:']) {
+ const result = SupervisionUrlSchema.safeParse(url)
+ if (result.success) {
+ assert.fail(`Expected '${url}' to be rejected`)
+ }
+ assert.match(result.error.issues[0].message, /socket path/)
+ }
+ })
+
+ await it('should match the URLs accepted by the ws client', () => {
+ const edgeCaseUrls = [
+ 'ws://localhost:8010/OCPP16#fragment',
+ 'wss://localhost:8010/OCPP16#',
+ 'ws+unix://',
+ 'ws+unix:',
+ ]
+ for (const url of [
+ ...acceptedUrls,
+ ...rejectedUrls.filter(url => URL.canParse(url)),
+ ...edgeCaseUrls,
+ ]) {
+ let wsAccepts = true
+ try {
+ const webSocket = new WebSocket(url)
+ webSocket.on('error', () => undefined)
+ webSocket.terminate()
+ } catch {
+ wsAccepts = false
+ }
+ assert.strictEqual(SupervisionUrlSchema.safeParse(url).success, wsAccepts, url)
+ }
+ })
+ })
+
await describe('mixed-type fields', async () => {
await it('should accept supervisionUrls as string', () => {
const result = ConfigurationSchema.safeParse(
assert.ok(result.success)
})
+ await it('should reject a scheme-less supervisionUrls string', () => {
+ const result = ConfigurationSchema.safeParse(
+ buildMinimalConfiguration({ supervisionUrls: 'localhost:8010/OCPP16' })
+ )
+ assert.strictEqual(result.success, false)
+ })
+
+ await it('should reject a supervisionUrls array entry with an unsupported scheme', () => {
+ const result = ConfigurationSchema.safeParse(
+ buildMinimalConfiguration({
+ supervisionUrls: [TEST_SUPERVISION_URL, 'ftp://localhost:8010/OCPP16'],
+ })
+ )
+ assert.strictEqual(result.success, false)
+ })
+
await it('should accept worker.elementsPerWorker as auto', () => {
const result = ConfigurationSchema.safeParse(
buildMinimalConfiguration({ worker: { elementsPerWorker: 'auto' } })
)
})
- await it('should accept string "$schemaVersion": "1" (no-migration path)', () => {
- const parsed = buildMinimalConfiguration({ $schemaVersion: '1' })
+ await it('should accept string "$schemaVersion" equal to CURRENT (no-migration path)', () => {
+ const parsed = buildMinimalConfiguration({
+ $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION.toString(),
+ })
const result = validateConfiguration(parsed, 'string-version.json')
)
})
+ await it('should migrate a valid v1 configuration to the current schema version', () => {
+ const parsed = buildMinimalConfiguration({
+ $schemaVersion: 1,
+ supervisionUrls: 'ws://localhost:8010/OCPP16',
+ })
+
+ const result = validateConfiguration(parsed, 'v1.json')
+
+ assert.strictEqual(
+ (result as unknown as Record<string, unknown>).$schemaVersion,
+ CURRENT_CONFIGURATION_SCHEMA_VERSION
+ )
+ assert.strictEqual(result.supervisionUrls, 'ws://localhost:8010/OCPP16')
+ })
+
+ await it('should report a v1 supervisionUrls value the ws client cannot dial', () => {
+ const parsed = buildMinimalConfiguration({
+ $schemaVersion: 1,
+ supervisionUrls: 'localhost:8010/OCPP16',
+ })
+
+ assert.throws(
+ () => validateConfiguration(parsed, 'v1-invalid.json'),
+ (error: unknown) =>
+ error instanceof ConfigurationValidationError &&
+ error.migratedFrom === 1 &&
+ error.fieldErrors.some(fieldError => fieldError.path === 'supervisionUrls')
+ )
+ })
+
await it('should sweep deprecated keys unconditionally for v1 configs', t => {
t.mock.method(console, 'warn', () => undefined)
const parsed = buildV1WithDeprecatedKey('workerPoolSize', 16)
await describe('error message snapshot', async () => {
await it('should match error message snapshot', () => {
try {
- validateConfiguration({ $schemaVersion: 1 }, 'test.json')
+ validateConfiguration({ $schemaVersion: CURRENT_CONFIGURATION_SCHEMA_VERSION }, 'test.json')
assert.fail('Expected ConfigurationValidationError')
} catch (error) {
assert.ok(error instanceof ConfigurationValidationError)