]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor: complete barrel-gap scope — extract host-parsing utilities and expose Abstr...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 5 Jul 2026 20:21:38 +0000 (22:21 +0200)
committerGitHub <noreply@github.com>
Sun, 5 Jul 2026 20:21:38 +0000 (22:21 +0200)
refactor: complete barrel-gap scope — extract host-parsing utilities and expose AbstractUIServer

Complete the scope that #1952 and #1953 left incomplete. Three coupled
concerns addressed atomically to avoid further PR fragmentation:

R1. Extract host-parsing utilities from ui-server internals to
    src/utils/HostUtils.ts:

    The `ui-server/UIServerNet.ts` file previously mixed pure host/IP
    parsing helpers (`LOOPBACK_HOSTNAME`, `isLoopback`,
    `normalizeIPAddress`, `normalizeHost`, `isHostLiteralWithoutPort`)
    with HTTP-header parsing helpers (`splitHeaderList`, `splitQuoted`).
    The host-parsing family is a pure utility, not UI-server-specific,
    and was consumed cross-layer by `src/utils/ConfigurationSchema.ts`
    (a Zod validator) — an inverted dependency (`utils/` importing from
    `charging-station/ui-server/`) that required a workaround comment
    documenting the TDZ (Temporal Dead Zone) cycle risk at
    UIServerNet.ts:3.

    Move the 5 host-parsing exports plus their private helpers
    (`HOSTNAME_PATTERN`, `isValidPort`, `parseIPv4MappedAddress`,
    `expandIPv6`, `isIPv6Group`) to `src/utils/HostUtils.ts`. The
    `ui-server/UIServerNet.ts` file now contains only the HTTP-header
    parsing helpers (`splitHeaderList`, `splitQuoted`) that legitimately
    belong to the ui-server access-policy layer.

    Callers updated:
    - ChargingStation ui-server internals (`AbstractUIServer.ts`,
      `UIServerAccessPolicy.ts`, `UIServerFactory.ts`) now import from
      `../../utils/index.js` (the utils barrel), each in a single
      alphabetically-sorted specifier block merged with the pre-existing
      utils imports from that file (no duplicate import statements).
    - `src/utils/ConfigurationSchema.ts` imports from `./HostUtils.js`
      directly (same directory, avoids barrel TDZ risk).
    - The utils barrel (`src/utils/index.ts`) re-exports the 5 host
      helpers alphabetically.

    Priority-3 anchor comments preserved verbatim in the moved private
    helpers (radix-16 rationale for `Number.parseInt` in
    `parseIPv4MappedAddress` and `expandIPv6`).

R2. Expose AbstractUIServer as a type-only re-export from the
    ui-server barrel:

    Bootstrap.ts holds a `readonly uiServer: AbstractUIServer` field
    without subclassing the abstract base — a legitimate external
    type-reference consumer. Previously deep-imported via
    `./ui-server/AbstractUIServer.js`. Now available through the
    barrel via `export type { AbstractUIServer } from './AbstractUIServer.js'`.
    Bootstrap merges its two ui-server imports into a single barrel
    import.

    The ui-server barrel `@file` header updates:
    - Move `AbstractUIServer` from the "Deliberately not re-exported"
      list to a positive mention: "re-exported as a type-only symbol
      for external consumers (Bootstrap) that hold a reference to the
      selected concrete server without subclassing it".
    - Retitle the `UIServerNet` bullet: the host-parsing helpers no
      longer live here; the barrel now cross-references
      `src/utils/HostUtils.ts` so a reader chasing them does not
      wonder where they went.
    - Complete the "Deliberately not re-exported" enumeration to also
      name the omitted internal symbols from `UIServerSecurity`
      (`DEFAULT_MAX_PAYLOAD_SIZE_BYTES`, `PayloadTooLargeError`,
      `readLimitedBody`) and `UIServerUtils`
      (`isProtocolAndVersionSupported`) so the list is exhaustive.

