}
const responses = this.responses.get(uuid)
if (responses != null && responses.responsesReceived >= responses.responsesExpected) {
- this.uiService.sendResponse(uuid, this.buildResponsePayload(uuid))
- this.responses.delete(uuid)
- this.uiService.deleteBroadcastChannelRequest(uuid)
+ // Always release aggregation state, even if downstream sendResponse throws.
+ try {
+ this.uiService.sendResponse(uuid, this.buildResponsePayload(uuid))
+ } finally {
+ this.responses.delete(uuid)
+ this.uiService.deleteBroadcastChannelRequest(uuid)
+ }
}
}
}
ProtocolVersion,
type RequestPayload,
type ResponsePayload,
+ UIRequestOrigin,
type UIServerConfiguration,
type UUIDv4,
} from '../../types/index.js'
this.registerProtocolVersionUIService(protocolVersion)
return await (this.uiServices
.get(protocolVersion)
- ?.requestHandler(request) as Promise<ProtocolResponse>)
+ ?.requestHandler(request, { origin: UIRequestOrigin.INTERNAL }) as Promise<ProtocolResponse>)
}
public abstract sendRequest (request: ProtocolRequest): void
type ResponsePayload,
ResponseStatus,
type StorageConfiguration,
+ type UIRequestContext,
+ UIRequestOrigin,
type UUIDv4,
} from '../../../types/index.js'
import {
Configuration,
ensureError,
getErrorMessage,
- isAsyncFunction,
isNotEmptyArray,
logger,
} from '../../../utils/index.js'
const moduleName = 'AbstractUIService'
+/**
+ * Structured payload of the `logger.{debug,warn}` calls emitted by
+ * {@link AbstractUIService.logBroadcastResponseWithoutHandler}. Fields are
+ * optional when the corresponding context is unavailable (untracked or
+ * late responses, payload without hashIds).
+ */
+export interface BroadcastChannelResponseLogContext {
+ readonly hashIdsFailed?: string[]
+ readonly hashIdsSucceeded?: string[]
+ readonly origin?: UIRequestOrigin
+ readonly procedureName?: BroadcastChannelProcedureName
+ readonly status: ResponseStatus
+ readonly uuid: UUIDv4
+}
+
interface AddChargingStationsRequestPayload extends RequestPayload {
numberOfStations: number
options?: ChargingStationOptions
template: string
}
+interface BroadcastChannelRequestContext {
+ readonly expectedResponses: number
+ readonly origin: UIRequestOrigin
+ readonly procedureName: BroadcastChannelProcedureName
+}
+
export abstract class AbstractUIService {
protected static readonly ProcedureNameToBroadCastChannelProcedureNameMapping = new Map<
ProcedureName,
])
protected readonly requestHandlers: Map<ProcedureName, ProtocolRequestHandler>
- private readonly broadcastChannelRequests: Map<UUIDv4, number>
+ private readonly broadcastChannelRequests: Map<UUIDv4, BroadcastChannelRequestContext>
+ private stopped = false
private readonly uiServer: AbstractUIServer
private readonly uiServiceWorkerBroadcastChannel: UIServiceWorkerBroadcastChannel
[ProcedureName.STOP_SIMULATOR, this.handleStopSimulator.bind(this)],
])
this.uiServiceWorkerBroadcastChannel = new UIServiceWorkerBroadcastChannel(this)
- this.broadcastChannelRequests = new Map<UUIDv4, number>()
+ this.broadcastChannelRequests = new Map<UUIDv4, BroadcastChannelRequestContext>()
}
public deleteBroadcastChannelRequest (uuid: UUIDv4): void {
}
public getBroadcastChannelExpectedResponses (uuid: UUIDv4): number {
- return this.broadcastChannelRequests.get(uuid) ?? 0
+ return this.broadcastChannelRequests.get(uuid)?.expectedResponses ?? 0
}
public logPrefix = (modName: string, methodName: string): string => {
return this.uiServer.logPrefix(modName, methodName, this.version)
}
- public async requestHandler (request: ProtocolRequest): Promise<ProtocolResponse | undefined> {
+ public async requestHandler (
+ request: ProtocolRequest,
+ context: UIRequestContext = { origin: UIRequestOrigin.TRANSPORT }
+ ): Promise<ProtocolResponse | undefined> {
let uuid: undefined | UUIDv4
let command: ProcedureName | undefined
let requestPayload: RequestPayload | undefined
if (requestHandler == null) {
throw new BaseError(`'${command}' request handler not found`)
}
- if (isAsyncFunction(requestHandler)) {
- responsePayload = await requestHandler(uuid, command, requestPayload)
- } else {
- responsePayload = (
- requestHandler as (
- uuid?: string,
- procedureName?: ProcedureName,
- payload?: RequestPayload
- ) => ResponsePayload | undefined
- )(uuid, command, requestPayload)
- }
+ responsePayload = await requestHandler(uuid, command, requestPayload, context)
} catch (error) {
// Log
logger.error(`${this.logPrefix(moduleName, 'requestHandler')} Handle request error:`, error)
public sendResponse (uuid: UUIDv4, responsePayload: ResponsePayload): void {
if (this.uiServer.hasResponseHandler(uuid)) {
this.uiServer.sendResponse(this.uiServer.buildProtocolResponse(uuid, responsePayload))
- } else {
- logger.warn(`${this.logPrefix(moduleName, 'sendResponse')} Response handler not found:`, {
- responsePayload,
- uuid,
- })
+ return
}
+ this.logBroadcastResponseWithoutHandler(uuid, responsePayload)
}
public stop (): void {
+ this.stopped = true
this.broadcastChannelRequests.clear()
this.uiServiceWorkerBroadcastChannel.close()
}
protected handleProtocolRequest (
uuid: UUIDv4,
procedureName: ProcedureName,
- payload: RequestPayload
- ): void {
+ payload: RequestPayload,
+ context: UIRequestContext = { origin: UIRequestOrigin.TRANSPORT }
+ ): undefined {
const broadCastChannelProcedureName =
AbstractUIService.ProcedureNameToBroadCastChannelProcedureNameMapping.get(procedureName)
if (broadCastChannelProcedureName == null) {
throw new BaseError(`No broadcast channel mapping for procedure '${procedureName}'`)
}
- this.sendBroadcastChannelRequest(uuid, broadCastChannelProcedureName, payload)
+ this.sendBroadcastChannelRequest(uuid, broadCastChannelProcedureName, payload, context)
+ return undefined
+ }
+
+ private buildBroadcastResponseLogContext (
+ uuid: UUIDv4,
+ responsePayload: ResponsePayload,
+ requestContext?: BroadcastChannelRequestContext
+ ): BroadcastChannelResponseLogContext {
+ return {
+ ...(responsePayload.hashIdsFailed != null && {
+ hashIdsFailed: responsePayload.hashIdsFailed,
+ }),
+ ...(responsePayload.hashIdsSucceeded != null && {
+ hashIdsSucceeded: responsePayload.hashIdsSucceeded,
+ }),
+ ...(requestContext != null && {
+ origin: requestContext.origin,
+ procedureName: requestContext.procedureName,
+ }),
+ status: responsePayload.status,
+ uuid,
+ }
}
private async handleAddChargingStations (
}
}
+ private logBroadcastResponseWithoutHandler (uuid: UUIDv4, responsePayload: ResponsePayload): void {
+ const requestContext = this.broadcastChannelRequests.get(uuid)
+ const logContext = this.buildBroadcastResponseLogContext(uuid, responsePayload, requestContext)
+ if (requestContext == null) {
+ if (this.stopped) {
+ logger.debug(
+ `${this.logPrefix(moduleName, 'sendResponse')} Dropping late broadcast response after UI service stop:`,
+ logContext
+ )
+ } else {
+ logger.warn(
+ `${this.logPrefix(moduleName, 'sendResponse')} Dropping untracked broadcast response:`,
+ logContext
+ )
+ }
+ return
+ }
+ if (responsePayload.status === ResponseStatus.SUCCESS) {
+ logger.debug(
+ `${this.logPrefix(moduleName, 'sendResponse')} Broadcast response completed without response handler:`,
+ logContext
+ )
+ return
+ }
+ logger.warn(
+ `${this.logPrefix(moduleName, 'sendResponse')} Failed broadcast response completed without response handler:`,
+ logContext
+ )
+ }
+
private sendBroadcastChannelRequest (
uuid: UUIDv4,
procedureName: BroadcastChannelProcedureName,
- payload: BroadcastChannelRequestPayload
+ payload: BroadcastChannelRequestPayload,
+ context: UIRequestContext
): void {
if (isNotEmptyArray(payload.hashIds)) {
payload.hashIds = payload.hashIds
'hashIds array in the request payload does not contain any valid charging station hashId'
)
}
- this.uiServiceWorkerBroadcastChannel.sendRequest([uuid, procedureName, payload])
- this.broadcastChannelRequests.set(uuid, expectedNumberOfResponses)
+ this.broadcastChannelRequests.set(uuid, {
+ expectedResponses: expectedNumberOfResponses,
+ origin: context.origin,
+ procedureName,
+ })
+ // Rollback expected-response accounting if dispatch to the worker channel throws.
+ try {
+ this.uiServiceWorkerBroadcastChannel.sendRequest([uuid, procedureName, payload])
+ } catch (error) {
+ this.broadcastChannelRequests.delete(uuid)
+ throw error
+ }
}
}
import type { AbstractUIServer } from '../AbstractUIServer.js'
-import { type ProtocolRequestHandler, ProtocolVersion } from '../../../types/index.js'
+import { ProtocolVersion } from '../../../types/index.js'
import { AbstractUIService } from './AbstractUIService.js'
export class UIService001 extends AbstractUIService {
constructor (uiServer: AbstractUIServer) {
super(uiServer, ProtocolVersion['0.0.1'])
for (const procedureName of AbstractUIService.ProcedureNameToBroadCastChannelProcedureNameMapping.keys()) {
- this.requestHandlers.set(
- procedureName,
- this.handleProtocolRequest.bind(this) as ProtocolRequestHandler
- )
+ this.requestHandlers.set(procedureName, this.handleProtocolRequest.bind(this))
}
}
}
REFRESH = 'refresh',
}
+/**
+ * Origin of a UI service request. Drives broadcast-response classification:
+ * `INTERNAL` requests originate from `AbstractUIServer.sendInternalRequest`
+ * (e.g. `Bootstrap.doStop`) and have no transport-side response handler.
+ * `TRANSPORT` requests originate from a UI client (WebSocket/HTTP/MCP).
+ */
+export enum UIRequestOrigin {
+ INTERNAL = 'internal',
+ TRANSPORT = 'transport',
+}
+
export type ProtocolNotification = [ServerNotification]
export type ProtocolRequest = [UUIDv4, ProcedureName, RequestPayload]
+/**
+ * Signature of any UI service request handler stored in the dispatch map.
+ * Sync or async; may return a payload or nothing. The optional `context`
+ * carries the request origin so broadcast handlers can classify late responses.
+ */
export type ProtocolRequestHandler = (
- uuid?: UUIDv4,
- procedureName?: ProcedureName,
- payload?: RequestPayload
-) => Promise<ResponsePayload> | Promise<undefined> | ResponsePayload | undefined
+ uuid: UUIDv4,
+ procedureName: ProcedureName,
+ payload: RequestPayload,
+ context?: UIRequestContext
+) => Promise<ResponsePayload | undefined> | ResponsePayload | undefined
export type ProtocolResponse = [UUIDv4, ResponsePayload]
responsesFailed?: BroadcastChannelResponsePayload[]
status: ResponseStatus
}
+
+/**
+ * Context carried alongside a UI protocol request. Currently records only the
+ * request origin; additive fields are non-breaking.
+ */
+export interface UIRequestContext {
+ readonly origin: UIRequestOrigin
+}
type ResponsePayload,
ResponseStatus,
ServerNotification,
+ type UIRequestContext,
+ UIRequestOrigin,
} from './UIProtocol.js'
export type { UUIDv4 } from './UUID.js'
export {
import type { Duplex } from 'node:stream'
import type { mock } from 'node:test'
+import assert from 'node:assert/strict'
import { EventEmitter, once } from 'node:events'
import type { IBootstrap } from '../../../src/charging-station/IBootstrap.js'
+import type { BroadcastChannelResponseLogContext } from '../../../src/charging-station/ui-server/ui-services/AbstractUIService.js'
import type {
ChargingStationData,
ProcedureName,
templateName: 'test-template',
},
})
+
+interface LogMock {
+ readonly mock: {
+ readonly calls: readonly { readonly arguments: readonly unknown[] }[]
+ }
+}
+
+/**
+ * Assert that exactly one of the two `logger` levels was invoked with a
+ * message matching `pattern` and, optionally, a structured second argument
+ * deep-equal to `contextShape`.
+ * @param mocks - Tracked `logger.debug` and `logger.warn` mocks.
+ * @param mocks.debug - Mock tracking `logger.debug` invocations.
+ * @param mocks.warn - Mock tracking `logger.warn` invocations.
+ * @param level - Expected level for the single invocation.
+ * @param pattern - Regular expression the message must match.
+ * @param contextShape - Optional deep-equal shape for the second log argument.
+ */
+export const expectSingleLog = (
+ mocks: { readonly debug: LogMock; readonly warn: LogMock },
+ level: 'debug' | 'warn',
+ pattern: RegExp,
+ contextShape?: BroadcastChannelResponseLogContext
+): void => {
+ const [hit, miss] = level === 'debug' ? [mocks.debug, mocks.warn] : [mocks.warn, mocks.debug]
+ assert.strictEqual(miss.mock.calls.length, 0)
+ assert.strictEqual(hit.mock.calls.length, 1)
+ const [message, context] = hit.mock.calls[0]?.arguments ?? []
+ if (typeof message !== 'string') {
+ assert.fail(`Expected ${level} log message to be a string`)
+ }
+ assert.match(message, pattern)
+ if (contextShape != null) {
+ assert.deepStrictEqual(context, contextShape)
+ }
+}
import assert from 'node:assert/strict'
import { afterEach, describe, it } from 'node:test'
-import { ProcedureName, ProtocolVersion, ResponseStatus } from '../../../../src/types/index.js'
+import type { UIServiceWorkerBroadcastChannel } from '../../../../src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.js'
+import type { AbstractUIService } from '../../../../src/charging-station/ui-server/ui-services/AbstractUIService.js'
+
+import {
+ BroadcastChannelProcedureName,
+ ProcedureName,
+ ProtocolVersion,
+ ResponseStatus,
+ UIRequestOrigin,
+} from '../../../../src/types/index.js'
+import { logger } from '../../../../src/utils/Logger.js'
import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_HASH_ID, TEST_UUID } from '../UIServerTestConstants.js'
import {
createMockChargingStationData,
createMockUIServerConfiguration,
createProtocolRequest,
+ expectSingleLog,
TestableUIWebSocketServer,
} from '../UIServerTestUtils.js'
+const createServiceContext = (): {
+ readonly server: TestableUIWebSocketServer
+ readonly service: AbstractUIService
+} => {
+ const config = createMockUIServerConfiguration()
+ const server = new TestableUIWebSocketServer(config)
+ server.testRegisterProtocolVersionUIService(ProtocolVersion['0.0.1'])
+ server.setChargingStationData(TEST_HASH_ID, createMockChargingStationData(TEST_HASH_ID))
+ const service = server.getUIService(ProtocolVersion['0.0.1'])
+ if (service == null) {
+ assert.fail('Expected UI service to be registered')
+ }
+ return { server, service }
+}
+
+const registerInternalStopRequest = async (server: TestableUIWebSocketServer): Promise<void> => {
+ await server.sendInternalRequest(
+ server.buildProtocolRequest(TEST_UUID, ProcedureName.STOP_CHARGING_STATION, {})
+ )
+}
+
+const registerTransportStopRequest = async (service: AbstractUIService): Promise<void> => {
+ await service.requestHandler(
+ createProtocolRequest(TEST_UUID, ProcedureName.STOP_CHARGING_STATION, {})
+ )
+}
+
await describe('AbstractUIService', async () => {
afterEach(() => {
standardCleanup()
}
})
+ await it('should log internal successful broadcast responses without response handler at debug', async t => {
+ const mocks = {
+ debug: t.mock.method(logger, 'debug', () => undefined),
+ warn: t.mock.method(logger, 'warn', () => undefined),
+ }
+ const { server, service } = createServiceContext()
+
+ try {
+ await registerInternalStopRequest(server)
+ service.sendResponse(TEST_UUID, {
+ hashIdsSucceeded: [TEST_HASH_ID],
+ status: ResponseStatus.SUCCESS,
+ })
+
+ expectSingleLog(mocks, 'debug', /Broadcast response completed without response handler/, {
+ hashIdsSucceeded: [TEST_HASH_ID],
+ origin: UIRequestOrigin.INTERNAL,
+ procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION,
+ status: ResponseStatus.SUCCESS,
+ uuid: TEST_UUID,
+ })
+ } finally {
+ service.stop()
+ }
+ })
+
+ await it('should warn on internal failed broadcast responses without response handler', async t => {
+ const mocks = {
+ debug: t.mock.method(logger, 'debug', () => undefined),
+ warn: t.mock.method(logger, 'warn', () => undefined),
+ }
+ const { server, service } = createServiceContext()
+
+ try {
+ await registerInternalStopRequest(server)
+ service.sendResponse(TEST_UUID, {
+ hashIdsFailed: [TEST_HASH_ID],
+ status: ResponseStatus.FAILURE,
+ })
+
+ expectSingleLog(
+ mocks,
+ 'warn',
+ /Failed broadcast response completed without response handler/,
+ {
+ hashIdsFailed: [TEST_HASH_ID],
+ origin: UIRequestOrigin.INTERNAL,
+ procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION,
+ status: ResponseStatus.FAILURE,
+ uuid: TEST_UUID,
+ }
+ )
+ } finally {
+ service.stop()
+ }
+ })
+
+ await it('should log transport successful broadcast responses without response handler at debug', async t => {
+ const mocks = {
+ debug: t.mock.method(logger, 'debug', () => undefined),
+ warn: t.mock.method(logger, 'warn', () => undefined),
+ }
+ const { service } = createServiceContext()
+
+ try {
+ await registerTransportStopRequest(service)
+ service.sendResponse(TEST_UUID, {
+ hashIdsSucceeded: [TEST_HASH_ID],
+ status: ResponseStatus.SUCCESS,
+ })
+
+ expectSingleLog(mocks, 'debug', /Broadcast response completed without response handler/, {
+ hashIdsSucceeded: [TEST_HASH_ID],
+ origin: UIRequestOrigin.TRANSPORT,
+ procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION,
+ status: ResponseStatus.SUCCESS,
+ uuid: TEST_UUID,
+ })
+ } finally {
+ service.stop()
+ }
+ })
+
+ await it('should warn on transport failed broadcast responses without response handler', async t => {
+ const mocks = {
+ debug: t.mock.method(logger, 'debug', () => undefined),
+ warn: t.mock.method(logger, 'warn', () => undefined),
+ }
+ const { service } = createServiceContext()
+
+ try {
+ await registerTransportStopRequest(service)
+ service.sendResponse(TEST_UUID, {
+ hashIdsFailed: [TEST_HASH_ID],
+ status: ResponseStatus.FAILURE,
+ })
+
+ expectSingleLog(
+ mocks,
+ 'warn',
+ /Failed broadcast response completed without response handler/,
+ {
+ hashIdsFailed: [TEST_HASH_ID],
+ origin: UIRequestOrigin.TRANSPORT,
+ procedureName: BroadcastChannelProcedureName.STOP_CHARGING_STATION,
+ status: ResponseStatus.FAILURE,
+ uuid: TEST_UUID,
+ }
+ )
+ } finally {
+ service.stop()
+ }
+ })
+
+ await it('should warn on untracked broadcast responses before service stop', t => {
+ const mocks = {
+ debug: t.mock.method(logger, 'debug', () => undefined),
+ warn: t.mock.method(logger, 'warn', () => undefined),
+ }
+ const { service } = createServiceContext()
+
+ try {
+ service.sendResponse(TEST_UUID, { status: ResponseStatus.SUCCESS })
+
+ expectSingleLog(mocks, 'warn', /Dropping untracked broadcast response/, {
+ status: ResponseStatus.SUCCESS,
+ uuid: TEST_UUID,
+ })
+ } finally {
+ service.stop()
+ }
+ })
+
+ await it('should log late broadcast responses after service stop at debug', async t => {
+ const mocks = {
+ debug: t.mock.method(logger, 'debug', () => undefined),
+ warn: t.mock.method(logger, 'warn', () => undefined),
+ }
+ const { service } = createServiceContext()
+
+ try {
+ await registerTransportStopRequest(service)
+ service.stop()
+ service.sendResponse(TEST_UUID, {
+ hashIdsSucceeded: [TEST_HASH_ID],
+ status: ResponseStatus.SUCCESS,
+ })
+
+ expectSingleLog(mocks, 'debug', /Dropping late broadcast response/, {
+ hashIdsSucceeded: [TEST_HASH_ID],
+ status: ResponseStatus.SUCCESS,
+ uuid: TEST_UUID,
+ })
+ } finally {
+ service.stop()
+ }
+ })
+
+ await it('should rollback expected responses when broadcast dispatch throws', async t => {
+ const { service } = createServiceContext()
+ const channel = Reflect.get(
+ service,
+ 'uiServiceWorkerBroadcastChannel'
+ ) as UIServiceWorkerBroadcastChannel
+ t.mock.method(channel, 'sendRequest', () => {
+ throw new Error('dispatch failed')
+ })
+
+ try {
+ const response = await service.requestHandler(
+ createProtocolRequest(TEST_UUID, ProcedureName.STOP_CHARGING_STATION, {})
+ )
+
+ assert.notStrictEqual(response, undefined)
+ if (response != null) {
+ assert.strictEqual(response[1].status, ResponseStatus.FAILURE)
+ }
+ assert.strictEqual(service.getBroadcastChannelExpectedResponses(TEST_UUID), 0)
+ } finally {
+ service.stop()
+ }
+ })
+
await it('should return failure response when request handler throws', async () => {
const config = createMockUIServerConfiguration()
const server = new TestableUIWebSocketServer(config)