{"version":3,"file":"api-client.cjs","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { isTrustedCredentialTarget, reportSuppressedCredential } from \"./credential-scope\";\nimport { decodeByContentType } from \"./decode-response\";\nimport type { ResponseDecoder } from \"./decode-response\";\nimport { buildApiError, TempestApiError, isRetriableStatus } from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport { withTimeout } from \"./timeout\";\nimport type { ApiClient, ApiClientConfig, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n    if (!IDEMPOTENT_METHODS.has(method)) return false;\n    if (!(error instanceof TempestApiError)) return false;\n    return isRetriableStatus(error.status);\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n    if (!config) return null;\n    return config === true ? {} : config;\n}\n\n/** Default milliseconds before a request is abandoned. */\nconst DEFAULT_TIMEOUT = 15_000;\n\n/** Default milliseconds before a `FormData` request is abandoned. */\nconst DEFAULT_UPLOAD_TIMEOUT = 300_000;\n\nfunction isFormData(body: unknown): body is FormData {\n    return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\n/**\n * Milliseconds since a `performance.now()` reading, rounded.\n *\n * `performance.now()` rather than `Date.now()` because this measures a\n * duration: the wall clock can step sideways mid-request (an NTP correction, a\n * VM resuming, the user changing the clock) and turn a 40 ms call into a\n * negative number. The monotonic clock cannot.\n *\n * @param startedAt - The reading taken before the work started.\n * @returns Whole milliseconds elapsed.\n */\nfunction elapsedMs(startedAt: number): number {\n    return Math.round(performance.now() - startedAt);\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n    let body: unknown;\n    try {\n        body = await response.clone().json();\n    } catch {\n        try {\n            body = await response.text();\n        } catch {\n            body = null;\n        }\n    }\n    return new TempestApiError(\n        buildApiError(response.status, body, response.headers, sentRequestId),\n    );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Binary bodies** have their own methods — `blob()` and `arrayBuffer()` — which\n * run the same pipeline and skip the parse. `request()` no longer decodes an\n * unknown `Content-Type` as text either: it returns a `Blob` and says so once in\n * a development build, because reading `image/jpeg` as UTF-8 destroyed the bytes\n * on the way in.\n *\n * **The bearer token is scoped to `baseURL`'s origin** since 0.66.0. A path may\n * be an absolute URL, which overrides the base entirely, so without a scope the\n * destination of a credentialed request could come from a value read off the\n * network. A request to another origin still goes out; it goes without the\n * header, and a development build says so once. Declare the exceptions in\n * {@link ApiClientConfig.trustedOrigins}.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write. A single call overrides it\n * with {@link RequestOptions.retry}, and opts out of auth entirely with\n * {@link RequestOptions.skipAuth}.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n *     baseURL: import.meta.env.VITE_API_URL,\n *     getToken: () => useAuthStore.getState().token,\n *     refresh,\n *     onUnauthorized: () => useAuthStore.getState().logout(),\n *     logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n *     retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`blob`/\n * `arrayBuffer`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n    const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n    /**\n     * The `Authorization` header for a request, or nothing.\n     *\n     * Takes the resolved URL rather than reading `config.baseURL`, because an\n     * absolute path overrides the base entirely (see `buildApiUrl`) and the\n     * credential is scoped to an origin, not to a client.\n     *\n     * @param url - The absolute URL the request is going to.\n     * @param skipAuth - Whether the caller opted this request out of auth.\n     * @returns The header, or an empty object.\n     */\n    function authHeaders(url: string, skipAuth: boolean): Record<string, string> {\n        if (skipAuth) return {};\n        const token = config.getToken?.();\n        if (!token) return {};\n        if (!isTrustedCredentialTarget(url, config.baseURL, config.trustedOrigins)) {\n            reportSuppressedCredential(url, config.baseURL);\n            return {};\n        }\n        return { Authorization: `Bearer ${token}` };\n    }\n\n    async function rawRequest(\n        path: string,\n        options: RequestOptions,\n        requestId: string | undefined,\n        skipAuth: boolean,\n    ): Promise<Response> {\n        const { body, params, headers, signal, timeout, ...rest } = options;\n        const isForm = isFormData(body);\n        const configured = isForm ? config.uploadTimeout : config.timeout;\n        const fallback = isForm ? DEFAULT_UPLOAD_TIMEOUT : DEFAULT_TIMEOUT;\n        const limit =\n            timeout !== undefined ? timeout : configured !== undefined ? configured : fallback;\n\n        const url = buildApiUrl(config.baseURL, path, { prefix: config.prefix, params });\n\n        const finalHeaders: Record<string, string> = {\n            ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n            ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n            ...config.headers,\n            ...authHeaders(url, skipAuth),\n            ...(headers as Record<string, string> | undefined),\n        };\n\n        const timed = withTimeout(signal, limit);\n        const init: RequestInit = {\n            ...rest,\n            signal: timed.signal,\n            headers: finalHeaders,\n            credentials: config.withCredentials ? \"include\" : rest.credentials,\n            body:\n                body === undefined || body === null\n                    ? undefined\n                    : isForm\n                      ? (body as FormData)\n                      : JSON.stringify(body),\n        };\n\n        try {\n            return await fetcher(url, init);\n        } catch (cause) {\n            if (timed.timedOut()) {\n                throw new TempestApiError({\n                    status: 0,\n                    detail: `A requisição excedeu ${limit}ms e foi abandonada.`,\n                });\n            }\n            throw cause;\n        } finally {\n            timed.dispose();\n        }\n    }\n\n    async function send(\n        path: string,\n        options: RequestOptions,\n        requestId: string,\n        method: string,\n        skipAuth: boolean,\n    ): Promise<Response> {\n        const log = config.logger;\n        if (!log) return rawRequest(path, options, requestId, skipAuth);\n\n        const startedAt = performance.now();\n        try {\n            const response = await rawRequest(path, options, requestId, skipAuth);\n            const entry = { requestId, status: response.status, ms: elapsedMs(startedAt) };\n            const line = `${method} ${path} → ${response.status}`;\n            if (response.status >= 400) log.warn(line, entry);\n            else log.debug(line, entry);\n            return response;\n        } catch (error) {\n            log.warn(`${method} ${path} → no response`, {\n                requestId,\n                ms: elapsedMs(startedAt),\n                error,\n            });\n            throw error;\n        }\n    }\n\n    async function notifyUnauthorized(response: Response, requestId: string): Promise<void> {\n        if (!config.onUnauthorized) return;\n        config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n            requestId,\n            status: response.status,\n        });\n        try {\n            await config.onUnauthorized(response);\n        } catch (error) {\n            config.logger?.warn(`onUnauthorized threw — keeping the original response error`, {\n                requestId,\n                status: response.status,\n                error,\n            });\n        }\n    }\n\n    async function attempt<T>(\n        path: string,\n        options: RequestOptions,\n        decode: ResponseDecoder<T>,\n    ): Promise<T> {\n        const { skipAuth = false, skipAuthRetry = false, ...init } = options;\n        const requestId = config.requestId ? config.requestId() : randomId();\n        const method = (init.method ?? \"GET\").toUpperCase();\n        let response = await send(path, init, requestId, method, skipAuth);\n\n        if (response.status === 401 && !skipAuth && !skipAuthRetry) {\n            if (config.refresh) {\n                try {\n                    await config.refresh();\n                    response = await send(path, init, requestId, method, skipAuth);\n                } catch {\n                    await notifyUnauthorized(response, requestId);\n                    throw await parseError(response, requestId);\n                }\n                if (response.status === 401) {\n                    await notifyUnauthorized(response, requestId);\n                }\n            } else {\n                await notifyUnauthorized(response, requestId);\n            }\n        }\n\n        if (!response.ok) {\n            throw await parseError(response, requestId);\n        }\n\n        return decode(response);\n    }\n\n    async function run<T>(\n        path: string,\n        options: RequestOptions,\n        decode: ResponseDecoder<T>,\n    ): Promise<T> {\n        const { retry: perRequest, ...rest } = options;\n        const retryOptions = resolveRetry(perRequest !== undefined ? perRequest : config.retry);\n        if (!retryOptions) return attempt<T>(path, rest, decode);\n\n        const method = (rest.method ?? \"GET\").toUpperCase();\n        return retryWithBackoff(() => attempt<T>(path, rest, decode), {\n            ...retryOptions,\n            shouldRetry:\n                retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n        });\n    }\n\n    async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n        return run<T>(path, options, decodeByContentType);\n    }\n\n    async function upload<T>(\n        path: string,\n        formData: FormData,\n        method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n        options?: Omit<RequestOptions, \"body\" | \"method\">,\n    ): Promise<T> {\n        return request<T>(path, { ...options, method, body: formData });\n    }\n\n    return {\n        request,\n        get: <T>(path: string, options?: RequestOptions) =>\n            request<T>(path, { ...options, method: \"GET\" }),\n        post: <T>(path: string, options?: RequestOptions) =>\n            request<T>(path, { ...options, method: \"POST\" }),\n        put: <T>(path: string, options?: RequestOptions) =>\n            request<T>(path, { ...options, method: \"PUT\" }),\n        patch: <T>(path: string, options?: RequestOptions) =>\n            request<T>(path, { ...options, method: \"PATCH\" }),\n        delete: <T>(path: string, options?: RequestOptions) =>\n            request<T>(path, { ...options, method: \"DELETE\" }),\n        blob: (path: string, options?: RequestOptions) =>\n            run<Blob>(path, { ...options }, (response) => response.blob()),\n        arrayBuffer: (path: string, options?: RequestOptions) =>\n            run<ArrayBuffer>(path, { ...options }, (response) => response.arrayBuffer()),\n        upload,\n    };\n}\n"],"mappings":"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"}