R3. Adjacent-sub-component cycle already resolved in #1953: the
    `broadcast-channel ↔ ui-server/ui-services` value-import back-edge
    remains a direct import (not via the barrel) per the codebase's
    established convention (`meter-values/index.ts`: "Internal helpers
    are intentionally not re-exported; tests and internal callers
    should import them directly from the owning sub-module"). No
    code change required for R3 in this PR.

Test surface reorganized to match the code surface:
- `tests/utils/HostUtils.test.ts` (new) — 9 `isLoopback` cases and
  12 `normalizeHost` cases moved from the previous
  `tests/charging-station/ui-server/UIServerNet.test.ts`. Uses a
  direct module import (`../../src/utils/HostUtils.js`) rather than
  the utils barrel: the barrel load pulls Configuration singleton +
  Logger side effects into a pure-function test surface, which under
  the Node test runner produces "Promise resolution is still pending
  but the event loop has already resolved" hangs. Peer utility tests
  that need those side effects use the barrel; a pure host-parsing
  unit test does not.
- `tests/charging-station/ui-server/UIServerNet.test.ts` retains only
  the 4 `splitHeaderList` cases.

Quality gates: typecheck clean, lint clean, targeted tests pass
(utils + ui-server + broadcast-channel suites: 705/705 pass, 0 fail).

src/charging-station/Bootstrap.ts
src/charging-station/ui-server/AbstractUIServer.ts
src/charging-station/ui-server/UIServerAccessPolicy.ts
src/charging-station/ui-server/UIServerFactory.ts
src/charging-station/ui-server/UIServerNet.ts
src/charging-station/ui-server/index.ts
src/utils/ConfigurationSchema.ts
src/utils/HostUtils.ts [new file with mode: 0644]
src/utils/index.ts
tests/charging-station/ui-server/UIServerNet.test.ts
tests/utils/HostUtils.test.ts [new file with mode: 0644]

index c8828b389eb931bb6479d48cdbe2e18f3b133519..40a00eb73ca49f619c555c20f0365941c0fde36a 100644 (file)
@@ -10,7 +10,6 @@ import { isMainThread } from 'node:worker_threads'
 import { availableParallelism, type MessageHandler } from 'poolifier'
 
 import type { IBootstrap } from './IBootstrap.js'
-import type { AbstractUIServer } from './ui-server/AbstractUIServer.js'
 
 import packageJson from '../../package.json' with { type: 'json' }
 import { BaseError, TimeoutError } from '../exception/index.js'
@@ -58,7 +57,7 @@ import {
 } from '../worker/index.js'
 import { readStateFile, reconstructTemplateIndexes, writeStateFile } from './BootstrapStateUtils.js'
 import { buildTemplateName, waitChargingStationEvents } from './Helpers.js'
-import { UIServerFactory } from './ui-server/index.js'
+import { type AbstractUIServer, UIServerFactory } from './ui-server/index.js'
 
 const moduleName = 'Bootstrap'
 
index be1a7703cf3a79310a2d45bdcf9af43c3fe5b9d7..76f2563928bb6a5ce6d12d28375ad57d51d2d2de 100644 (file)
@@ -26,7 +26,14 @@ import {
   type UIServerConfiguration,
   type UUIDv4,
 } from '../../types/index.js'
-import { isEmpty, isNotEmptyArray, isNotEmptyString, logger, logPrefix } from '../../utils/index.js'
+import {
+  isEmpty,
+  isLoopback,
+  isNotEmptyArray,
+  isNotEmptyString,
+  logger,
+  logPrefix,
+} from '../../utils/index.js'
 import { UIServiceFactory } from './ui-services/UIServiceFactory.js'
 import {
   createUIServerAccessCache,
@@ -34,7 +41,6 @@ import {
   type UIServerAccessCache,
   type UIServerAccessDecision,
 } from './UIServerAccessPolicy.js'
-import { isLoopback } from './UIServerNet.js'
 import {
   createRateLimiter,
   DEFAULT_RATE_LIMIT,
index 3c3670f15a5bcb9c8029e60e771958e1bae04988..c504af8178f146124b6e3adbb07ef0dab63f9bfd 100644 (file)
@@ -3,14 +3,15 @@ import type { IncomingMessage } from 'node:http'
 import type { UIServerConfiguration } from '../../types/index.js'
 
 import { UI_SERVER_ACCESS_POLICY_DEFAULTS } from '../../utils/ConfigurationSchema.js'
-import { has, isEmpty, isNotEmptyArray } from '../../utils/index.js'
 import {
+  has,
+  isEmpty,
   isLoopback,
+  isNotEmptyArray,
   normalizeHost,
   normalizeIPAddress,
-  splitHeaderList,
-  splitQuoted,
-} from './UIServerNet.js'
+} from '../../utils/index.js'
+import { splitHeaderList, splitQuoted } from './UIServerNet.js'
 
 const FORWARDED_HEADER_NAMES = [
   'forwarded',
index 2a9d686caa6192f36c9760d60a741d0e8d39b88a..015a1887a153c323d0d47bebaa573200b5804960 100644 (file)
@@ -9,10 +9,9 @@ import {
   ConfigurationSection,
   type UIServerConfiguration,
 } from '../../types/index.js'
-import { logger, logPrefix } from '../../utils/index.js'
+import { isLoopback, logger, logPrefix } from '../../utils/index.js'
 import { UIHttpServer } from './UIHttpServer.js'
 import { UIMCPServer } from './UIMCPServer.js'
-import { isLoopback } from './UIServerNet.js'
 import { UIWebSocketServer } from './UIWebSocketServer.js'
 
 const moduleName = 'UIServerFactory'
index d493c01454b481a89cf5c19cc974404affc1aa13..a93ce1877212fdb378dd0f7777603ac5b8bb675d 100644 (file)
@@ -1,106 +1,3 @@
-import { isIP } from 'node:net'
-
-// Direct path: the `utils/index.js` barrel re-exports ConfigurationSchema.ts which imports from this module, causing a TDZ cycle.
-import { convertToInt } from '../../utils/Utils.js'
-
-export const LOOPBACK_HOSTNAME = 'localhost'
-
-export const isLoopback = (address: string): boolean => {
-  if (address.trim().toLowerCase() === LOOPBACK_HOSTNAME) {
-    return true
-  }
-  const normalizedAddress = normalizeIPAddress(address)
-  if (normalizedAddress == null) {
-    return false
-  }
-  if (normalizedAddress.family === 'ipv4') {
-    return normalizedAddress.value.split('.')[0] === '127'
-  }
-  const groups = normalizedAddress.value.split(':')
-  return groups.slice(0, -1).every(group => group === '0') && groups.at(-1) === '1'
-}
-
-/**
- * Parse an IP literal (IPv4, IPv6, or IPv4-mapped IPv6) into a normalized
- * `family:value` pair.
- * @param address The address to normalize.
- * @returns The normalized IP literal, or `undefined` when not a valid IP.
- */
-export const normalizeIPAddress = (
-  address: string
-): undefined | { family: 'ipv4' | 'ipv6'; value: string } => {
-  const host = normalizeHost(address)
-  if (host == null) {
-    return undefined
-  }
-  if (host === LOOPBACK_HOSTNAME) {
-    return { family: 'ipv4', value: '127.0.0.1' }
-  }
-  const ipv4MappedAddress = parseIPv4MappedAddress(host)
-  if (ipv4MappedAddress != null) {
-    return { family: 'ipv4', value: ipv4MappedAddress }
-  }
-  if (isIP(host) === 4) {
-    return { family: 'ipv4', value: host }
-  }
-  if (isIP(host) === 6) {
-    const groups = expandIPv6(host)
-    return groups != null ? { family: 'ipv6', value: groups.join(':') } : undefined
-  }
-  return undefined
-}
-
-/**
- * Strip a bracketed IPv6 wrapper and a trailing `:port` suffix, lowercase
- * the result, and drop a single trailing dot.
- * @param host The raw `Host` header or address value.
- * @returns The bare host, or `undefined` when the input is malformed.
- */
-export const normalizeHost = (host: string): string | undefined => {
-  const trimmedHost = host.trim().toLowerCase()
-  if (trimmedHost === '') {
-    return undefined
-  }
-  const bracketMatch = /^\[([^\]]+)](?::(\d+))?$/.exec(trimmedHost)
-  if (bracketMatch != null) {
-    return isValidPort(bracketMatch[2]) ? bracketMatch[1] : undefined
-  }
-  if (isIP(trimmedHost) === 6) {
-    return trimmedHost
-  }
-  const parts = trimmedHost.split(':')
-  if (parts.length > 2 || (parts.length === 2 && !isValidPort(parts[1]))) {
-    return undefined
-  }
-  const hostPart = parts[0].replace(/\.$/, '')
-  if (hostPart === '') {
-    return undefined
-  }
-  return HOSTNAME_PATTERN.test(hostPart) ? hostPart : undefined
-}
-
-export const isHostLiteralWithoutPort = (value: string): boolean => {
-  const normalized = normalizeHost(value)
-  if (normalized == null) {
-    return false
-  }
-  const stripped = value.trim().toLowerCase().replace(/\.$/, '')
-  return stripped === normalized || stripped === `[${normalized}]`
-}
-
-const HOSTNAME_PATTERN = /^[a-z0-9._-]+$/
-
-const isValidPort = (port: string | undefined): boolean => {
-  if (port == null) {
-    return true
-  }
-  if (!/^\d+$/.test(port)) {
-    return false
-  }
-  const parsedPort = convertToInt(port)
-  return parsedPort >= 1 && parsedPort <= 65535
-}
-
 /**
  * Split a comma-separated header list while honoring RFC 7239 / RFC 7230
  * double-quoted values. Commas inside `"…"` are preserved.
@@ -142,41 +39,3 @@ export const splitQuoted = (value: string, delimiter: string): string[] => {
   }
   return entries
 }
-
-const parseIPv4MappedAddress = (address: string): string | undefined => {
-  const dottedMatch = /^::ffff:(.+)$/i.exec(address)
-  if (dottedMatch != null && isIP(dottedMatch[1]) === 4) {
-    return dottedMatch[1]
-  }
-  const hexMatch = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address)
-  if (hexMatch == null) {
-    return undefined
-  }
-  // radix-16 required for IPv4-mapped IPv6 hex octet parsing; convertToInt does not accept a radix
-  const high = Number.parseInt(hexMatch[1], 16)
-  const low = Number.parseInt(hexMatch[2], 16)
-  return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.')
-}
-
-const expandIPv6 = (address: string): string[] | undefined => {
-  const sections = address.toLowerCase().split('::')
-  if (sections.length > 2) {
-    return undefined
-  }
-  const head = sections[0] === '' ? [] : sections[0].split(':')
-  const tail = sections.length === 1 || sections[1] === '' ? [] : sections[1].split(':')
-  const missingGroups = 8 - head.length - tail.length
-  if ((sections.length === 1 && missingGroups !== 0) || missingGroups < 0) {
-    return undefined
-  }
-  const groups = [...head, ...Array<string>(missingGroups).fill('0'), ...tail]
-  if (groups.length !== 8) {
-    return undefined
-  }
-  // radix-16 required for IPv6 hex group normalization; convertToInt does not accept a radix
-  return groups.every(isIPv6Group)
-    ? groups.map(group => Number.parseInt(group, 16).toString(16))
-    : undefined
-}
-
-const isIPv6Group = (group: string): boolean => /^[0-9a-f]{1,4}$/i.test(group)
index 015ae8bb7029eb2a868ba014d11d29fc9aa02c57..2d240f8deb9ee8cb1c4d638930677a29f1b72716 100644 (file)
@@ -5,19 +5,32 @@
  *   `HttpMethod` enum, the `DEFAULT_COMPRESSION_THRESHOLD_BYTES` canonical default,
  *   plus the `AbstractUIService` base class and its `BroadcastChannelResponseLogContext`
  *   type which are consumed as extension points across the broadcast-channel boundary.
+ *   `AbstractUIServer` is re-exported as a type-only symbol for external consumers
+ *   (`Bootstrap`) that hold a reference to the selected concrete server without
+ *   subclassing it.
  *
  *   Deliberately not re-exported:
- *   - `AbstractUIServer` — internal extension point subclassed only by the concrete
- *     servers within this sub-component; no external extenders exist.
  *   - `UIHttpServer` — `@deprecated` pending removal; re-exporting through a fresh
  *     barrel would surface the deprecation on every consumer via
  *     `@typescript-eslint/no-deprecated`.
- *   - `UIServerAccessPolicy` / `UIServerNet` / `UIServerSecurity` helpers beyond
- *     `DEFAULT_COMPRESSION_THRESHOLD_BYTES` — internal to `UIServerFactory` and
- *     the concrete servers.
+ *   - `UIServerAccessPolicy` — internal to the concrete servers.
+ *   - `UIServerNet` (`splitHeaderList` / `splitQuoted`) — HTTP-header parsing
+ *     internals used by `UIServerAccessPolicy` only. Host-parsing helpers that
+ *     were previously here (`isLoopback`, `normalizeHost`, `normalizeIPAddress`,
+ *     `isHostLiteralWithoutPort`, `LOOPBACK_HOSTNAME`) now live in
+ *     `src/utils/HostUtils.ts` and are re-exported through `src/utils/index.ts`.
+ *   - `UIServerSecurity` (`DEFAULT_MAX_PAYLOAD_SIZE_BYTES`, `PayloadTooLargeError`,
+ *     `readLimitedBody`) — request-body handling internals consumed by the
+ *     concrete servers only; `DEFAULT_COMPRESSION_THRESHOLD_BYTES` is the sole
+ *     canonical default surfaced through this barrel.
+ *   - `UIServerUtils` (`isProtocolAndVersionSupported`, protocol-negotiation
+ *     helpers) — WebSocket protocol handshake internals consumed by
+ *     `UIWebSocketServer` and `UIHttpServer` only; `HttpMethod` is the sole
+ *     transport-agnostic enum surfaced through this barrel.
  *   - `ui-services/UIService001` / `ui-services/UIServiceFactory` concrete
  *     implementations — internal to `AbstractUIService`.
  */
+export type { AbstractUIServer } from './AbstractUIServer.js'
 export {
   AbstractUIService,
   type BroadcastChannelResponseLogContext,
index 25d4b09f5cb55e2af44bb3e8304cd9338bae36e7..e26aec4f3dc8d595f767d00da87a0f8db24bd294 100644 (file)
@@ -4,7 +4,6 @@ import type { ResourceLimits } from 'node:worker_threads'
 import { isIP } from 'node:net'
 import { z } from 'zod'
 
-import { isHostLiteralWithoutPort } from '../charging-station/ui-server/UIServerNet.js'
 import {
   ApplicationProtocol,
   ApplicationProtocolVersion,
@@ -14,6 +13,7 @@ import {
 } from '../types/index.js'
 import { WorkerProcessType } from '../worker/index.js'
 import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from './ConfigurationMigrations.js'
+import { isHostLiteralWithoutPort } from './HostUtils.js'
 import { has } from './Utils.js'
 
 // ---------------------------------------------------------------
diff --git a/src/utils/HostUtils.ts b/src/utils/HostUtils.ts
new file mode 100644 (file)
index 0000000..7b04f6e
--- /dev/null
@@ -0,0 +1,139 @@
+import { isIP } from 'node:net'
+
+import { convertToInt } from './Utils.js'
+
+export const LOOPBACK_HOSTNAME = 'localhost'
+
+export const isLoopback = (address: string): boolean => {
+  if (address.trim().toLowerCase() === LOOPBACK_HOSTNAME) {
+    return true
+  }
+  const normalizedAddress = normalizeIPAddress(address)
+  if (normalizedAddress == null) {
+    return false
+  }
+  if (normalizedAddress.family === 'ipv4') {
+    return normalizedAddress.value.split('.')[0] === '127'
+  }
+  const groups = normalizedAddress.value.split(':')
+  return groups.slice(0, -1).every(group => group === '0') && groups.at(-1) === '1'
+}
+
+/**
+ * Parse an IP literal (IPv4, IPv6, or IPv4-mapped IPv6) into a normalized
+ * `family:value` pair.
+ * @param address The address to normalize.
+ * @returns The normalized IP literal, or `undefined` when not a valid IP.
+ */
+export const normalizeIPAddress = (
+  address: string
+): undefined | { family: 'ipv4' | 'ipv6'; value: string } => {
+  const host = normalizeHost(address)
+  if (host == null) {
+    return undefined
+  }
+  if (host === LOOPBACK_HOSTNAME) {
+    return { family: 'ipv4', value: '127.0.0.1' }
+  }
+  const ipv4MappedAddress = parseIPv4MappedAddress(host)
+  if (ipv4MappedAddress != null) {
+    return { family: 'ipv4', value: ipv4MappedAddress }
+  }
+  if (isIP(host) === 4) {
+    return { family: 'ipv4', value: host }
+  }
+  if (isIP(host) === 6) {
+    const groups = expandIPv6(host)
+    return groups != null ? { family: 'ipv6', value: groups.join(':') } : undefined
+  }
+  return undefined
+}
+
+/**
+ * Strip a bracketed IPv6 wrapper and a trailing `:port` suffix, lowercase
+ * the result, and drop a single trailing dot.
+ * @param host The raw `Host` header or address value.
+ * @returns The bare host, or `undefined` when the input is malformed.
+ */
+export const normalizeHost = (host: string): string | undefined => {
+  const trimmedHost = host.trim().toLowerCase()
+  if (trimmedHost === '') {
+    return undefined
+  }
+  const bracketMatch = /^\[([^\]]+)](?::(\d+))?$/.exec(trimmedHost)
+  if (bracketMatch != null) {
+    return isValidPort(bracketMatch[2]) ? bracketMatch[1] : undefined
+  }
+  if (isIP(trimmedHost) === 6) {
+    return trimmedHost
+  }
+  const parts = trimmedHost.split(':')
+  if (parts.length > 2 || (parts.length === 2 && !isValidPort(parts[1]))) {
+    return undefined
+  }
+  const hostPart = parts[0].replace(/\.$/, '')
+  if (hostPart === '') {
+    return undefined
+  }
+  return HOSTNAME_PATTERN.test(hostPart) ? hostPart : undefined
+}
+
+export const isHostLiteralWithoutPort = (value: string): boolean => {
+  const normalized = normalizeHost(value)
+  if (normalized == null) {
+    return false
+  }
+  const stripped = value.trim().toLowerCase().replace(/\.$/, '')
+  return stripped === normalized || stripped === `[${normalized}]`
+}
+
+const HOSTNAME_PATTERN = /^[a-z0-9._-]+$/
+
+const isValidPort = (port: string | undefined): boolean => {
+  if (port == null) {
+    return true
+  }
+  if (!/^\d+$/.test(port)) {
+    return false
+  }
+  const parsedPort = convertToInt(port)
+  return parsedPort >= 1 && parsedPort <= 65535
+}
+
+const parseIPv4MappedAddress = (address: string): string | undefined => {
+  const dottedMatch = /^::ffff:(.+)$/i.exec(address)
+  if (dottedMatch != null && isIP(dottedMatch[1]) === 4) {
+    return dottedMatch[1]
+  }
+  const hexMatch = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address)
+  if (hexMatch == null) {
+    return undefined
+  }
+  // radix-16 required for IPv4-mapped IPv6 hex octet parsing; convertToInt does not accept a radix
+  const high = Number.parseInt(hexMatch[1], 16)
+  const low = Number.parseInt(hexMatch[2], 16)
+  return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.')
+}
+
+const expandIPv6 = (address: string): string[] | undefined => {
+  const sections = address.toLowerCase().split('::')
+  if (sections.length > 2) {
+    return undefined
+  }
+  const head = sections[0] === '' ? [] : sections[0].split(':')
+  const tail = sections.length === 1 || sections[1] === '' ? [] : sections[1].split(':')
+  const missingGroups = 8 - head.length - tail.length
+  if ((sections.length === 1 && missingGroups !== 0) || missingGroups < 0) {
+    return undefined
+  }
+  const groups = [...head, ...Array<string>(missingGroups).fill('0'), ...tail]
+  if (groups.length !== 8) {
+    return undefined
+  }
+  // radix-16 required for IPv6 hex group normalization; convertToInt does not accept a radix
+  return groups.every(isIPv6Group)
+    ? groups.map(group => Number.parseInt(group, 16).toString(16))
+    : undefined
+}
+
+const isIPv6Group = (group: string): boolean => /^[0-9a-f]{1,4}$/i.test(group)
index c1db125d67862a46629428583b563ef1fe590e12..435a341f876135342c4788e4775c821a8a45045d 100644 (file)
@@ -51,6 +51,13 @@ export {
   type AtomicWriteOptions,
   watchJsonFile,
 } from './FileUtils.js'
+export {
+  isHostLiteralWithoutPort,
+  isLoopback,
+  LOOPBACK_HOSTNAME,
+  normalizeHost,
+  normalizeIPAddress,
+} from './HostUtils.js'
 export { logger } from './Logger.js'
 export {
   buildAddedMessage,
index 10b6e42eb38da063f23c0229a1cb6e7b1aa51d35..03fac6c2b1729b50492de3ad91fa98c6c8b7afd1 100644 (file)
@@ -1,17 +1,12 @@
 /**
  * @file Tests for UIServerNet
- * @description Unit tests for IP literal normalization, loopback
- *   classification, host parsing, and quote-aware header tokenization.
+ * @description Unit tests for quote-aware header tokenization.
  */
 
 import assert from 'node:assert/strict'
 import { afterEach, describe, it } from 'node:test'
 
-import {
-  isLoopback,
-  normalizeHost,
-  splitHeaderList,
-} from '../../../src/charging-station/ui-server/UIServerNet.js'
+import { splitHeaderList } from '../../../src/charging-station/ui-server/UIServerNet.js'
 import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
 
 await describe('UIServerNet', async () => {
@@ -19,46 +14,6 @@ await describe('UIServerNet', async () => {
     standardCleanup()
   })
 
-  await describe('isLoopback', async () => {
-    await it('should return true for localhost', () => {
-      assert.strictEqual(isLoopback('localhost'), true)
-    })
-
-    await it('should return true for 127.0.0.1', () => {
-      assert.strictEqual(isLoopback('127.0.0.1'), true)
-    })
-
-    await it('should return true for IPv4-mapped loopback addresses', () => {
-      assert.strictEqual(isLoopback('::ffff:127.0.0.1'), true)
-      assert.strictEqual(isLoopback('::ffff:7f00:1'), true)
-    })
-
-    await it('should return true for IPv6 loopback ::1', () => {
-      assert.strictEqual(isLoopback('::1'), true)
-    })
-
-    await it('should return true for full IPv6 loopback', () => {
-      assert.strictEqual(isLoopback('0000:0000:0000:0000:0000:0000:0000:0001'), true)
-    })
-
-    await it('should return false for external IPv4 address', () => {
-      assert.strictEqual(isLoopback('192.168.1.1'), false)
-    })
-
-    await it('should return false for invalid addresses', () => {
-      assert.strictEqual(isLoopback('127.999.999.999'), false)
-      assert.strictEqual(isLoopback('1'), false)
-    })
-
-    await it('should return true for bracketed IPv6 loopback with port', () => {
-      assert.strictEqual(isLoopback('[::1]:8080'), true)
-    })
-
-    await it('should return false for empty string', () => {
-      assert.strictEqual(isLoopback(''), false)
-    })
-  })
-
   await describe('splitHeaderList', async () => {
     await it('should split unquoted comma-separated values', () => {
       assert.deepStrictEqual(splitHeaderList('a, b ,c'), ['a', 'b', 'c'])
@@ -81,62 +36,4 @@ await describe('UIServerNet', async () => {
       ])
     })
   })
-
-  await describe('normalizeHost', async () => {
-    await it('should reject inputs with too many colons', () => {
-      assert.strictEqual(normalizeHost('a:b:c'), undefined)
-    })
-
-    await it('should reject inputs with non-numeric port', () => {
-      assert.strictEqual(normalizeHost('localhost:bad'), undefined)
-    })
-
-    await it('should reject bracketed inputs with non-numeric port', () => {
-      assert.strictEqual(normalizeHost('[::1]:abc'), undefined)
-    })
-
-    await it('should reject inputs with port out of range', () => {
-      assert.strictEqual(normalizeHost('[::1]:99999'), undefined)
-    })
-
-    await it('should reject inputs with port 0 (RFC 6335 reserved)', () => {
-      assert.strictEqual(normalizeHost('localhost:0'), undefined)
-      assert.strictEqual(normalizeHost('[::1]:0'), undefined)
-    })
-
-    await it('should reject inputs with characters outside hostname charset', () => {
-      assert.strictEqual(normalizeHost('a.example.com, b.example.com'), undefined)
-      assert.strictEqual(normalizeHost('foo bar'), undefined)
-      assert.strictEqual(normalizeHost('[bad'), undefined)
-    })
-
-    await it('should reject empty input', () => {
-      assert.strictEqual(normalizeHost(''), undefined)
-      assert.strictEqual(normalizeHost('   '), undefined)
-    })
-
-    await it('should accept hostname with optional port', () => {
-      assert.strictEqual(normalizeHost('gateway.example.com'), 'gateway.example.com')
-      assert.strictEqual(normalizeHost('gateway.example.com:8080'), 'gateway.example.com')
-    })
-
-    await it('should accept IPv4 literal with optional port', () => {
-      assert.strictEqual(normalizeHost('127.0.0.1'), '127.0.0.1')
-      assert.strictEqual(normalizeHost('127.0.0.1:8080'), '127.0.0.1')
-    })
-
-    await it('should accept bracketed IPv6 literal with optional port', () => {
-      assert.strictEqual(normalizeHost('[::1]'), '::1')
-      assert.strictEqual(normalizeHost('[::1]:8080'), '::1')
-    })
-
-    await it('should drop a single trailing dot', () => {
-      assert.strictEqual(normalizeHost('gateway.example.com.'), 'gateway.example.com')
-      assert.strictEqual(normalizeHost('localhost.:80'), 'localhost')
-    })
-
-    await it('should lowercase the result', () => {
-      assert.strictEqual(normalizeHost('Gateway.Example.COM'), 'gateway.example.com')
-    })
-  })
 })
diff --git a/tests/utils/HostUtils.test.ts b/tests/utils/HostUtils.test.ts
new file mode 100644 (file)
index 0000000..560ed4d
--- /dev/null
@@ -0,0 +1,115 @@
+/**
+ * @file Tests for HostUtils
+ * @description Unit tests for IP literal normalization, loopback classification,
+ *   and host-parsing helpers.
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import { isLoopback, normalizeHost } from '../../src/utils/HostUtils.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+await describe('HostUtils', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('isLoopback', async () => {
+    await it('should return true for localhost', () => {
+      assert.strictEqual(isLoopback('localhost'), true)
+    })
+
+    await it('should return true for 127.0.0.1', () => {
+      assert.strictEqual(isLoopback('127.0.0.1'), true)
+    })
+
+    await it('should return true for IPv4-mapped loopback addresses', () => {
+      assert.strictEqual(isLoopback('::ffff:127.0.0.1'), true)
+      assert.strictEqual(isLoopback('::ffff:7f00:1'), true)
+    })
+
+    await it('should return true for IPv6 loopback ::1', () => {
+      assert.strictEqual(isLoopback('::1'), true)
+    })
+
+    await it('should return true for full IPv6 loopback', () => {
+      assert.strictEqual(isLoopback('0000:0000:0000:0000:0000:0000:0000:0001'), true)
+    })
+
+    await it('should return false for external IPv4 address', () => {
+      assert.strictEqual(isLoopback('192.168.1.1'), false)
+    })
+
+    await it('should return false for invalid addresses', () => {
+      assert.strictEqual(isLoopback('127.999.999.999'), false)
+      assert.strictEqual(isLoopback('1'), false)
+    })
+
+    await it('should return true for bracketed IPv6 loopback with port', () => {
+      assert.strictEqual(isLoopback('[::1]:8080'), true)
+    })
+
+    await it('should return false for empty string', () => {
+      assert.strictEqual(isLoopback(''), false)
+    })
+  })
+
+  await describe('normalizeHost', async () => {
+    await it('should reject inputs with too many colons', () => {
+      assert.strictEqual(normalizeHost('a:b:c'), undefined)
+    })
+
+    await it('should reject inputs with non-numeric port', () => {
+      assert.strictEqual(normalizeHost('localhost:bad'), undefined)
+    })
+
+    await it('should reject bracketed inputs with non-numeric port', () => {
+      assert.strictEqual(normalizeHost('[::1]:abc'), undefined)
+    })
+
+    await it('should reject inputs with port out of range', () => {
+      assert.strictEqual(normalizeHost('[::1]:99999'), undefined)
+    })
+
+    await it('should reject inputs with port 0 (RFC 6335 reserved)', () => {
+      assert.strictEqual(normalizeHost('localhost:0'), undefined)
+      assert.strictEqual(normalizeHost('[::1]:0'), undefined)
+    })
+
+    await it('should reject inputs with characters outside hostname charset', () => {
+      assert.strictEqual(normalizeHost('a.example.com, b.example.com'), undefined)
+      assert.strictEqual(normalizeHost('foo bar'), undefined)
+      assert.strictEqual(normalizeHost('[bad'), undefined)
+    })
+
+    await it('should reject empty input', () => {
+      assert.strictEqual(normalizeHost(''), undefined)
+      assert.strictEqual(normalizeHost('   '), undefined)
+    })
+
+    await it('should accept hostname with optional port', () => {
+      assert.strictEqual(normalizeHost('gateway.example.com'), 'gateway.example.com')
+      assert.strictEqual(normalizeHost('gateway.example.com:8080'), 'gateway.example.com')
+    })
+
+    await it('should accept IPv4 literal with optional port', () => {
+      assert.strictEqual(normalizeHost('127.0.0.1'), '127.0.0.1')
+      assert.strictEqual(normalizeHost('127.0.0.1:8080'), '127.0.0.1')
+    })
+
+    await it('should accept bracketed IPv6 literal with optional port', () => {
+      assert.strictEqual(normalizeHost('[::1]'), '::1')
+      assert.strictEqual(normalizeHost('[::1]:8080'), '::1')
+    })
+
+    await it('should drop a single trailing dot', () => {
+      assert.strictEqual(normalizeHost('gateway.example.com.'), 'gateway.example.com')
+      assert.strictEqual(normalizeHost('localhost.:80'), 'localhost')
+    })
+
+    await it('should lowercase the result', () => {
+      assert.strictEqual(normalizeHost('Gateway.Example.COM'), 'gateway.example.com')
+    })
+  })
+})