Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
5.66 kB
/**
* 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<string, string>;
}
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<string, string>;
/** 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<T>(
config: Required<Omit<ClientConfig, 'fetch'>> & { fetch: FetchLike },
options: RequestOptions,
): Promise<T> {
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<string, string> = {
...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.
}
}