/** * Injectable HTTP transport for the OpenWA SDK. * * The client never calls `globalThis.fetch` directly. Instead it accepts a * `FetchLike` implementation (defaulting to the global `fetch`). This makes the * SDK trivially testable — a test passes a recorder as `fetch` instead of * monkey-patching globals — and lets consumers intercept/observability-wrap * outbound calls. * * @packageDocumentation */ import { classifyApiError, OpenWAApiError, OpenWATimeoutError } from './errors.js'; /** Subset of the WHATWG `fetch` signature the SDK relies on. */ export type FetchLike = typeof globalThis.fetch; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; export interface RequestOptions { method: HttpMethod; /** Full path beginning with `/`, e.g. `/api/sessions`. */ path: string; /** Query parameters, serialized into the URL. */ query?: object; /** JSON-serializable request body. */ body?: unknown; /** Override the per-client timeout (ms) for this single request. */ timeoutMs?: number; /** Extra headers merged on top of the client defaults (auth/JSON win). */ headers?: Record; } export interface ClientConfig { /** Base URL of the OpenWA API, e.g. `http://localhost:2785`. */ baseUrl: string; /** API key sent as `X-API-Key`. */ apiKey: string; /** Per-request timeout in milliseconds (default 30000). */ timeoutMs?: number; /** Default headers applied to every request. */ defaultHeaders?: Record; /** Injectable transport; defaults to the global `fetch`. */ fetch?: FetchLike; } /** * Percent-encode a single path segment (e.g. a chat/message id) so a value * containing `/`, `#`, `?` or whitespace can't break out of its path position. * WhatsApp-id characters that are already path-safe (`@`, `:`, `+`) are kept * readable. */ export function encodeSegment(segment: string | number): string { return encodeURIComponent(String(segment)).replace(/%40/g, '@').replace(/%3A/g, ':').replace(/%2B/g, '+'); } /** Build a URL with serialized query params, omitting `undefined`/`null` values. */ export function buildUrl(baseUrl: string, path: string, query?: object): string { const url = `${baseUrl.replace(/\/$/, '')}${path}`; if (!query) return url; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null) continue; params.append(key, String(value)); } const qs = params.toString(); return qs ? `${url}?${qs}` : url; } /** * Perform a single request against the OpenWA API and return the parsed JSON * body (or `null` for 204). Throws a typed {@link OpenWAApiError} subclass on * non-2xx, or {@link OpenWATimeoutError} on timeout. */ export async function request( config: Required> & { fetch: FetchLike }, options: RequestOptions, ): Promise { const url = buildUrl(config.baseUrl, options.path, options.query); const timeoutMs = options.timeoutMs ?? config.timeoutMs; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); // Auth and JSON content-type WIN over caller-supplied defaults/per-request headers — the SDK only // ever sends a JSON body, and this matches the Python and PHP SDKs (which force JSON) and the // documented "JSON headers win" contract. Put them last so a defaultHeaders Content-Type can't clobber. const headers: Record = { ...config.defaultHeaders, ...options.headers, 'Content-Type': 'application/json', 'X-API-Key': config.apiKey, }; try { const res = await config.fetch(url, { method: options.method, headers, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, signal: controller.signal, // Never auto-follow redirects: doing so would re-send the X-API-Key header // to the redirect target (potentially a different origin). A 3xx surfaces // as a non-2xx error instead. redirect: 'manual', }); if (!res.ok) { const context = `${options.method} ${options.path}`; const apiError = await OpenWAApiError.fromResponse(res, context); throw classifyApiError(apiError.status, apiError.message, apiError.body, apiError.errorKind); } if (res.status === 204) { return null as T; } const text = await res.text(); if (!text) return null as T; try { return JSON.parse(text) as T; } catch { return text as unknown as T; } } catch (err) { if (err instanceof Error && err.name === 'AbortError') { throw new OpenWATimeoutError(timeoutMs); } throw err; } finally { clearTimeout(timer); } } const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); /** * Warn (NOT throw) when a URL is `http://` and the host is not localhost. The API key is sent as * an `X-API-Key` header on every request — over plaintext http to a non-local host that's cleartext * on the wire. Warning (not refusing) keeps local dev and TLS-terminating-proxy topologies working. */ export function warnIfInsecureHttpUrl(url: string, label = 'baseUrl'): void { try { const parsed = new URL(url); if (parsed.protocol === 'http:' && !LOCALHOST_HOSTS.has(parsed.hostname.toLowerCase())) { console.warn( `[OpenWA SDK] ${label} uses an insecure http:// URL (host: ${parsed.hostname}). ` + 'The API key will be sent in cleartext. Use https:// in production.', ); } } catch { // Unparseable — the request will fail downstream with a clear error. } }