/** * Shared HTTP client types + request helpers for Host and standalone dev. * Keep body/query/timeout/response parsing in one place so implementations cannot drift. */ export type HttpResponseKind = 'json' | 'text' | 'blob' | 'response'; export type HttpQueryValue = | string | number | boolean | undefined | null | Array; export type HttpQuery = Record; export interface HttpOptions { /** Extra request headers (wins over auto Content-Type) */ headers?: Record; /** Timeout in ms */ timeout?: number; /** Skip authentication (Host may omit Authorization / credentials) */ skipAuth?: boolean; /** Query string params; `undefined` / `null` values are skipped */ query?: HttpQuery; /** * Response parsing mode. Default `json`. * - `json` / `text` / `blob`: throw {@link HttpError} when `!response.ok` * - `response`: return the raw {@link Response} (caller checks status / headers) */ responseKind?: HttpResponseKind; /** AbortSignal; combined with `timeout` when both are set */ signal?: AbortSignal; /** * Override Content-Type for JSON/string bodies. * Ignored for FormData / Blob / URLSearchParams / ArrayBuffer (browser sets boundary). */ contentType?: string; } export interface HttpClient { get: (url: string, options?: HttpOptions) => Promise; post: (url: string, body?: unknown, options?: HttpOptions) => Promise; put: (url: string, body?: unknown, options?: HttpOptions) => Promise; patch: (url: string, body?: unknown, options?: HttpOptions) => Promise; delete: (url: string, options?: HttpOptions) => Promise; } export class HttpError extends Error { readonly status: number; readonly statusText: string; readonly response: Response; constructor(message: string, response: Response) { super(message); this.name = 'HttpError'; this.status = response.status; this.statusText = response.statusText; this.response = response; } } function isArrayBufferView(value: unknown): value is ArrayBufferView { return ArrayBuffer.isView(value); } /** Bodies that must not be JSON.stringified and must not force Content-Type. */ export function isRawHttpBody( body: unknown, ): body is FormData | Blob | URLSearchParams | ArrayBuffer | ArrayBufferView { if (body == null || typeof body !== 'object') return false; if (typeof FormData !== 'undefined' && body instanceof FormData) return true; if (typeof Blob !== 'undefined' && body instanceof Blob) return true; if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) return true; if (body instanceof ArrayBuffer) return true; if (isArrayBufferView(body)) return true; return false; } export function appendHttpQuery(url: string, query?: HttpQuery): string { if (!query) return url; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value === undefined || value === null) continue; if (Array.isArray(value)) { for (const item of value) { params.append(key, String(item)); } continue; } params.append(key, String(value)); } const qs = params.toString(); if (!qs) return url; const hashIndex = url.indexOf('#'); const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url; const hash = hashIndex >= 0 ? url.slice(hashIndex) : ''; const joiner = withoutHash.includes('?') ? '&' : '?'; return `${withoutHash}${joiner}${qs}${hash}`; } export function prepareHttpBody( body: unknown, contentType?: string, ): { body?: BodyInit; contentType?: string } { if (body === undefined) { return {}; } if (isRawHttpBody(body)) { return { body: body as BodyInit }; } if (typeof body === 'string') { return { body, contentType: contentType ?? 'text/plain;charset=UTF-8', }; } return { body: JSON.stringify(body), contentType: contentType ?? 'application/json', }; } /** Merge optional timeout with an outer AbortSignal into one signal. */ export function createRequestAbort( timeout?: number, outer?: AbortSignal, ): { signal?: AbortSignal; cleanup: () => void } { if (timeout == null && !outer) { return { cleanup: () => {} }; } if (timeout == null) { return { signal: outer, cleanup: () => {} }; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); const onOuterAbort = () => controller.abort(); if (outer) { if (outer.aborted) { controller.abort(); } else { outer.addEventListener('abort', onOuterAbort, { once: true }); } } return { signal: controller.signal, cleanup: () => { clearTimeout(timer); outer?.removeEventListener('abort', onOuterAbort); }, }; } export async function parseHttpResponse( response: Response, kind: HttpResponseKind = 'json', ): Promise { switch (kind) { case 'response': return response as T; case 'blob': return (await response.blob()) as T; case 'text': return (await response.text()) as T; case 'json': default: { const text = await response.text(); if (!text) return undefined as T; try { return JSON.parse(text) as T; } catch { throw new Error('HTTP response is not valid JSON'); } } } } export interface ExecuteHttpRequestArgs { method: string; body?: unknown; options?: HttpOptions; /** Platform/auth headers applied before options.headers */ headers?: Record; /** Extra fetch init (credentials, mode, …) */ requestInit?: Omit; fetch?: typeof globalThis.fetch; /** Invoked after fetch, before ok / parse (e.g. Host 401 handling) */ onResponse?: (response: Response) => void; } /** * Shared request pipeline: query → body → abort → fetch → (optional) ok check → parse. */ export async function executeHttpRequest( url: string, args: ExecuteHttpRequestArgs, ): Promise { const options = args.options; const kind = options?.responseKind ?? 'json'; const fullUrl = appendHttpQuery(url, options?.query); const prepared = prepareHttpBody(args.body, options?.contentType); const { signal, cleanup } = createRequestAbort(options?.timeout, options?.signal); const headers: Record = { ...(prepared.contentType ? { 'Content-Type': prepared.contentType } : {}), ...args.headers, ...options?.headers, }; try { const fetchImpl = args.fetch ?? globalThis.fetch; const response = await fetchImpl(fullUrl, { ...args.requestInit, method: args.method, headers, body: prepared.body, signal, }); args.onResponse?.(response); // Raw Response is fetch-like: caller owns status / headers (e.g. Content-Disposition). if (kind === 'response') { return response as T; } if (!response.ok) { throw new HttpError( `HTTP ${response.status}: ${response.statusText}`, response, ); } return parseHttpResponse(response, kind); } finally { cleanup(); } }