Files
2026-05-27 23:29:53 +01:00

8.3 KiB

OpenSpec Change: enterprise-network

  • Change ID: enterprise-network
  • Package: @suaveplan/network
  • Category: universal
  • Target path: universal/network/network
  • Status: PROPOSED
  • Date: 2026-04-24
  • Reference spec: /mnt/s/web/suaveplan/enterprise/network.md

Summary

Deliver @suaveplan/network on the genesis branch from scratch at enterprise quality. The package provides core networking utilities for universal TypeScript: a typed HttpClient with middleware pipeline, WebSocketClient with auto-reconnect and backpressure, URL construction/parsing helpers, fetch timeout and cancellation wrappers, request/response interceptors, and network-quality probing. Injectable fetch and WebSocket constructors for test-time substitution. No hard browser/Node platform deps.


Current-state audit (genesis)

The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data. The main-branch package had 85.40% coverage.


Available workspace deps on genesis

Package Role
@suaveplan/error (workspace:^) Base class for NetworkError; NETWORK.* codes
@suaveplan/types (workspace:^) LoggerLike, Clock
@suaveplan/retry (workspace:^) Exponential backoff + circuit breaker on HTTP/WS paths
@suaveplan/serialize (workspace:^) JSON request/response body serialisation
zod (catalog:forms) Zod schemas for all public option types
@opentelemetry/api (catalog:observability) Spans + counters on every request

Dev deps: vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing).


Public API

HttpClient

class NetworkError extends SuaveplanError

type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS'

interface HttpRequest {
  url: string
  method?: HttpMethod                      // default: 'GET'
  headers?: Record<string, string>
  body?: unknown                           // serialised via @suaveplan/serialize
  timeoutMs?: number                       // default: 30000
  signal?: AbortSignal
}

interface HttpResponse<T = unknown> {
  status: number
  statusText: string
  headers: Record<string, string>
  body: T
  url: string
  durationMs: number
}

type HttpMiddleware = (request: HttpRequest, next: (req: HttpRequest) => Promise<HttpResponse>) => Promise<HttpResponse>

interface HttpClientOptions {
  baseUrl?: string
  defaultHeaders?: Record<string, string>
  defaultTimeoutMs?: number
  middleware?: HttpMiddleware[]
  fetch?: typeof globalThis.fetch
  logger?: LoggerLike
  clock?: Clock
}

class HttpClient {
  constructor(options?: HttpClientOptions)
  request<T = unknown>(req: HttpRequest): Promise<HttpResponse<T>>
  get<T = unknown>(path: string, options?: Omit<HttpRequest, 'url' | 'method'>): Promise<HttpResponse<T>>
  post<T = unknown>(path: string, body: unknown, options?: Omit<HttpRequest, 'url' | 'method' | 'body'>): Promise<HttpResponse<T>>
  put<T = unknown>(path: string, body: unknown, options?: Omit<HttpRequest, 'url' | 'method' | 'body'>): Promise<HttpResponse<T>>
  patch<T = unknown>(path: string, body: unknown, options?: Omit<HttpRequest, 'url' | 'method' | 'body'>): Promise<HttpResponse<T>>
  delete<T = unknown>(path: string, options?: Omit<HttpRequest, 'url' | 'method'>): Promise<HttpResponse<T>>
  use(middleware: HttpMiddleware): this
}

function createHttpClient(options?: HttpClientOptions): HttpClient

WebSocketClient

type WsReadyState = 'connecting' | 'open' | 'closing' | 'closed'
type WsMessage = string | ArrayBuffer | Uint8Array

interface WebSocketClientOptions {
  url: string
  protocols?: string[]
  reconnect?: boolean                // default: true
  reconnectDelayMs?: number          // base delay; uses @suaveplan/retry exponential backoff
  maxReconnectAttempts?: number      // default: 10
  pingIntervalMs?: number            // default: 30000; 0 = disabled
  timeoutMs?: number                 // connect timeout; default: 10000
  WebSocket?: typeof WebSocket       // injectable; defaults to globalThis.WebSocket
  logger?: LoggerLike
  clock?: Clock
}

