{"path":"lib/api-client-react/src/custom-fetch.ts","size":11377,"locked":false,"binary":false,"truncated":false,"content":"export type CustomFetchOptions = RequestInit & {\n  responseType?: \"json\" | \"text\" | \"blob\" | \"auto\";\n};\n\nexport type ErrorType<T = unknown> = ApiError<T>;\n\nexport type BodyType<T> = T;\n\nexport type AuthTokenGetter = () => Promise<string | null> | string | null;\n\nconst NO_BODY_STATUS = new Set([204, 205, 304]);\nconst DEFAULT_JSON_ACCEPT = \"application/json, application/problem+json\";\n\n// ---------------------------------------------------------------------------\n// Module-level configuration\n// ---------------------------------------------------------------------------\n\nlet _baseUrl: string | null = null;\nlet _authTokenGetter: AuthTokenGetter | null = null;\n\n/**\n * Set a base URL that is prepended to every relative request URL\n * (i.e. paths that start with `/`).\n *\n * Useful for Expo bundles that need to call a remote API server.\n * Pass `null` to clear the base URL.\n */\nexport function setBaseUrl(url: string | null): void {\n  _baseUrl = url ? url.replace(/\\/+$/, \"\") : null;\n}\n\n/**\n * Register a getter that supplies a bearer auth token.  Before every fetch\n * the getter is invoked; when it returns a non-null string, an\n * `Authorization: Bearer <token>` header is attached to the request.\n *\n * Useful for Expo bundles making token-gated API calls.\n * Pass `null` to clear the getter.\n *\n * NOTE: This function should never be used in web applications where session\n * token cookies are automatically associated with API calls by the browser.\n */\nexport function setAuthTokenGetter(getter: AuthTokenGetter | null): void {\n  _authTokenGetter = getter;\n}\n\nfunction isRequest(input: RequestInfo | URL): input is Request {\n  return typeof Request !== \"undefined\" && input instanceof Request;\n}\n\nfunction resolveMethod(input: RequestInfo | URL, explicitMethod?: string): string {\n  if (explicitMethod) return explicitMethod.toUpperCase();\n  if (isRequest(input)) return input.method.toUpperCase();\n  return \"GET\";\n}\n\n// Use loose check for URL — some runtimes (e.g. React Native) polyfill URL\n// differently, so `instanceof URL` can fail.\nfunction isUrl(input: RequestInfo | URL): input is URL {\n  return typeof URL !== \"undefined\" && input instanceof URL;\n}\n\nfunction applyBaseUrl(input: RequestInfo | URL): RequestInfo | URL {\n  if (!_baseUrl) return input;\n  const url = resolveUrl(input);\n  // Only prepend to relative paths (starting with /)\n  if (!url.startsWith(\"/\")) return input;\n\n  const absolute = `${_baseUrl}${url}`;\n  if (typeof input === \"string\") return absolute;\n  if (isUrl(input)) return new URL(absolute);\n  return new Request(absolute, input as Request);\n}\n\nfunction resolveUrl(input: RequestInfo | URL): string {\n  if (typeof input === \"string\") return input;\n  if (isUrl(input)) return input.toString();\n  return input.url;\n}\n\nfunction mergeHeaders(...sources: Array<HeadersInit | undefined>): Headers {\n  const headers = new Headers();\n\n  for (const source of sources) {\n    if (!source) continue;\n    new Headers(source).forEach((value, key) => {\n      headers.set(key, value);\n    });\n  }\n\n  return headers;\n}\n\nfunction getMediaType(headers: Headers): string | null {\n  const value = headers.get(\"content-type\");\n  return value ? value.split(\";\", 1)[0].trim().toLowerCase() : null;\n}\n\nfunction isJsonMediaType(mediaType: string | null): boolean {\n  return mediaType === \"application/json\" || Boolean(mediaType?.endsWith(\"+json\"));\n}\n\nfunction isTextMediaType(mediaType: string | null): boolean {\n  return Boolean(\n    mediaType &&\n      (mediaType.startsWith(\"text/\") ||\n        mediaType === \"application/xml\" ||\n        mediaType === \"text/xml\" ||\n        mediaType.endsWith(\"+xml\") ||\n        mediaType === \"application/x-www-form-urlencoded\"),\n  );\n}\n\n// Use strict equality: in browsers, `response.body` is `null` when the\n// response genuinely has no content.  In React Native, `response.body` is\n// always `undefined` because the ReadableStream API is not implemented —\n// even when the response carries a full payload readable via `.text()` or\n// `.json()`.  Loose equality (`== null`) matches both `null` and `undefined`,\n// which causes every React Native response to be treated as empty.\nfunction hasNoBody(response: Response, method: string): boolean {\n  if (method === \"HEAD\") return true;\n  if (NO_BODY_STATUS.has(response.status)) return true;\n  if (response.headers.get(\"content-length\") === \"0\") return true;\n  if (response.body === null) return true;\n  return false;\n}\n\nfunction stripBom(text: string): string {\n  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n}\n\nfunction looksLikeJson(text: string): boolean {\n  const trimmed = text.trimStart();\n  return trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\");\n}\n\nfunction getStringField(value: unknown, key: string): string | undefined {\n  if (!value || typeof value !== \"object\") return undefined;\n\n  const candidate = (value as Record<string, unknown>)[key];\n  if (typeof candidate !== \"string\") return undefined;\n\n  const trimmed = candidate.trim();\n  return trimmed === \"\" ? undefined : trimmed;\n}\n\nfunction truncate(text: string, maxLength = 300): string {\n  return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;\n}\n\nfunction buildErrorMessage(response: Response, data: unknown): string {\n  const prefix = `HTTP ${response.status} ${response.statusText}`;\n\n  if (typeof data === \"string\") {\n    const text = data.trim();\n    return text ? `${prefix}: ${truncate(text)}` : prefix;\n  }\n\n  const title = getStringField(data, \"title\");\n  const detail = getStringField(data, \"detail\");\n  const message =\n    getStringField(data, \"message\") ??\n    getStringField(data, \"error_description\") ??\n    getStringField(data, \"error\");\n\n  if (title && detail) return `${prefix}: ${title} — ${detail}`;\n  if (detail) return `${prefix}: ${detail}`;\n  if (message) return `${prefix}: ${message}`;\n  if (title) return `${prefix}: ${title}`;\n\n  return prefix;\n}\n\nexport class ApiError<T = unknown> extends Error {\n  readonly name = \"ApiError\";\n  readonly status: number;\n  readonly statusText: string;\n  readonly data: T | null;\n  readonly headers: Headers;\n  readonly response: Response;\n  readonly method: string;\n  readonly url: string;\n\n  constructor(\n    response: Response,\n    data: T | null,\n    requestInfo: { method: string; url: string },\n  ) {\n    super(buildErrorMessage(response, data));\n    Object.setPrototypeOf(this, new.target.prototype);\n\n    this.status = response.status;\n    this.statusText = response.statusText;\n    this.data = data;\n    this.headers = response.headers;\n    this.response = response;\n    this.method = requestInfo.method;\n    this.url = response.url || requestInfo.url;\n  }\n}\n\nexport class ResponseParseError extends Error {\n  readonly name = \"ResponseParseError\";\n  readonly status: number;\n  readonly statusText: string;\n  readonly headers: Headers;\n  readonly response: Response;\n  readonly method: string;\n  readonly url: string;\n  readonly rawBody: string;\n  readonly cause: unknown;\n\n  constructor(\n    response: Response,\n    rawBody: string,\n    cause: unknown,\n    requestInfo: { method: string; url: string },\n  ) {\n    super(\n      `Failed to parse response from ${requestInfo.method} ${response.url || requestInfo.url} ` +\n        `(${response.status} ${response.statusText}) as JSON`,\n    );\n    Object.setPrototypeOf(this, new.target.prototype);\n\n    this.status = response.status;\n    this.statusText = response.statusText;\n    this.headers = response.headers;\n    this.response = response;\n    this.method = requestInfo.method;\n    this.url = response.url || requestInfo.url;\n    this.rawBody = rawBody;\n    this.cause = cause;\n  }\n}\n\nasync function parseJsonBody(\n  response: Response,\n  requestInfo: { method: string; url: string },\n): Promise<unknown> {\n  const raw = await response.text();\n  const normalized = stripBom(raw);\n\n  if (normalized.trim() === \"\") {\n    return null;\n  }\n\n  try {\n    return JSON.parse(normalized);\n  } catch (cause) {\n    throw new ResponseParseError(response, raw, cause, requestInfo);\n  }\n}\n\nasync function parseErrorBody(response: Response, method: string): Promise<unknown> {\n  if (hasNoBody(response, method)) {\n    return null;\n  }\n\n  const mediaType = getMediaType(response.headers);\n\n  // Fall back to text when blob() is unavailable (e.g. some React Native builds).\n  if (mediaType && !isJsonMediaType(mediaType) && !isTextMediaType(mediaType)) {\n    return typeof response.blob === \"function\" ? response.blob() : response.text();\n  }\n\n  const raw = await response.text();\n  const normalized = stripBom(raw);\n  const trimmed = normalized.trim();\n\n  if (trimmed === \"\") {\n    return null;\n  }\n\n  if (isJsonMediaType(mediaType) || looksLikeJson(normalized)) {\n    try {\n      return JSON.parse(normalized);\n    } catch {\n      return raw;\n    }\n  }\n\n  return raw;\n}\n\nfunction inferResponseType(response: Response): \"json\" | \"text\" | \"blob\" {\n  const mediaType = getMediaType(response.headers);\n\n  if (isJsonMediaType(mediaType)) return \"json\";\n  if (isTextMediaType(mediaType) || mediaType == null) return \"text\";\n  return \"blob\";\n}\n\nasync function parseSuccessBody(\n  response: Response,\n  responseType: \"json\" | \"text\" | \"blob\" | \"auto\",\n  requestInfo: { method: string; url: string },\n): Promise<unknown> {\n  if (hasNoBody(response, requestInfo.method)) {\n    return null;\n  }\n\n  const effectiveType =\n    responseType === \"auto\" ? inferResponseType(response) : responseType;\n\n  switch (effectiveType) {\n    case \"json\":\n      return parseJsonBody(response, requestInfo);\n\n    case \"text\": {\n      const text = await response.text();\n      return text === \"\" ? null : text;\n    }\n\n    case \"blob\":\n      if (typeof response.blob !== \"function\") {\n        throw new TypeError(\n          \"Blob responses are not supported in this runtime. \" +\n            \"Use responseType \\\"json\\\" or \\\"text\\\" instead.\",\n        );\n      }\n      return response.blob();\n  }\n}\n\nexport async function customFetch<T = unknown>(\n  input: RequestInfo | URL,\n  options: CustomFetchOptions = {},\n): Promise<T> {\n  input = applyBaseUrl(input);\n  const { responseType = \"auto\", headers: headersInit, ...init } = options;\n\n  const method = resolveMethod(input, init.method);\n\n  if (init.body != null && (method === \"GET\" || method === \"HEAD\")) {\n    throw new TypeError(`customFetch: ${method} requests cannot have a body.`);\n  }\n\n  const headers = mergeHeaders(isRequest(input) ? input.headers : undefined, headersInit);\n\n  if (\n    typeof init.body === \"string\" &&\n    !headers.has(\"content-type\") &&\n    looksLikeJson(init.body)\n  ) {\n    headers.set(\"content-type\", \"application/json\");\n  }\n\n  if (responseType === \"json\" && !headers.has(\"accept\")) {\n    headers.set(\"accept\", DEFAULT_JSON_ACCEPT);\n  }\n\n  // Attach bearer token when an auth getter is configured and no\n  // Authorization header has been explicitly provided.\n  if (_authTokenGetter && !headers.has(\"authorization\")) {\n    const token = await _authTokenGetter();\n    if (token) {\n      headers.set(\"authorization\", `Bearer ${token}`);\n    }\n  }\n\n  const requestInfo = { method, url: resolveUrl(input) };\n\n  const response = await fetch(input, { ...init, method, headers });\n\n  if (!response.ok) {\n    const errorData = await parseErrorBody(response, method);\n    throw new ApiError(response, errorData, requestInfo);\n  }\n\n  return (await parseSuccessBody(response, responseType, requestInfo)) as T;\n}\n"}