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
- Middleware pipeline —
use(middleware)appends to ordered chain; each middleware gets(request, next)and can modify request/response, short-circuit, or retry. - Injectable
fetch—HttpClientOptions.fetchdefaults toglobalThis.fetch; swap to a stub in tests withoutvi.mock. - Injectable
WebSocket— same pattern;WebSocketClientOptions.WebSocketdefaults toglobalThis.WebSocket. - Retry wiring —
retryMiddlewareuses@suaveplan/retrywith circuit breaker; configurable perHttpClientinstance. - WS auto-reconnect — exponential backoff via
@suaveplan/retry; configurable max attempts; ping/pong keepalive. - OTel instrumentation — span
http.request(attrs:http.method,http.url,http.status_code), counterhttp_request_total, histogramhttp_request_duration_ms; counterws.reconnect_total. - Property-based tests —
buildUrlround-trip with query params,parseQueryString(serializeQueryString(params)) == paramsfor any param map. - Benchmark suite —
buildUrlwith 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.