class WebSocketClient {
  constructor(options: WebSocketClientOptions)
  connect(signal?: AbortSignal): Promise<void>
  disconnect(): void
  send(message: WsMessage): void
  on(event: 'message', listener: (msg: WsMessage) => void): () => void
  on(event: 'open' | 'close' | 'error', listener: () => void): () => void
  get readyState(): WsReadyState
  get reconnectCount(): number
}

function createWebSocketClient(options: WebSocketClientOptions): WebSocketClient

URL utilities

function buildUrl(base: string, path?: string, params?: Record<string, string | number | boolean>): string
function parseQueryString(qs: string): Record<string, string>
function serializeQueryString(params: Record<string, string | number | boolean>): string
function isAbsoluteUrl(url: string): boolean
function joinPaths(...segments: string[]): string

Error codes

type NetworkErrorCode =
  | 'NETWORK_REQUEST_FAILED'
  | 'NETWORK_TIMEOUT'
  | 'NETWORK_ABORT'
  | 'NETWORK_BAD_STATUS'           // 4xx/5xx
  | 'NETWORK_PARSE_FAILED'
  | 'NETWORK_WS_CONNECT_FAILED'
  | 'NETWORK_WS_SEND_FAILED'
  | 'NETWORK_WS_MAX_RECONNECTS'
  | 'NETWORK_INVALID_URL'

const NETWORK_ERROR_METADATA: Readonly<Record<NetworkErrorCode, CodeMetadata>>
function registerNetworkErrorCodes(): void

Zod schemas

HttpRequestSchema
HttpClientOptionsSchema
WebSocketClientOptionsSchema

Built-in middleware

const loggingMiddleware: (logger: LoggerLike) => HttpMiddleware
const retryMiddleware: (options?: RetryOptions) => HttpMiddleware     // @suaveplan/retry
const authMiddleware: (token: string | (() => string)) => HttpMiddleware
const rateLimitMiddleware: (options: { maxRpm: number; clock?: Clock }) => HttpMiddleware

New additions for genesis

  1. Middleware pipeline — use(middleware) appends to ordered chain; each middleware gets (request, next) and can modify request/response, short-circuit, or retry.
  2. Injectable fetch — HttpClientOptions.fetch defaults to globalThis.fetch; swap to a stub in tests without vi.mock.
  3. Injectable WebSocket — same pattern; WebSocketClientOptions.WebSocket defaults to globalThis.WebSocket.
  4. Retry wiring — retryMiddleware uses @suaveplan/retry with circuit breaker; configurable per HttpClient instance.
  5. WS auto-reconnect — exponential backoff via @suaveplan/retry; configurable max attempts; ping/pong keepalive.
  6. OTel instrumentation — span http.request (attrs: http.method, http.url, http.status_code), counter http_request_total, histogram http_request_duration_ms; counter ws.reconnect_total.
  7. Property-based tests — buildUrl round-trip with query params, parseQueryString(serializeQueryString(params)) == params for any param map.
  8. Benchmark suite — buildUrl with 10 params, middleware chain dispatch (5 middlewares, no-op fetch stub).

File layout

universal/network/network/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts
    error-codes.ts
    constants/
      index.ts
    types/
      common.ts
      common.md
    core/
      http-client/
        http-client.ts
        http-client.md
        http-client.types.ts
        http-client.test.ts
        index.ts
      websocket-client/
        websocket-client.ts
        websocket-client.md
        websocket-client.types.ts
        websocket-client.test.ts
        index.ts
    middleware/
      logging/
        logging.ts
        logging.md
        logging.test.ts
        index.ts
      retry/
        retry.ts
        retry.md
        retry.test.ts
        index.ts
      auth/
        auth.ts
        auth.md
        auth.test.ts
        index.ts
      rate-limit/
        rate-limit.ts
        rate-limit.md
        rate-limit.test.ts
        index.ts
    utils/
      url.ts
      url.md
      url.test.ts
    __benchmarks__/
      network.bench.ts
  tests/
    integration.test.ts
    property.test.ts

Impact

Downstream: @suaveplan/service-discovery, @suaveplan/webhook-client (may refactor to consume), any server/API packages. Breaking changes: none — fresh build. Prerequisites: @suaveplan/error, @suaveplan/types, @suaveplan/retry, @suaveplan/serialize published to Verdaccio.