tempest-react-sdk 0.65.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/auth/create-tempest-auth.cjs +1 -1
  2. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  3. package/dist/auth/create-tempest-auth.js +31 -36
  4. package/dist/auth/create-tempest-auth.js.map +1 -1
  5. package/dist/components/BottomSheet/BottomSheet.cjs +1 -1
  6. package/dist/components/BottomSheet/BottomSheet.cjs.map +1 -1
  7. package/dist/components/BottomSheet/BottomSheet.js +31 -40
  8. package/dist/components/BottomSheet/BottomSheet.js.map +1 -1
  9. package/dist/components/Combobox/Combobox.cjs +1 -1
  10. package/dist/components/Combobox/Combobox.cjs.map +1 -1
  11. package/dist/components/Combobox/Combobox.js +94 -73
  12. package/dist/components/Combobox/Combobox.js.map +1 -1
  13. package/dist/components/Combobox/Combobox.module.cjs +1 -1
  14. package/dist/components/Combobox/Combobox.module.cjs.map +1 -1
  15. package/dist/components/Combobox/Combobox.module.js +4 -3
  16. package/dist/components/Combobox/Combobox.module.js.map +1 -1
  17. package/dist/components/ContextMenu/ContextMenu.cjs +1 -1
  18. package/dist/components/ContextMenu/ContextMenu.cjs.map +1 -1
  19. package/dist/components/ContextMenu/ContextMenu.js +80 -83
  20. package/dist/components/ContextMenu/ContextMenu.js.map +1 -1
  21. package/dist/components/Drawer/Drawer.cjs +1 -1
  22. package/dist/components/Drawer/Drawer.cjs.map +1 -1
  23. package/dist/components/Drawer/Drawer.js +43 -46
  24. package/dist/components/Drawer/Drawer.js.map +1 -1
  25. package/dist/components/DropdownMenu/DropdownMenu.cjs +1 -1
  26. package/dist/components/DropdownMenu/DropdownMenu.cjs.map +1 -1
  27. package/dist/components/DropdownMenu/DropdownMenu.js +129 -105
  28. package/dist/components/DropdownMenu/DropdownMenu.js.map +1 -1
  29. package/dist/components/DropdownMenu/DropdownMenu.module.cjs +1 -1
  30. package/dist/components/DropdownMenu/DropdownMenu.module.cjs.map +1 -1
  31. package/dist/components/DropdownMenu/DropdownMenu.module.js +12 -11
  32. package/dist/components/DropdownMenu/DropdownMenu.module.js.map +1 -1
  33. package/dist/components/HoverCard/HoverCard.cjs +1 -1
  34. package/dist/components/HoverCard/HoverCard.cjs.map +1 -1
  35. package/dist/components/HoverCard/HoverCard.js +54 -29
  36. package/dist/components/HoverCard/HoverCard.js.map +1 -1
  37. package/dist/components/HoverCard/HoverCard.module.cjs +1 -1
  38. package/dist/components/HoverCard/HoverCard.module.cjs.map +1 -1
  39. package/dist/components/HoverCard/HoverCard.module.js +4 -3
  40. package/dist/components/HoverCard/HoverCard.module.js.map +1 -1
  41. package/dist/components/Lightbox/Lightbox.cjs +1 -1
  42. package/dist/components/Lightbox/Lightbox.cjs.map +1 -1
  43. package/dist/components/Lightbox/Lightbox.js +86 -89
  44. package/dist/components/Lightbox/Lightbox.js.map +1 -1
  45. package/dist/components/Menubar/Menubar.cjs +1 -1
  46. package/dist/components/Menubar/Menubar.cjs.map +1 -1
  47. package/dist/components/Menubar/Menubar.js +86 -65
  48. package/dist/components/Menubar/Menubar.js.map +1 -1
  49. package/dist/components/Menubar/Menubar.module.cjs +1 -1
  50. package/dist/components/Menubar/Menubar.module.cjs.map +1 -1
  51. package/dist/components/Menubar/Menubar.module.js +4 -3
  52. package/dist/components/Menubar/Menubar.module.js.map +1 -1
  53. package/dist/components/Modal/Modal.cjs +1 -1
  54. package/dist/components/Modal/Modal.cjs.map +1 -1
  55. package/dist/components/Modal/Modal.js +44 -51
  56. package/dist/components/Modal/Modal.js.map +1 -1
  57. package/dist/components/MultiSelect/MultiSelect.cjs +1 -1
  58. package/dist/components/MultiSelect/MultiSelect.cjs.map +1 -1
  59. package/dist/components/MultiSelect/MultiSelect.js +113 -96
  60. package/dist/components/MultiSelect/MultiSelect.js.map +1 -1
  61. package/dist/components/MultiSelect/MultiSelect.module.cjs +1 -1
  62. package/dist/components/MultiSelect/MultiSelect.module.cjs.map +1 -1
  63. package/dist/components/MultiSelect/MultiSelect.module.js +4 -3
  64. package/dist/components/MultiSelect/MultiSelect.module.js.map +1 -1
  65. package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
  66. package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
  67. package/dist/components/NavigationMenu/NavigationMenu.js +83 -61
  68. package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
  69. package/dist/components/NavigationMenu/NavigationMenu.module.cjs +1 -1
  70. package/dist/components/NavigationMenu/NavigationMenu.module.cjs.map +1 -1
  71. package/dist/components/NavigationMenu/NavigationMenu.module.js +4 -3
  72. package/dist/components/NavigationMenu/NavigationMenu.module.js.map +1 -1
  73. package/dist/components/Popover/Popover.cjs +1 -1
  74. package/dist/components/Popover/Popover.cjs.map +1 -1
  75. package/dist/components/Popover/Popover.js +57 -36
  76. package/dist/components/Popover/Popover.js.map +1 -1
  77. package/dist/components/Popover/Popover.module.cjs +1 -1
  78. package/dist/components/Popover/Popover.module.cjs.map +1 -1
  79. package/dist/components/Popover/Popover.module.js +7 -6
  80. package/dist/components/Popover/Popover.module.js.map +1 -1
  81. package/dist/components/Portal/anchor-position.cjs +2 -0
  82. package/dist/components/Portal/anchor-position.cjs.map +1 -0
  83. package/dist/components/Portal/anchor-position.js +99 -0
  84. package/dist/components/Portal/anchor-position.js.map +1 -0
  85. package/dist/components/Portal/escape-layer.cjs +2 -0
  86. package/dist/components/Portal/escape-layer.cjs.map +1 -0
  87. package/dist/components/Portal/escape-layer.js +30 -0
  88. package/dist/components/Portal/escape-layer.js.map +1 -0
  89. package/dist/components/Portal/tab-bridge.cjs +2 -0
  90. package/dist/components/Portal/tab-bridge.cjs.map +1 -0
  91. package/dist/components/Portal/tab-bridge.js +39 -0
  92. package/dist/components/Portal/tab-bridge.js.map +1 -0
  93. package/dist/components/Tooltip/Tooltip.cjs +1 -1
  94. package/dist/components/Tooltip/Tooltip.cjs.map +1 -1
  95. package/dist/components/Tooltip/Tooltip.js +43 -29
  96. package/dist/components/Tooltip/Tooltip.js.map +1 -1
  97. package/dist/components/Tooltip/Tooltip.module.cjs +1 -1
  98. package/dist/components/Tooltip/Tooltip.module.cjs.map +1 -1
  99. package/dist/components/Tooltip/Tooltip.module.js +4 -3
  100. package/dist/components/Tooltip/Tooltip.module.js.map +1 -1
  101. package/dist/hooks/use-focus-trap.cjs +1 -1
  102. package/dist/hooks/use-focus-trap.cjs.map +1 -1
  103. package/dist/hooks/use-focus-trap.js +2 -2
  104. package/dist/hooks/use-focus-trap.js.map +1 -1
  105. package/dist/hooks/use-sortable.cjs +1 -1
  106. package/dist/hooks/use-sortable.cjs.map +1 -1
  107. package/dist/hooks/use-sortable.js +3 -3
  108. package/dist/hooks/use-sortable.js.map +1 -1
  109. package/dist/http/api-client.cjs +1 -1
  110. package/dist/http/api-client.cjs.map +1 -1
  111. package/dist/http/api-client.js +87 -85
  112. package/dist/http/api-client.js.map +1 -1
  113. package/dist/http/credential-scope.cjs +2 -0
  114. package/dist/http/credential-scope.cjs.map +1 -0
  115. package/dist/http/credential-scope.js +23 -0
  116. package/dist/http/credential-scope.js.map +1 -0
  117. package/dist/http/describe-api-error.cjs +1 -1
  118. package/dist/http/describe-api-error.cjs.map +1 -1
  119. package/dist/http/describe-api-error.js +4 -2
  120. package/dist/http/describe-api-error.js.map +1 -1
  121. package/dist/http/resumable-upload.cjs +1 -1
  122. package/dist/http/resumable-upload.cjs.map +1 -1
  123. package/dist/http/resumable-upload.js +121 -120
  124. package/dist/http/resumable-upload.js.map +1 -1
  125. package/dist/http/upload-with-progress.cjs +1 -1
  126. package/dist/http/upload-with-progress.cjs.map +1 -1
  127. package/dist/http/upload-with-progress.js +35 -34
  128. package/dist/http/upload-with-progress.js.map +1 -1
  129. package/dist/styles/actions.css +3 -0
  130. package/dist/styles/advanced.css +1 -0
  131. package/dist/styles/component/Combobox.css +1 -0
  132. package/dist/styles/component/DropdownMenu.css +1 -0
  133. package/dist/styles/component/HoverCard.css +1 -0
  134. package/dist/styles/component/Menubar.css +1 -0
  135. package/dist/styles/component/MultiSelect.css +1 -0
  136. package/dist/styles/component/NavigationMenu.css +1 -0
  137. package/dist/styles/component/Popover.css +1 -0
  138. package/dist/styles/component/Tooltip.css +1 -0
  139. package/dist/styles/forms.css +2 -0
  140. package/dist/styles/navigation.css +2 -0
  141. package/dist/styles.css +1 -1
  142. package/dist/tempest-react-sdk.cjs +1 -1
  143. package/dist/tempest-react-sdk.d.ts +364 -29
  144. package/dist/tempest-react-sdk.js +335 -333
  145. package/dist/utils/absent.cjs +2 -0
  146. package/dist/utils/absent.cjs.map +1 -0
  147. package/dist/utils/absent.js +12 -0
  148. package/dist/utils/absent.js.map +1 -0
  149. package/dist/utils/format.cjs +1 -1
  150. package/dist/utils/format.cjs.map +1 -1
  151. package/dist/utils/format.js +42 -37
  152. package/dist/utils/format.js.map +1 -1
  153. package/dist/utils/numbers.cjs +1 -1
  154. package/dist/utils/numbers.cjs.map +1 -1
  155. package/dist/utils/numbers.js +14 -12
  156. package/dist/utils/numbers.js.map +1 -1
  157. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.js","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 { 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 * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write.\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 function authHeaders(): Record<string, string> {\n const token = config.getToken?.();\n return token ? { Authorization: `Bearer ${token}` } : {};\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId?: string,\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 finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(),\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 const url = buildApiUrl(config.baseURL, path, { prefix: config.prefix, params });\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 ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(path, options, requestId);\n\n const startedAt = performance.now();\n try {\n const response = await rawRequest(path, options, requestId);\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 requestId = config.requestId ? config.requestId() : randomId();\n const method = (options.method ?? \"GET\").toUpperCase();\n let response = await send(path, options, requestId, method);\n\n if (response.status === 401) {\n if (config.refresh) {\n try {\n await config.refresh();\n response = await send(path, options, requestId, method);\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 retryOptions = resolveRetry(config.retry);\n if (!retryOptions) return attempt<T>(path, options, decode);\n\n const method = (options.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, options, 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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"}
1
+ {"version":3,"file":"api-client.js","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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"}
@@ -0,0 +1,2 @@
1
+ const e=require("../utils/dev-mode.cjs");var t=new Set;function n(e){try{return new URL(e,globalThis.location?.href).origin}catch{return null}}function r(e,t,r=[]){let i=n(e);return i===null||i===n(t)||r.some(e=>n(e)===i)}function i(r,i){if(!e.isDevBuild())return;let a=n(r);a===null||t.has(a)||(t.add(a),console.warn(`[tempest-react-sdk] Authorization was not sent to ${a}: the credential is scoped to ${n(i)??i}. Add the origin to trustedOrigins if it should receive the token.`))}exports.isTrustedCredentialTarget=r,exports.reportSuppressedCredential=i;
2
+ //# sourceMappingURL=credential-scope.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-scope.cjs","names":[],"sources":["../../src/http/credential-scope.ts"],"sourcesContent":["import { isDevBuild } from \"../utils/dev-mode\";\n\n/**\n * Origins already reported by {@link reportSuppressedCredential}, so a chunked\n * upload warns once instead of once per chunk.\n */\nconst reported = new Set<string>();\n\n/**\n * The origin a URL resolves to, or `null` when it has none to compare.\n *\n * Relative values resolve against the document, because that is what they mean\n * everywhere else in the SDK: an `endpoint` of `\"/api/uploads\"` and a `baseURL`\n * of `\"/api\"` are the shape you get behind a dev-server or a reverse proxy, and\n * both name the page's own origin. Comparing a resolved absolute target against\n * an unresolved relative reference is how the first draft of this function\n * withheld the credential from every client configured that way.\n *\n * `null` is reserved for the case with genuinely nothing to compare: a relative\n * URL outside a browsing context, where there is no document to resolve against.\n *\n * @param url - Absolute or relative URL.\n * @returns The origin, or `null` when it cannot be resolved.\n */\nfunction originOf(url: string): string | null {\n try {\n return new URL(url, globalThis.location?.href).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Whether a request to `target` may carry the caller's credential.\n *\n * The SDK writes `Authorization: Bearer` in three places, and until 0.66.0 none\n * of them looked at where the request was going. That is only safe while every\n * target URL is written by the app: a resumable upload takes its URL from the\n * server's `Location` header, and `createApiClient` lets an absolute path\n * override `baseURL` entirely, so a URL arriving over the network could decide\n * which origin received the token — and the file bytes with it.\n *\n * Handing an upload off to object storage on another host is ordinary `tus`\n * deployment, not an attack, which is exactly why the credential must be scoped\n * rather than the request blocked: the upload still goes through, it just goes\n * without the API's bearer token.\n *\n * Both sides are resolved against the document first, so a relative `baseURL`\n * or `endpoint` — the shape behind a dev-server or reverse proxy — compares\n * equal to the absolute URL the request actually goes to. Outside a browsing\n * context a relative target resolves to nothing and is allowed: there is no\n * origin for it to cross.\n *\n * @param target - The URL the request is about to go to.\n * @param reference - The origin the credential belongs to, normally the\n * client's `baseURL` or the upload `endpoint`.\n * @param trustedOrigins - Extra origins the app declared, for the legitimate\n * second host: a CDN, a signed upload endpoint.\n * @returns Whether the credential may be attached.\n *\n * @example\n * isTrustedCredentialTarget(\"https://api.acme.com/me\", \"https://api.acme.com\");\n * // true\n * isTrustedCredentialTarget(\"https://cdn.other/u/1\", \"https://api.acme.com\");\n * // false\n */\nexport function isTrustedCredentialTarget(\n target: string,\n reference: string,\n trustedOrigins: readonly string[] = [],\n): boolean {\n const targetOrigin = originOf(target);\n if (targetOrigin === null) return true;\n if (targetOrigin === originOf(reference)) return true;\n return trustedOrigins.some((origin) => originOf(origin) === targetOrigin);\n}\n\n/**\n * Say once, in a development build, that a credential was withheld.\n *\n * Without it the symptom is a `401` from a host the developer never typed, and\n * nothing in the app's code mentions the origin that produced it. Keyed by\n * target origin so a hundred-chunk upload reports one line.\n *\n * Silent in production for the same reason {@link parseResponse} is: the origin\n * an app talks to is not something to print into a user's console or ship to an\n * error tracker.\n *\n * @param target - The URL whose request went out unauthenticated.\n * @param reference - The origin the credential was scoped to.\n */\nexport function reportSuppressedCredential(target: string, reference: string): void {\n if (!isDevBuild()) return;\n const targetOrigin = originOf(target);\n if (targetOrigin === null || reported.has(targetOrigin)) return;\n reported.add(targetOrigin);\n console.warn(\n `[tempest-react-sdk] Authorization was not sent to ${targetOrigin}: the credential is scoped to ${originOf(reference) ?? reference}. Add the origin to trustedOrigins if it should receive the token.`,\n );\n}\n\n/**\n * Forget which origins were already reported.\n *\n * Exported for tests, which assert the once-per-origin behaviour and would\n * otherwise leak state between cases.\n */\nexport function resetSuppressedCredentialReports(): void {\n reported.clear();\n}\n"],"mappings":"yCAMA,IAAM,EAAW,IAAI,IAkBrB,SAAS,EAAS,EAA4B,CAC1C,GAAI,CACA,OAAO,IAAI,IAAI,EAAK,WAAW,UAAU,IAAI,CAAC,CAAC,MACnD,MAAQ,CACJ,OAAO,IACX,CACJ,CAoCA,SAAgB,EACZ,EACA,EACA,EAAoC,CAAC,EAC9B,CACP,IAAM,EAAe,EAAS,CAAM,EAGpC,OAFI,IAAiB,MACjB,IAAiB,EAAS,CAAS,GAChC,EAAe,KAAM,GAAW,EAAS,CAAM,IAAM,CAAY,CAC5E,CAgBA,SAAgB,EAA2B,EAAgB,EAAyB,CAChF,GAAI,CAAC,EAAA,WAAW,EAAG,OACnB,IAAM,EAAe,EAAS,CAAM,EAChC,IAAiB,MAAQ,EAAS,IAAI,CAAY,IACtD,EAAS,IAAI,CAAY,EACzB,QAAQ,KACJ,qDAAqD,EAAa,gCAAgC,EAAS,CAAS,GAAK,EAAU,mEACvI,EACJ"}
@@ -0,0 +1,23 @@
1
+ import { isDevBuild as e } from "../utils/dev-mode.js";
2
+ //#region src/http/credential-scope.ts
3
+ var t = /* @__PURE__ */ new Set();
4
+ function n(e) {
5
+ try {
6
+ return new URL(e, globalThis.location?.href).origin;
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+ function r(e, t, r = []) {
12
+ let i = n(e);
13
+ return i === null || i === n(t) || r.some((e) => n(e) === i);
14
+ }
15
+ function i(r, i) {
16
+ if (!e()) return;
17
+ let a = n(r);
18
+ a === null || t.has(a) || (t.add(a), console.warn(`[tempest-react-sdk] Authorization was not sent to ${a}: the credential is scoped to ${n(i) ?? i}. Add the origin to trustedOrigins if it should receive the token.`));
19
+ }
20
+ //#endregion
21
+ export { r as isTrustedCredentialTarget, i as reportSuppressedCredential };
22
+
23
+ //# sourceMappingURL=credential-scope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-scope.js","names":[],"sources":["../../src/http/credential-scope.ts"],"sourcesContent":["import { isDevBuild } from \"../utils/dev-mode\";\n\n/**\n * Origins already reported by {@link reportSuppressedCredential}, so a chunked\n * upload warns once instead of once per chunk.\n */\nconst reported = new Set<string>();\n\n/**\n * The origin a URL resolves to, or `null` when it has none to compare.\n *\n * Relative values resolve against the document, because that is what they mean\n * everywhere else in the SDK: an `endpoint` of `\"/api/uploads\"` and a `baseURL`\n * of `\"/api\"` are the shape you get behind a dev-server or a reverse proxy, and\n * both name the page's own origin. Comparing a resolved absolute target against\n * an unresolved relative reference is how the first draft of this function\n * withheld the credential from every client configured that way.\n *\n * `null` is reserved for the case with genuinely nothing to compare: a relative\n * URL outside a browsing context, where there is no document to resolve against.\n *\n * @param url - Absolute or relative URL.\n * @returns The origin, or `null` when it cannot be resolved.\n */\nfunction originOf(url: string): string | null {\n try {\n return new URL(url, globalThis.location?.href).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Whether a request to `target` may carry the caller's credential.\n *\n * The SDK writes `Authorization: Bearer` in three places, and until 0.66.0 none\n * of them looked at where the request was going. That is only safe while every\n * target URL is written by the app: a resumable upload takes its URL from the\n * server's `Location` header, and `createApiClient` lets an absolute path\n * override `baseURL` entirely, so a URL arriving over the network could decide\n * which origin received the token — and the file bytes with it.\n *\n * Handing an upload off to object storage on another host is ordinary `tus`\n * deployment, not an attack, which is exactly why the credential must be scoped\n * rather than the request blocked: the upload still goes through, it just goes\n * without the API's bearer token.\n *\n * Both sides are resolved against the document first, so a relative `baseURL`\n * or `endpoint` — the shape behind a dev-server or reverse proxy — compares\n * equal to the absolute URL the request actually goes to. Outside a browsing\n * context a relative target resolves to nothing and is allowed: there is no\n * origin for it to cross.\n *\n * @param target - The URL the request is about to go to.\n * @param reference - The origin the credential belongs to, normally the\n * client's `baseURL` or the upload `endpoint`.\n * @param trustedOrigins - Extra origins the app declared, for the legitimate\n * second host: a CDN, a signed upload endpoint.\n * @returns Whether the credential may be attached.\n *\n * @example\n * isTrustedCredentialTarget(\"https://api.acme.com/me\", \"https://api.acme.com\");\n * // true\n * isTrustedCredentialTarget(\"https://cdn.other/u/1\", \"https://api.acme.com\");\n * // false\n */\nexport function isTrustedCredentialTarget(\n target: string,\n reference: string,\n trustedOrigins: readonly string[] = [],\n): boolean {\n const targetOrigin = originOf(target);\n if (targetOrigin === null) return true;\n if (targetOrigin === originOf(reference)) return true;\n return trustedOrigins.some((origin) => originOf(origin) === targetOrigin);\n}\n\n/**\n * Say once, in a development build, that a credential was withheld.\n *\n * Without it the symptom is a `401` from a host the developer never typed, and\n * nothing in the app's code mentions the origin that produced it. Keyed by\n * target origin so a hundred-chunk upload reports one line.\n *\n * Silent in production for the same reason {@link parseResponse} is: the origin\n * an app talks to is not something to print into a user's console or ship to an\n * error tracker.\n *\n * @param target - The URL whose request went out unauthenticated.\n * @param reference - The origin the credential was scoped to.\n */\nexport function reportSuppressedCredential(target: string, reference: string): void {\n if (!isDevBuild()) return;\n const targetOrigin = originOf(target);\n if (targetOrigin === null || reported.has(targetOrigin)) return;\n reported.add(targetOrigin);\n console.warn(\n `[tempest-react-sdk] Authorization was not sent to ${targetOrigin}: the credential is scoped to ${originOf(reference) ?? reference}. Add the origin to trustedOrigins if it should receive the token.`,\n );\n}\n\n/**\n * Forget which origins were already reported.\n *\n * Exported for tests, which assert the once-per-origin behaviour and would\n * otherwise leak state between cases.\n */\nexport function resetSuppressedCredentialReports(): void {\n reported.clear();\n}\n"],"mappings":";;AAMA,IAAM,oBAAW,IAAI,IAAY;AAkBjC,SAAS,EAAS,GAA4B;CAC1C,IAAI;EACA,OAAO,IAAI,IAAI,GAAK,WAAW,UAAU,IAAI,CAAC,CAAC;CACnD,QAAQ;EACJ,OAAO;CACX;AACJ;AAoCA,SAAgB,EACZ,GACA,GACA,IAAoC,CAAC,GAC9B;CACP,IAAM,IAAe,EAAS,CAAM;CAGpC,OAFI,MAAiB,QACjB,MAAiB,EAAS,CAAS,KAChC,EAAe,MAAM,MAAW,EAAS,CAAM,MAAM,CAAY;AAC5E;AAgBA,SAAgB,EAA2B,GAAgB,GAAyB;CAChF,IAAI,CAAC,EAAW,GAAG;CACnB,IAAM,IAAe,EAAS,CAAM;CAChC,MAAiB,QAAQ,EAAS,IAAI,CAAY,MACtD,EAAS,IAAI,CAAY,GACzB,QAAQ,KACJ,qDAAqD,EAAa,gCAAgC,EAAS,CAAS,KAAK,EAAU,mEACvI;AACJ"}
@@ -1,2 +1,2 @@
1
- const e=require("./errors.cjs");var t={offline:`Sem conexão com o servidor. Verifique sua internet e tente de novo.`,validation:`Confira os campos destacados e tente de novo.`},n=`tempest.error.offline`,r=`tempest.error.validation`;function i(){return typeof navigator<`u`&&navigator.onLine===!1}function a(t,n,r){if(n===``||n===e.syntheticDetail(r))return!1;let i=Object.values(t);return i.length===1&&i[0].trim()===n}function o(n,r,o){let s=o?.offline??t.offline;if(e.isApiError(n)){let i=n.code===void 0?void 0:o?.codes?.[n.code];if(i!==void 0)return i;if(n.status===0)return s;let c=n.detail.trim();return n.fields&&Object.keys(n.fields).length>0?o?.useDetail!==!1&&a(n.fields,c,n.status)?c:o?.validation??t.validation:o?.useDetail!==!1&&c!==``&&c!==e.syntheticDetail(n.status)?c:`${r} (HTTP ${n.status})`}return i()?s:r}exports.API_ERROR_OFFLINE_KEY=n,exports.API_ERROR_VALIDATION_KEY=r,exports.DEFAULT_API_ERROR_STRINGS=t,exports.describeApiError=o;
1
+ const e=require("./errors.cjs");var t={offline:`Sem conexão com o servidor. Verifique sua internet e tente de novo.`,validation:`Confira os campos destacados e tente de novo.`},n=`tempest.error.offline`,r=`tempest.error.validation`;function i(){return typeof navigator<`u`&&navigator.onLine===!1}function a(t,n,r){if(n===``||n===e.syntheticDetail(r))return!1;let i=Object.values(t);return i.length===1&&i[0].trim()===n}function o(n,r,o){let s=o?.offline??t.offline;if(e.isApiError(n)){let i=n.code===void 0?void 0:o?.codes?.[n.code];if(i!==void 0)return i;let c=o?.statuses?.[n.status];if(c!==void 0)return c;if(n.status===0)return s;let l=n.detail.trim();return n.fields&&Object.keys(n.fields).length>0?o?.useDetail!==!1&&a(n.fields,l,n.status)?l:o?.validation??t.validation:o?.useDetail!==!1&&l!==``&&l!==e.syntheticDetail(n.status)?l:`${r} (HTTP ${n.status})`}return i()?s:r}exports.API_ERROR_OFFLINE_KEY=n,exports.API_ERROR_VALIDATION_KEY=r,exports.DEFAULT_API_ERROR_STRINGS=t,exports.describeApiError=o;
2
2
  //# sourceMappingURL=describe-api-error.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"describe-api-error.cjs","names":[],"sources":["../../src/http/describe-api-error.ts"],"sourcesContent":["// The last mile of error handling: a typed error is what code reads, a sentence\n// is what a person reads, and every app was writing the funnel between the two.\n// The case everyone forgets is the request that never reached the server, which\n// without special handling renders as the nonsense \"erro 0\".\n\nimport { isApiError, syntheticDetail } from \"./errors\";\n\n/** The fixed sentences {@link describeApiError} may need. */\nexport interface ApiErrorStrings {\n /** Shown when the request never reached the server. */\n offline: string;\n /**\n * Shown when the backend rejected the payload field by field.\n *\n * The per-field messages are on `error.fields`, to be attached to the inputs\n * themselves; this sentence is what the toast says.\n *\n * It does not apply to the one rejection that named a single field with a\n * finished sentence — there the server's own `detail` is shown instead, since\n * it is the same string `fields` carries. Pass `useDetail: false` to force\n * this sentence in that case too.\n */\n validation: string;\n}\n\n/**\n * PT-BR defaults, used when no strings are supplied and no catalog answers.\n *\n * The SDK's copy is pt-BR everywhere else (`FilterBar`, `DataTable`, `Chat`), so\n * the default here matches rather than introducing an English string that only\n * shows up on a network failure.\n */\nexport const DEFAULT_API_ERROR_STRINGS: ApiErrorStrings = {\n offline: \"Sem conexão com o servidor. Verifique sua internet e tente de novo.\",\n validation: \"Confira os campos destacados e tente de novo.\",\n};\n\n/**\n * Translation key the {@link useDescribeApiError} hook looks up.\n *\n * A catalog that does not define it falls back to\n * {@link DEFAULT_API_ERROR_STRINGS}, because `t` returns the key itself when the\n * lookup misses and printing `tempest.error.offline` at the user would be worse\n * than printing pt-BR at them.\n */\nexport const API_ERROR_OFFLINE_KEY = \"tempest.error.offline\";\n\n/**\n * Translation key for the validation sentence, looked up the same way as\n * {@link API_ERROR_OFFLINE_KEY}.\n */\nexport const API_ERROR_VALIDATION_KEY = \"tempest.error.validation\";\n\n/**\n * Everything {@link describeApiError} accepts beyond the error and the fallback.\n *\n * Extends the fixed sentences rather than sitting beside them, so a caller that\n * already passed `{ offline, validation }` keeps compiling untouched.\n */\nexport interface DescribeApiErrorOptions extends Partial<ApiErrorStrings> {\n /**\n * Maps the backend's programmatic `code` to a sentence in your language.\n *\n * The client already surfaces `code` on `ApiError`, but without this every\n * app writes the same `switch` over it. A hit here wins over every other\n * step: it is the only sentence written for that exact case, by someone who\n * knew both the backend contract and the screen it lands on.\n */\n codes?: Readonly<Record<string, string>>;\n /**\n * Whether the backend's `detail` may be shown when no `code` matched.\n * Default `true`.\n *\n * Set it to `false` when `detail` is written for developers rather than\n * users, or when it could echo internals — the result is then always either\n * a sentence you wrote or the fallback.\n */\n useDetail?: boolean;\n}\n\n/**\n * Whether the browser currently reports itself as offline.\n *\n * `fetch` rejects a network failure with a plain `TypeError` whose message\n * differs per browser (\"Failed to fetch\", \"NetworkError when attempting to fetch\n * resource.\", \"Load failed\"), so sniffing the message is not portable. The online\n * flag is, and it is the signal that matters for the sentence being chosen.\n *\n * @returns `true` only when the environment positively says it is offline.\n */\nfunction browserIsOffline(): boolean {\n return typeof navigator !== \"undefined\" && navigator.onLine === false;\n}\n\n/**\n * Whether `fields` is the flattened single-field envelope, carrying `detail`.\n *\n * The check is identity rather than shape-sniffing. `collectFields` has exactly\n * two sources: FastAPI's `detail` **list**, whose entries are the per-issue\n * messages, and the flattened envelope, whose one entry is built from the same\n * string that becomes `ApiError.detail`. So one entry equal to `detail` can only\n * have come from the second, and returning `detail` there shows the string the\n * form is already about to attach to that input.\n *\n * @param fields - The error's field messages.\n * @param detail - `error.detail`, already trimmed.\n * @param status - The HTTP status, to recognise the synthetic `Erro <status>`\n * that stands in when the body carried no message at all.\n * @returns Whether `detail` is safe to show as the sentence.\n */\nfunction singleFieldSentence(\n fields: Record<string, string>,\n detail: string,\n status: number,\n): boolean {\n if (detail === \"\" || detail === syntheticDetail(status)) return false;\n const messages = Object.values(fields);\n return messages.length === 1 && messages[0].trim() === detail;\n}\n\n/**\n * Turn any caught value into a sentence worth showing.\n *\n * The funnel, in order:\n *\n * 0. `codes[error.code]` — the sentence you wrote for that exact backend case.\n * Checked first because nothing the funnel derives can beat it, and because a\n * request that never landed carries no `code` for it to shadow.\n * 1. A request that never reached the server — `status === 0`, or a non-API\n * error thrown while the browser reports itself offline — produces the\n * offline sentence. This is the step apps skip, and skipping it renders\n * \"erro 0\" or a raw `TypeError` at the user.\n * 2. A validation rejection — `error.fields` is set — produces the validation\n * sentence, **not** `detail`. On a FastAPI `422` the `detail` line is assembled\n * from the backend's field paths and the validator's own wording\n * (`\"items.0.price: Input should be greater than 0\"`), which is right for a\n * log and wrong for a person: it is half English in a pt-BR screen and it\n * names internals. The per-field messages stay on `fields`, where a form can\n * attach them to the inputs that failed.\n *\n * **Unless the rejection named exactly one field and its message is\n * `detail` itself**, in which case `detail` is returned. That is not a guess\n * about the text: `collectFields` fills a single entry from `detail` only for\n * the flattened envelope a `tempest-fastapi-sdk` backend sends, where the\n * server wrote one finished sentence about one field (`\"CPF ou CNPJ\n * inválido\"`). Returning it shows the same string that is already on\n * `fields`, so nothing is invented and nothing is lost. The assembled FastAPI\n * line never reaches this branch, because it comes from the `detail` **list**\n * and its entries are the per-issue messages, not `detail`.\n *\n * This is a fix, not a preference. Before `namedField` shipped in 0.54.0,\n * `fields` was empty against that backend and the sentence reached the user\n * through step 3; filling `fields` silently replaced it with \"Confira os\n * campos destacados\", which presumes a screen that highlights fields — and\n * no app highlighted anything on the day of the bump.\n *\n * `useDetail: false` still suppresses it, and is the way to force the fixed\n * sentence for a backend whose text is written for developers. `validation`\n * does **not** override it, and that is on purpose: its own contract is the\n * sentence for a payload rejected *field by field*, which one field carrying\n * one finished sentence is not — and `useDescribeApiError` always passes\n * `validation`, so treating it as an override would mean the branch never ran\n * for any component, which is every caller that matters here.\n * 3. The backend's own `detail`, which is the most specific thing available and\n * is already written for a person — unless `useDetail: false` says that text\n * is for developers.\n * 4. `fallback`, with `(HTTP <status>)` appended when a status is known, so the\n * screenshot in the support ticket carries the one fact a developer needs.\n *\n * Pure on purpose: it works in an interceptor, in a logger and anywhere outside\n * the React tree. {@link useDescribeApiError} is the same funnel with the\n * sentences resolved through `I18nProvider`.\n *\n * @example\n * catch (error) {\n * toast(describeApiError(error, \"Não foi possível salvar o pedido\"));\n * }\n *\n * @example\n * catch (error) {\n * toast(\n * describeApiError(error, \"Não foi possível se candidatar\", {\n * codes: {\n * SERVICE_FULL: \"Este serviço atingiu o limite de vagas.\",\n * CANDIDATE_ALREADY_EXISTS: \"Você já se candidatou a este serviço.\",\n * },\n * useDetail: false,\n * }),\n * );\n * }\n *\n * @param error - The caught value, of any shape.\n * @param fallback - What to say when the error carries nothing better.\n * @param options - A `codes` catalog, `useDetail`, and overrides for the fixed\n * sentences.\n * @returns A sentence to show the user.\n */\nexport function describeApiError(\n error: unknown,\n fallback: string,\n options?: DescribeApiErrorOptions,\n): string {\n const offline = options?.offline ?? DEFAULT_API_ERROR_STRINGS.offline;\n\n if (isApiError(error)) {\n const mapped = error.code === undefined ? undefined : options?.codes?.[error.code];\n if (mapped !== undefined) return mapped;\n if (error.status === 0) return offline;\n const detail = error.detail.trim();\n if (error.fields && Object.keys(error.fields).length > 0) {\n if (\n options?.useDetail !== false &&\n singleFieldSentence(error.fields, detail, error.status)\n ) {\n return detail;\n }\n return options?.validation ?? DEFAULT_API_ERROR_STRINGS.validation;\n }\n if (\n options?.useDetail !== false &&\n detail !== \"\" &&\n detail !== syntheticDetail(error.status)\n ) {\n return detail;\n }\n return `${fallback} (HTTP ${error.status})`;\n }\n\n if (browserIsOffline()) return offline;\n\n return fallback;\n}\n"],"mappings":"gCAgCA,IAAa,EAA6C,CACtD,QAAS,sEACT,WAAY,+CAChB,EAUa,EAAwB,wBAMxB,EAA2B,2BAuCxC,SAAS,GAA4B,CACjC,OAAO,OAAO,UAAc,KAAe,UAAU,SAAW,EACpE,CAkBA,SAAS,EACL,EACA,EACA,EACO,CACP,GAAI,IAAW,IAAM,IAAW,EAAA,gBAAgB,CAAM,EAAG,MAAO,GAChE,IAAM,EAAW,OAAO,OAAO,CAAM,EACrC,OAAO,EAAS,SAAW,GAAK,EAAS,EAAE,CAAC,KAAK,IAAM,CAC3D,CA+EA,SAAgB,EACZ,EACA,EACA,EACM,CACN,IAAM,EAAU,GAAS,SAAW,EAA0B,QAE9D,GAAI,EAAA,WAAW,CAAK,EAAG,CACnB,IAAM,EAAS,EAAM,OAAS,IAAA,GAAY,IAAA,GAAY,GAAS,QAAQ,EAAM,MAC7E,GAAI,IAAW,IAAA,GAAW,OAAO,EACjC,GAAI,EAAM,SAAW,EAAG,OAAO,EAC/B,IAAM,EAAS,EAAM,OAAO,KAAK,EAiBjC,OAhBI,EAAM,QAAU,OAAO,KAAK,EAAM,MAAM,CAAC,CAAC,OAAS,EAE/C,GAAS,YAAc,IACvB,EAAoB,EAAM,OAAQ,EAAQ,EAAM,MAAM,EAE/C,EAEJ,GAAS,YAAc,EAA0B,WAGxD,GAAS,YAAc,IACvB,IAAW,IACX,IAAW,EAAA,gBAAgB,EAAM,MAAM,EAEhC,EAEJ,GAAG,EAAS,SAAS,EAAM,OAAO,EAC7C,CAIA,OAFI,EAAiB,EAAU,EAExB,CACX"}
1
+ {"version":3,"file":"describe-api-error.cjs","names":[],"sources":["../../src/http/describe-api-error.ts"],"sourcesContent":["// The last mile of error handling: a typed error is what code reads, a sentence\n// is what a person reads, and every app was writing the funnel between the two.\n// The case everyone forgets is the request that never reached the server, which\n// without special handling renders as the nonsense \"erro 0\".\n\nimport { isApiError, syntheticDetail } from \"./errors\";\n\n/** The fixed sentences {@link describeApiError} may need. */\nexport interface ApiErrorStrings {\n /** Shown when the request never reached the server. */\n offline: string;\n /**\n * Shown when the backend rejected the payload field by field.\n *\n * The per-field messages are on `error.fields`, to be attached to the inputs\n * themselves; this sentence is what the toast says.\n *\n * It does not apply to the one rejection that named a single field with a\n * finished sentence — there the server's own `detail` is shown instead, since\n * it is the same string `fields` carries. Pass `useDetail: false` to force\n * this sentence in that case too.\n */\n validation: string;\n}\n\n/**\n * PT-BR defaults, used when no strings are supplied and no catalog answers.\n *\n * The SDK's copy is pt-BR everywhere else (`FilterBar`, `DataTable`, `Chat`), so\n * the default here matches rather than introducing an English string that only\n * shows up on a network failure.\n */\nexport const DEFAULT_API_ERROR_STRINGS: ApiErrorStrings = {\n offline: \"Sem conexão com o servidor. Verifique sua internet e tente de novo.\",\n validation: \"Confira os campos destacados e tente de novo.\",\n};\n\n/**\n * Translation key the {@link useDescribeApiError} hook looks up.\n *\n * A catalog that does not define it falls back to\n * {@link DEFAULT_API_ERROR_STRINGS}, because `t` returns the key itself when the\n * lookup misses and printing `tempest.error.offline` at the user would be worse\n * than printing pt-BR at them.\n */\nexport const API_ERROR_OFFLINE_KEY = \"tempest.error.offline\";\n\n/**\n * Translation key for the validation sentence, looked up the same way as\n * {@link API_ERROR_OFFLINE_KEY}.\n */\nexport const API_ERROR_VALIDATION_KEY = \"tempest.error.validation\";\n\n/**\n * Everything {@link describeApiError} accepts beyond the error and the fallback.\n *\n * Extends the fixed sentences rather than sitting beside them, so a caller that\n * already passed `{ offline, validation }` keeps compiling untouched.\n */\nexport interface DescribeApiErrorOptions extends Partial<ApiErrorStrings> {\n /**\n * Maps the backend's programmatic `code` to a sentence in your language.\n *\n * The client already surfaces `code` on `ApiError`, but without this every\n * app writes the same `switch` over it. A hit here wins over every other\n * step: it is the only sentence written for that exact case, by someone who\n * knew both the backend contract and the screen it lands on.\n */\n codes?: Readonly<Record<string, string>>;\n /**\n * Maps an HTTP status to a sentence, for the failures that arrive with no\n * useful body.\n *\n * `codes` covers the cases the backend named; this covers the ones it did\n * not. The reference case is the `403` of an admin-only route: the body\n * carries a generic `detail` (or none), so the funnel fell through to the\n * fallback and an administrator read \"Não foi possível carregar\" when the\n * fact that answers their question is \"esta listagem é só para\n * administradores\".\n *\n * Checked after `codes` and **before** the backend's `detail`: a generic\n * sentence emitted by the framework is worse than the one the app wrote for\n * that status. `useDetail: false` remains the way to suppress `detail`\n * entirely.\n *\n * A `0` key is honoured like any other, ahead of the offline sentence —\n * `statuses` is the caller's explicit map, and the fixed `offline` string is\n * already the way to reword that case. Status `0` covers a request that\n * never landed **and** one abandoned by the client timeout, so an app that\n * maps it is taking both.\n */\n statuses?: Readonly<Record<number, string>>;\n /**\n * Whether the backend's `detail` may be shown when no `code` matched.\n * Default `true`.\n *\n * Set it to `false` when `detail` is written for developers rather than\n * users, or when it could echo internals — the result is then always either\n * a sentence you wrote or the fallback.\n */\n useDetail?: boolean;\n}\n\n/**\n * Whether the browser currently reports itself as offline.\n *\n * `fetch` rejects a network failure with a plain `TypeError` whose message\n * differs per browser (\"Failed to fetch\", \"NetworkError when attempting to fetch\n * resource.\", \"Load failed\"), so sniffing the message is not portable. The online\n * flag is, and it is the signal that matters for the sentence being chosen.\n *\n * @returns `true` only when the environment positively says it is offline.\n */\nfunction browserIsOffline(): boolean {\n return typeof navigator !== \"undefined\" && navigator.onLine === false;\n}\n\n/**\n * Whether `fields` is the flattened single-field envelope, carrying `detail`.\n *\n * The check is identity rather than shape-sniffing. `collectFields` has exactly\n * two sources: FastAPI's `detail` **list**, whose entries are the per-issue\n * messages, and the flattened envelope, whose one entry is built from the same\n * string that becomes `ApiError.detail`. So one entry equal to `detail` can only\n * have come from the second, and returning `detail` there shows the string the\n * form is already about to attach to that input.\n *\n * @param fields - The error's field messages.\n * @param detail - `error.detail`, already trimmed.\n * @param status - The HTTP status, to recognise the synthetic `Erro <status>`\n * that stands in when the body carried no message at all.\n * @returns Whether `detail` is safe to show as the sentence.\n */\nfunction singleFieldSentence(\n fields: Record<string, string>,\n detail: string,\n status: number,\n): boolean {\n if (detail === \"\" || detail === syntheticDetail(status)) return false;\n const messages = Object.values(fields);\n return messages.length === 1 && messages[0].trim() === detail;\n}\n\n/**\n * Turn any caught value into a sentence worth showing.\n *\n * The funnel, in order:\n *\n * 0. `codes[error.code]` — the sentence you wrote for that exact backend case.\n * Checked first because nothing the funnel derives can beat it, and because a\n * request that never landed carries no `code` for it to shadow.\n * 0.5. `statuses[error.status]` — the sentence for a whole class of failure,\n * for the errors that arrive with no useful body. It beats the backend's\n * `detail`, because a generic line emitted by the framework is worse than the\n * one the app wrote for that status, and it beats the offline sentence when\n * the caller maps `0` explicitly.\n * 1. A request that never reached the server — `status === 0`, or a non-API\n * error thrown while the browser reports itself offline — produces the\n * offline sentence. This is the step apps skip, and skipping it renders\n * \"erro 0\" or a raw `TypeError` at the user.\n * 2. A validation rejection — `error.fields` is set — produces the validation\n * sentence, **not** `detail`. On a FastAPI `422` the `detail` line is assembled\n * from the backend's field paths and the validator's own wording\n * (`\"items.0.price: Input should be greater than 0\"`), which is right for a\n * log and wrong for a person: it is half English in a pt-BR screen and it\n * names internals. The per-field messages stay on `fields`, where a form can\n * attach them to the inputs that failed.\n *\n * **Unless the rejection named exactly one field and its message is\n * `detail` itself**, in which case `detail` is returned. That is not a guess\n * about the text: `collectFields` fills a single entry from `detail` only for\n * the flattened envelope a `tempest-fastapi-sdk` backend sends, where the\n * server wrote one finished sentence about one field (`\"CPF ou CNPJ\n * inválido\"`). Returning it shows the same string that is already on\n * `fields`, so nothing is invented and nothing is lost. The assembled FastAPI\n * line never reaches this branch, because it comes from the `detail` **list**\n * and its entries are the per-issue messages, not `detail`.\n *\n * This is a fix, not a preference. Before `namedField` shipped in 0.54.0,\n * `fields` was empty against that backend and the sentence reached the user\n * through step 3; filling `fields` silently replaced it with \"Confira os\n * campos destacados\", which presumes a screen that highlights fields — and\n * no app highlighted anything on the day of the bump.\n *\n * `useDetail: false` still suppresses it, and is the way to force the fixed\n * sentence for a backend whose text is written for developers. `validation`\n * does **not** override it, and that is on purpose: its own contract is the\n * sentence for a payload rejected *field by field*, which one field carrying\n * one finished sentence is not — and `useDescribeApiError` always passes\n * `validation`, so treating it as an override would mean the branch never ran\n * for any component, which is every caller that matters here.\n * 3. The backend's own `detail`, which is the most specific thing available and\n * is already written for a person — unless `useDetail: false` says that text\n * is for developers.\n * 4. `fallback`, with `(HTTP <status>)` appended when a status is known, so the\n * screenshot in the support ticket carries the one fact a developer needs.\n *\n * Pure on purpose: it works in an interceptor, in a logger and anywhere outside\n * the React tree. {@link useDescribeApiError} is the same funnel with the\n * sentences resolved through `I18nProvider`.\n *\n * @example\n * catch (error) {\n * toast(describeApiError(error, \"Não foi possível salvar o pedido\"));\n * }\n *\n * @example\n * catch (error) {\n * toast(\n * describeApiError(error, \"Não foi possível se candidatar\", {\n * codes: {\n * SERVICE_FULL: \"Este serviço atingiu o limite de vagas.\",\n * CANDIDATE_ALREADY_EXISTS: \"Você já se candidatou a este serviço.\",\n * },\n * useDetail: false,\n * }),\n * );\n * }\n *\n * @param error - The caught value, of any shape.\n * @param fallback - What to say when the error carries nothing better.\n * @param options - A `codes` catalog, `useDetail`, and overrides for the fixed\n * sentences.\n * @returns A sentence to show the user.\n */\nexport function describeApiError(\n error: unknown,\n fallback: string,\n options?: DescribeApiErrorOptions,\n): string {\n const offline = options?.offline ?? DEFAULT_API_ERROR_STRINGS.offline;\n\n if (isApiError(error)) {\n const mapped = error.code === undefined ? undefined : options?.codes?.[error.code];\n if (mapped !== undefined) return mapped;\n const byStatus = options?.statuses?.[error.status];\n if (byStatus !== undefined) return byStatus;\n if (error.status === 0) return offline;\n const detail = error.detail.trim();\n if (error.fields && Object.keys(error.fields).length > 0) {\n if (\n options?.useDetail !== false &&\n singleFieldSentence(error.fields, detail, error.status)\n ) {\n return detail;\n }\n return options?.validation ?? DEFAULT_API_ERROR_STRINGS.validation;\n }\n if (\n options?.useDetail !== false &&\n detail !== \"\" &&\n detail !== syntheticDetail(error.status)\n ) {\n return detail;\n }\n return `${fallback} (HTTP ${error.status})`;\n }\n\n if (browserIsOffline()) return offline;\n\n return fallback;\n}\n"],"mappings":"gCAgCA,IAAa,EAA6C,CACtD,QAAS,sEACT,WAAY,+CAChB,EAUa,EAAwB,wBAMxB,EAA2B,2BA8DxC,SAAS,GAA4B,CACjC,OAAO,OAAO,UAAc,KAAe,UAAU,SAAW,EACpE,CAkBA,SAAS,EACL,EACA,EACA,EACO,CACP,GAAI,IAAW,IAAM,IAAW,EAAA,gBAAgB,CAAM,EAAG,MAAO,GAChE,IAAM,EAAW,OAAO,OAAO,CAAM,EACrC,OAAO,EAAS,SAAW,GAAK,EAAS,EAAE,CAAC,KAAK,IAAM,CAC3D,CAoFA,SAAgB,EACZ,EACA,EACA,EACM,CACN,IAAM,EAAU,GAAS,SAAW,EAA0B,QAE9D,GAAI,EAAA,WAAW,CAAK,EAAG,CACnB,IAAM,EAAS,EAAM,OAAS,IAAA,GAAY,IAAA,GAAY,GAAS,QAAQ,EAAM,MAC7E,GAAI,IAAW,IAAA,GAAW,OAAO,EACjC,IAAM,EAAW,GAAS,WAAW,EAAM,QAC3C,GAAI,IAAa,IAAA,GAAW,OAAO,EACnC,GAAI,EAAM,SAAW,EAAG,OAAO,EAC/B,IAAM,EAAS,EAAM,OAAO,KAAK,EAiBjC,OAhBI,EAAM,QAAU,OAAO,KAAK,EAAM,MAAM,CAAC,CAAC,OAAS,EAE/C,GAAS,YAAc,IACvB,EAAoB,EAAM,OAAQ,EAAQ,EAAM,MAAM,EAE/C,EAEJ,GAAS,YAAc,EAA0B,WAGxD,GAAS,YAAc,IACvB,IAAW,IACX,IAAW,EAAA,gBAAgB,EAAM,MAAM,EAEhC,EAEJ,GAAG,EAAS,SAAS,EAAM,OAAO,EAC7C,CAIA,OAFI,EAAiB,EAAU,EAExB,CACX"}
@@ -17,9 +17,11 @@ function s(r, i, s) {
17
17
  if (e(r)) {
18
18
  let e = r.code === void 0 ? void 0 : s?.codes?.[r.code];
19
19
  if (e !== void 0) return e;
20
+ let a = s?.statuses?.[r.status];
21
+ if (a !== void 0) return a;
20
22
  if (r.status === 0) return c;
21
- let a = r.detail.trim();
22
- return r.fields && Object.keys(r.fields).length > 0 ? s?.useDetail !== !1 && o(r.fields, a, r.status) ? a : s?.validation ?? n.validation : s?.useDetail !== !1 && a !== "" && a !== t(r.status) ? a : `${i} (HTTP ${r.status})`;
23
+ let l = r.detail.trim();
24
+ return r.fields && Object.keys(r.fields).length > 0 ? s?.useDetail !== !1 && o(r.fields, l, r.status) ? l : s?.validation ?? n.validation : s?.useDetail !== !1 && l !== "" && l !== t(r.status) ? l : `${i} (HTTP ${r.status})`;
23
25
  }
24
26
  return a() ? c : i;
25
27
  }
@@ -1 +1 @@
1
- {"version":3,"file":"describe-api-error.js","names":[],"sources":["../../src/http/describe-api-error.ts"],"sourcesContent":["// The last mile of error handling: a typed error is what code reads, a sentence\n// is what a person reads, and every app was writing the funnel between the two.\n// The case everyone forgets is the request that never reached the server, which\n// without special handling renders as the nonsense \"erro 0\".\n\nimport { isApiError, syntheticDetail } from \"./errors\";\n\n/** The fixed sentences {@link describeApiError} may need. */\nexport interface ApiErrorStrings {\n /** Shown when the request never reached the server. */\n offline: string;\n /**\n * Shown when the backend rejected the payload field by field.\n *\n * The per-field messages are on `error.fields`, to be attached to the inputs\n * themselves; this sentence is what the toast says.\n *\n * It does not apply to the one rejection that named a single field with a\n * finished sentence — there the server's own `detail` is shown instead, since\n * it is the same string `fields` carries. Pass `useDetail: false` to force\n * this sentence in that case too.\n */\n validation: string;\n}\n\n/**\n * PT-BR defaults, used when no strings are supplied and no catalog answers.\n *\n * The SDK's copy is pt-BR everywhere else (`FilterBar`, `DataTable`, `Chat`), so\n * the default here matches rather than introducing an English string that only\n * shows up on a network failure.\n */\nexport const DEFAULT_API_ERROR_STRINGS: ApiErrorStrings = {\n offline: \"Sem conexão com o servidor. Verifique sua internet e tente de novo.\",\n validation: \"Confira os campos destacados e tente de novo.\",\n};\n\n/**\n * Translation key the {@link useDescribeApiError} hook looks up.\n *\n * A catalog that does not define it falls back to\n * {@link DEFAULT_API_ERROR_STRINGS}, because `t` returns the key itself when the\n * lookup misses and printing `tempest.error.offline` at the user would be worse\n * than printing pt-BR at them.\n */\nexport const API_ERROR_OFFLINE_KEY = \"tempest.error.offline\";\n\n/**\n * Translation key for the validation sentence, looked up the same way as\n * {@link API_ERROR_OFFLINE_KEY}.\n */\nexport const API_ERROR_VALIDATION_KEY = \"tempest.error.validation\";\n\n/**\n * Everything {@link describeApiError} accepts beyond the error and the fallback.\n *\n * Extends the fixed sentences rather than sitting beside them, so a caller that\n * already passed `{ offline, validation }` keeps compiling untouched.\n */\nexport interface DescribeApiErrorOptions extends Partial<ApiErrorStrings> {\n /**\n * Maps the backend's programmatic `code` to a sentence in your language.\n *\n * The client already surfaces `code` on `ApiError`, but without this every\n * app writes the same `switch` over it. A hit here wins over every other\n * step: it is the only sentence written for that exact case, by someone who\n * knew both the backend contract and the screen it lands on.\n */\n codes?: Readonly<Record<string, string>>;\n /**\n * Whether the backend's `detail` may be shown when no `code` matched.\n * Default `true`.\n *\n * Set it to `false` when `detail` is written for developers rather than\n * users, or when it could echo internals — the result is then always either\n * a sentence you wrote or the fallback.\n */\n useDetail?: boolean;\n}\n\n/**\n * Whether the browser currently reports itself as offline.\n *\n * `fetch` rejects a network failure with a plain `TypeError` whose message\n * differs per browser (\"Failed to fetch\", \"NetworkError when attempting to fetch\n * resource.\", \"Load failed\"), so sniffing the message is not portable. The online\n * flag is, and it is the signal that matters for the sentence being chosen.\n *\n * @returns `true` only when the environment positively says it is offline.\n */\nfunction browserIsOffline(): boolean {\n return typeof navigator !== \"undefined\" && navigator.onLine === false;\n}\n\n/**\n * Whether `fields` is the flattened single-field envelope, carrying `detail`.\n *\n * The check is identity rather than shape-sniffing. `collectFields` has exactly\n * two sources: FastAPI's `detail` **list**, whose entries are the per-issue\n * messages, and the flattened envelope, whose one entry is built from the same\n * string that becomes `ApiError.detail`. So one entry equal to `detail` can only\n * have come from the second, and returning `detail` there shows the string the\n * form is already about to attach to that input.\n *\n * @param fields - The error's field messages.\n * @param detail - `error.detail`, already trimmed.\n * @param status - The HTTP status, to recognise the synthetic `Erro <status>`\n * that stands in when the body carried no message at all.\n * @returns Whether `detail` is safe to show as the sentence.\n */\nfunction singleFieldSentence(\n fields: Record<string, string>,\n detail: string,\n status: number,\n): boolean {\n if (detail === \"\" || detail === syntheticDetail(status)) return false;\n const messages = Object.values(fields);\n return messages.length === 1 && messages[0].trim() === detail;\n}\n\n/**\n * Turn any caught value into a sentence worth showing.\n *\n * The funnel, in order:\n *\n * 0. `codes[error.code]` — the sentence you wrote for that exact backend case.\n * Checked first because nothing the funnel derives can beat it, and because a\n * request that never landed carries no `code` for it to shadow.\n * 1. A request that never reached the server — `status === 0`, or a non-API\n * error thrown while the browser reports itself offline — produces the\n * offline sentence. This is the step apps skip, and skipping it renders\n * \"erro 0\" or a raw `TypeError` at the user.\n * 2. A validation rejection — `error.fields` is set — produces the validation\n * sentence, **not** `detail`. On a FastAPI `422` the `detail` line is assembled\n * from the backend's field paths and the validator's own wording\n * (`\"items.0.price: Input should be greater than 0\"`), which is right for a\n * log and wrong for a person: it is half English in a pt-BR screen and it\n * names internals. The per-field messages stay on `fields`, where a form can\n * attach them to the inputs that failed.\n *\n * **Unless the rejection named exactly one field and its message is\n * `detail` itself**, in which case `detail` is returned. That is not a guess\n * about the text: `collectFields` fills a single entry from `detail` only for\n * the flattened envelope a `tempest-fastapi-sdk` backend sends, where the\n * server wrote one finished sentence about one field (`\"CPF ou CNPJ\n * inválido\"`). Returning it shows the same string that is already on\n * `fields`, so nothing is invented and nothing is lost. The assembled FastAPI\n * line never reaches this branch, because it comes from the `detail` **list**\n * and its entries are the per-issue messages, not `detail`.\n *\n * This is a fix, not a preference. Before `namedField` shipped in 0.54.0,\n * `fields` was empty against that backend and the sentence reached the user\n * through step 3; filling `fields` silently replaced it with \"Confira os\n * campos destacados\", which presumes a screen that highlights fields — and\n * no app highlighted anything on the day of the bump.\n *\n * `useDetail: false` still suppresses it, and is the way to force the fixed\n * sentence for a backend whose text is written for developers. `validation`\n * does **not** override it, and that is on purpose: its own contract is the\n * sentence for a payload rejected *field by field*, which one field carrying\n * one finished sentence is not — and `useDescribeApiError` always passes\n * `validation`, so treating it as an override would mean the branch never ran\n * for any component, which is every caller that matters here.\n * 3. The backend's own `detail`, which is the most specific thing available and\n * is already written for a person — unless `useDetail: false` says that text\n * is for developers.\n * 4. `fallback`, with `(HTTP <status>)` appended when a status is known, so the\n * screenshot in the support ticket carries the one fact a developer needs.\n *\n * Pure on purpose: it works in an interceptor, in a logger and anywhere outside\n * the React tree. {@link useDescribeApiError} is the same funnel with the\n * sentences resolved through `I18nProvider`.\n *\n * @example\n * catch (error) {\n * toast(describeApiError(error, \"Não foi possível salvar o pedido\"));\n * }\n *\n * @example\n * catch (error) {\n * toast(\n * describeApiError(error, \"Não foi possível se candidatar\", {\n * codes: {\n * SERVICE_FULL: \"Este serviço atingiu o limite de vagas.\",\n * CANDIDATE_ALREADY_EXISTS: \"Você já se candidatou a este serviço.\",\n * },\n * useDetail: false,\n * }),\n * );\n * }\n *\n * @param error - The caught value, of any shape.\n * @param fallback - What to say when the error carries nothing better.\n * @param options - A `codes` catalog, `useDetail`, and overrides for the fixed\n * sentences.\n * @returns A sentence to show the user.\n */\nexport function describeApiError(\n error: unknown,\n fallback: string,\n options?: DescribeApiErrorOptions,\n): string {\n const offline = options?.offline ?? DEFAULT_API_ERROR_STRINGS.offline;\n\n if (isApiError(error)) {\n const mapped = error.code === undefined ? undefined : options?.codes?.[error.code];\n if (mapped !== undefined) return mapped;\n if (error.status === 0) return offline;\n const detail = error.detail.trim();\n if (error.fields && Object.keys(error.fields).length > 0) {\n if (\n options?.useDetail !== false &&\n singleFieldSentence(error.fields, detail, error.status)\n ) {\n return detail;\n }\n return options?.validation ?? DEFAULT_API_ERROR_STRINGS.validation;\n }\n if (\n options?.useDetail !== false &&\n detail !== \"\" &&\n detail !== syntheticDetail(error.status)\n ) {\n return detail;\n }\n return `${fallback} (HTTP ${error.status})`;\n }\n\n if (browserIsOffline()) return offline;\n\n return fallback;\n}\n"],"mappings":";;AAgCA,IAAa,IAA6C;CACtD,SAAS;CACT,YAAY;AAChB,GAUa,IAAwB,yBAMxB,IAA2B;AAuCxC,SAAS,IAA4B;CACjC,OAAO,OAAO,YAAc,OAAe,UAAU,WAAW;AACpE;AAkBA,SAAS,EACL,GACA,GACA,GACO;CACP,IAAI,MAAW,MAAM,MAAW,EAAgB,CAAM,GAAG,OAAO;CAChE,IAAM,IAAW,OAAO,OAAO,CAAM;CACrC,OAAO,EAAS,WAAW,KAAK,EAAS,EAAE,CAAC,KAAK,MAAM;AAC3D;AA+EA,SAAgB,EACZ,GACA,GACA,GACM;CACN,IAAM,IAAU,GAAS,WAAW,EAA0B;CAE9D,IAAI,EAAW,CAAK,GAAG;EACnB,IAAM,IAAS,EAAM,SAAS,KAAA,IAAY,KAAA,IAAY,GAAS,QAAQ,EAAM;EAC7E,IAAI,MAAW,KAAA,GAAW,OAAO;EACjC,IAAI,EAAM,WAAW,GAAG,OAAO;EAC/B,IAAM,IAAS,EAAM,OAAO,KAAK;EAiBjC,OAhBI,EAAM,UAAU,OAAO,KAAK,EAAM,MAAM,CAAC,CAAC,SAAS,IAE/C,GAAS,cAAc,MACvB,EAAoB,EAAM,QAAQ,GAAQ,EAAM,MAAM,IAE/C,IAEJ,GAAS,cAAc,EAA0B,aAGxD,GAAS,cAAc,MACvB,MAAW,MACX,MAAW,EAAgB,EAAM,MAAM,IAEhC,IAEJ,GAAG,EAAS,SAAS,EAAM,OAAO;CAC7C;CAIA,OAFI,EAAiB,IAAU,IAExB;AACX"}
1
+ {"version":3,"file":"describe-api-error.js","names":[],"sources":["../../src/http/describe-api-error.ts"],"sourcesContent":["// The last mile of error handling: a typed error is what code reads, a sentence\n// is what a person reads, and every app was writing the funnel between the two.\n// The case everyone forgets is the request that never reached the server, which\n// without special handling renders as the nonsense \"erro 0\".\n\nimport { isApiError, syntheticDetail } from \"./errors\";\n\n/** The fixed sentences {@link describeApiError} may need. */\nexport interface ApiErrorStrings {\n /** Shown when the request never reached the server. */\n offline: string;\n /**\n * Shown when the backend rejected the payload field by field.\n *\n * The per-field messages are on `error.fields`, to be attached to the inputs\n * themselves; this sentence is what the toast says.\n *\n * It does not apply to the one rejection that named a single field with a\n * finished sentence — there the server's own `detail` is shown instead, since\n * it is the same string `fields` carries. Pass `useDetail: false` to force\n * this sentence in that case too.\n */\n validation: string;\n}\n\n/**\n * PT-BR defaults, used when no strings are supplied and no catalog answers.\n *\n * The SDK's copy is pt-BR everywhere else (`FilterBar`, `DataTable`, `Chat`), so\n * the default here matches rather than introducing an English string that only\n * shows up on a network failure.\n */\nexport const DEFAULT_API_ERROR_STRINGS: ApiErrorStrings = {\n offline: \"Sem conexão com o servidor. Verifique sua internet e tente de novo.\",\n validation: \"Confira os campos destacados e tente de novo.\",\n};\n\n/**\n * Translation key the {@link useDescribeApiError} hook looks up.\n *\n * A catalog that does not define it falls back to\n * {@link DEFAULT_API_ERROR_STRINGS}, because `t` returns the key itself when the\n * lookup misses and printing `tempest.error.offline` at the user would be worse\n * than printing pt-BR at them.\n */\nexport const API_ERROR_OFFLINE_KEY = \"tempest.error.offline\";\n\n/**\n * Translation key for the validation sentence, looked up the same way as\n * {@link API_ERROR_OFFLINE_KEY}.\n */\nexport const API_ERROR_VALIDATION_KEY = \"tempest.error.validation\";\n\n/**\n * Everything {@link describeApiError} accepts beyond the error and the fallback.\n *\n * Extends the fixed sentences rather than sitting beside them, so a caller that\n * already passed `{ offline, validation }` keeps compiling untouched.\n */\nexport interface DescribeApiErrorOptions extends Partial<ApiErrorStrings> {\n /**\n * Maps the backend's programmatic `code` to a sentence in your language.\n *\n * The client already surfaces `code` on `ApiError`, but without this every\n * app writes the same `switch` over it. A hit here wins over every other\n * step: it is the only sentence written for that exact case, by someone who\n * knew both the backend contract and the screen it lands on.\n */\n codes?: Readonly<Record<string, string>>;\n /**\n * Maps an HTTP status to a sentence, for the failures that arrive with no\n * useful body.\n *\n * `codes` covers the cases the backend named; this covers the ones it did\n * not. The reference case is the `403` of an admin-only route: the body\n * carries a generic `detail` (or none), so the funnel fell through to the\n * fallback and an administrator read \"Não foi possível carregar\" when the\n * fact that answers their question is \"esta listagem é só para\n * administradores\".\n *\n * Checked after `codes` and **before** the backend's `detail`: a generic\n * sentence emitted by the framework is worse than the one the app wrote for\n * that status. `useDetail: false` remains the way to suppress `detail`\n * entirely.\n *\n * A `0` key is honoured like any other, ahead of the offline sentence —\n * `statuses` is the caller's explicit map, and the fixed `offline` string is\n * already the way to reword that case. Status `0` covers a request that\n * never landed **and** one abandoned by the client timeout, so an app that\n * maps it is taking both.\n */\n statuses?: Readonly<Record<number, string>>;\n /**\n * Whether the backend's `detail` may be shown when no `code` matched.\n * Default `true`.\n *\n * Set it to `false` when `detail` is written for developers rather than\n * users, or when it could echo internals — the result is then always either\n * a sentence you wrote or the fallback.\n */\n useDetail?: boolean;\n}\n\n/**\n * Whether the browser currently reports itself as offline.\n *\n * `fetch` rejects a network failure with a plain `TypeError` whose message\n * differs per browser (\"Failed to fetch\", \"NetworkError when attempting to fetch\n * resource.\", \"Load failed\"), so sniffing the message is not portable. The online\n * flag is, and it is the signal that matters for the sentence being chosen.\n *\n * @returns `true` only when the environment positively says it is offline.\n */\nfunction browserIsOffline(): boolean {\n return typeof navigator !== \"undefined\" && navigator.onLine === false;\n}\n\n/**\n * Whether `fields` is the flattened single-field envelope, carrying `detail`.\n *\n * The check is identity rather than shape-sniffing. `collectFields` has exactly\n * two sources: FastAPI's `detail` **list**, whose entries are the per-issue\n * messages, and the flattened envelope, whose one entry is built from the same\n * string that becomes `ApiError.detail`. So one entry equal to `detail` can only\n * have come from the second, and returning `detail` there shows the string the\n * form is already about to attach to that input.\n *\n * @param fields - The error's field messages.\n * @param detail - `error.detail`, already trimmed.\n * @param status - The HTTP status, to recognise the synthetic `Erro <status>`\n * that stands in when the body carried no message at all.\n * @returns Whether `detail` is safe to show as the sentence.\n */\nfunction singleFieldSentence(\n fields: Record<string, string>,\n detail: string,\n status: number,\n): boolean {\n if (detail === \"\" || detail === syntheticDetail(status)) return false;\n const messages = Object.values(fields);\n return messages.length === 1 && messages[0].trim() === detail;\n}\n\n/**\n * Turn any caught value into a sentence worth showing.\n *\n * The funnel, in order:\n *\n * 0. `codes[error.code]` — the sentence you wrote for that exact backend case.\n * Checked first because nothing the funnel derives can beat it, and because a\n * request that never landed carries no `code` for it to shadow.\n * 0.5. `statuses[error.status]` — the sentence for a whole class of failure,\n * for the errors that arrive with no useful body. It beats the backend's\n * `detail`, because a generic line emitted by the framework is worse than the\n * one the app wrote for that status, and it beats the offline sentence when\n * the caller maps `0` explicitly.\n * 1. A request that never reached the server — `status === 0`, or a non-API\n * error thrown while the browser reports itself offline — produces the\n * offline sentence. This is the step apps skip, and skipping it renders\n * \"erro 0\" or a raw `TypeError` at the user.\n * 2. A validation rejection — `error.fields` is set — produces the validation\n * sentence, **not** `detail`. On a FastAPI `422` the `detail` line is assembled\n * from the backend's field paths and the validator's own wording\n * (`\"items.0.price: Input should be greater than 0\"`), which is right for a\n * log and wrong for a person: it is half English in a pt-BR screen and it\n * names internals. The per-field messages stay on `fields`, where a form can\n * attach them to the inputs that failed.\n *\n * **Unless the rejection named exactly one field and its message is\n * `detail` itself**, in which case `detail` is returned. That is not a guess\n * about the text: `collectFields` fills a single entry from `detail` only for\n * the flattened envelope a `tempest-fastapi-sdk` backend sends, where the\n * server wrote one finished sentence about one field (`\"CPF ou CNPJ\n * inválido\"`). Returning it shows the same string that is already on\n * `fields`, so nothing is invented and nothing is lost. The assembled FastAPI\n * line never reaches this branch, because it comes from the `detail` **list**\n * and its entries are the per-issue messages, not `detail`.\n *\n * This is a fix, not a preference. Before `namedField` shipped in 0.54.0,\n * `fields` was empty against that backend and the sentence reached the user\n * through step 3; filling `fields` silently replaced it with \"Confira os\n * campos destacados\", which presumes a screen that highlights fields — and\n * no app highlighted anything on the day of the bump.\n *\n * `useDetail: false` still suppresses it, and is the way to force the fixed\n * sentence for a backend whose text is written for developers. `validation`\n * does **not** override it, and that is on purpose: its own contract is the\n * sentence for a payload rejected *field by field*, which one field carrying\n * one finished sentence is not — and `useDescribeApiError` always passes\n * `validation`, so treating it as an override would mean the branch never ran\n * for any component, which is every caller that matters here.\n * 3. The backend's own `detail`, which is the most specific thing available and\n * is already written for a person — unless `useDetail: false` says that text\n * is for developers.\n * 4. `fallback`, with `(HTTP <status>)` appended when a status is known, so the\n * screenshot in the support ticket carries the one fact a developer needs.\n *\n * Pure on purpose: it works in an interceptor, in a logger and anywhere outside\n * the React tree. {@link useDescribeApiError} is the same funnel with the\n * sentences resolved through `I18nProvider`.\n *\n * @example\n * catch (error) {\n * toast(describeApiError(error, \"Não foi possível salvar o pedido\"));\n * }\n *\n * @example\n * catch (error) {\n * toast(\n * describeApiError(error, \"Não foi possível se candidatar\", {\n * codes: {\n * SERVICE_FULL: \"Este serviço atingiu o limite de vagas.\",\n * CANDIDATE_ALREADY_EXISTS: \"Você já se candidatou a este serviço.\",\n * },\n * useDetail: false,\n * }),\n * );\n * }\n *\n * @param error - The caught value, of any shape.\n * @param fallback - What to say when the error carries nothing better.\n * @param options - A `codes` catalog, `useDetail`, and overrides for the fixed\n * sentences.\n * @returns A sentence to show the user.\n */\nexport function describeApiError(\n error: unknown,\n fallback: string,\n options?: DescribeApiErrorOptions,\n): string {\n const offline = options?.offline ?? DEFAULT_API_ERROR_STRINGS.offline;\n\n if (isApiError(error)) {\n const mapped = error.code === undefined ? undefined : options?.codes?.[error.code];\n if (mapped !== undefined) return mapped;\n const byStatus = options?.statuses?.[error.status];\n if (byStatus !== undefined) return byStatus;\n if (error.status === 0) return offline;\n const detail = error.detail.trim();\n if (error.fields && Object.keys(error.fields).length > 0) {\n if (\n options?.useDetail !== false &&\n singleFieldSentence(error.fields, detail, error.status)\n ) {\n return detail;\n }\n return options?.validation ?? DEFAULT_API_ERROR_STRINGS.validation;\n }\n if (\n options?.useDetail !== false &&\n detail !== \"\" &&\n detail !== syntheticDetail(error.status)\n ) {\n return detail;\n }\n return `${fallback} (HTTP ${error.status})`;\n }\n\n if (browserIsOffline()) return offline;\n\n return fallback;\n}\n"],"mappings":";;AAgCA,IAAa,IAA6C;CACtD,SAAS;CACT,YAAY;AAChB,GAUa,IAAwB,yBAMxB,IAA2B;AA8DxC,SAAS,IAA4B;CACjC,OAAO,OAAO,YAAc,OAAe,UAAU,WAAW;AACpE;AAkBA,SAAS,EACL,GACA,GACA,GACO;CACP,IAAI,MAAW,MAAM,MAAW,EAAgB,CAAM,GAAG,OAAO;CAChE,IAAM,IAAW,OAAO,OAAO,CAAM;CACrC,OAAO,EAAS,WAAW,KAAK,EAAS,EAAE,CAAC,KAAK,MAAM;AAC3D;AAoFA,SAAgB,EACZ,GACA,GACA,GACM;CACN,IAAM,IAAU,GAAS,WAAW,EAA0B;CAE9D,IAAI,EAAW,CAAK,GAAG;EACnB,IAAM,IAAS,EAAM,SAAS,KAAA,IAAY,KAAA,IAAY,GAAS,QAAQ,EAAM;EAC7E,IAAI,MAAW,KAAA,GAAW,OAAO;EACjC,IAAM,IAAW,GAAS,WAAW,EAAM;EAC3C,IAAI,MAAa,KAAA,GAAW,OAAO;EACnC,IAAI,EAAM,WAAW,GAAG,OAAO;EAC/B,IAAM,IAAS,EAAM,OAAO,KAAK;EAiBjC,OAhBI,EAAM,UAAU,OAAO,KAAK,EAAM,MAAM,CAAC,CAAC,SAAS,IAE/C,GAAS,cAAc,MACvB,EAAoB,EAAM,QAAQ,GAAQ,EAAM,MAAM,IAE/C,IAEJ,GAAS,cAAc,EAA0B,aAGxD,GAAS,cAAc,MACvB,MAAW,MACX,MAAW,EAAgB,EAAM,MAAM,IAEhC,IAEJ,GAAG,EAAS,SAAS,EAAM,OAAO;CAC7C;CAIA,OAFI,EAAiB,IAAU,IAExB;AACX"}
@@ -1,2 +1,2 @@
1
- const e=require("../utils/base64.cjs"),t=require("./errors.cjs"),n=require("./retry.cjs"),r=require("./idempotency.cjs");var i=`1.0.0`,a=5242880;function o(t){return e.bytesToBase64(new TextEncoder().encode(t))}function s(e){if(!e)return null;let t=Object.entries(e).map(([e,t])=>`${e} ${o(t)}`);return t.length>0?t.join(`,`):null}function c(e,t){let n=t,r=typeof n.name==`string`?n.name:`blob`,i=typeof n.lastModified==`number`?n.lastModified:0;return`${e}|${r}|${t.size}|${t.type}|${i}`}function l(e=`tempest-upload:`){function t(){try{return typeof localStorage>`u`?null:localStorage}catch{return null}}return{get(n){let r=t()?.getItem(e+n);if(!r)return null;try{return JSON.parse(r)}catch{return null}},set(n,r){t()?.setItem(e+n,JSON.stringify(r))},delete(n){t()?.removeItem(e+n)}}}function u(e){return new Promise((n,r)=>{let i=new XMLHttpRequest;i.open(e.method,e.url),i.withCredentials=e.withCredentials;for(let[t,n]of Object.entries(e.headers))i.setRequestHeader(t,n);if(e.onProgress){let t=e.onProgress;i.upload.onprogress=e=>t(e.loaded)}i.onload=()=>n({status:i.status,text:i.responseText,header:e=>i.getResponseHeader(e)}),i.onerror=()=>r(new t.TempestApiError({status:0,detail:`Falha de rede no upload resumível.`})),i.onabort=()=>r(new DOMException(`Aborted`,`AbortError`)),e.register(i),i.send(e.body)})}function d(e){let t=e.header(`Upload-Offset`);if(t===null)return null;let n=Number(t);return Number.isFinite(n)&&n>=0?n:null}function f(e){if(!e)return null;try{return JSON.parse(e)}catch{return e}}var p=new Set([409,412]);function m(e){return!t.isApiError(e)||p.has(e.status)?!0:t.isRetriableStatus(e.status)}function h(e,n){let r=f(e.text),i=t.buildApiError(e.status,r,{get:e.header}),a=typeof r==`object`&&!!r&&(`detail`in r||`message`in r);return new t.TempestApiError({...i,detail:a?i.detail:n})}function g(e){let t=typeof window>`u`?void 0:window.location.href;try{return new URL(e,t).href}catch{return e}}function _(e){let{endpoint:o,file:f,chunkSize:p=a,metadata:_,headers:v={},getToken:y,withCredentials:b=!1,key:x=c(o,f),storage:S=l(),retry:C,onProgress:w,onStateChange:T}=e,E=`idle`,D=0,O=null,k=null,A=null,j=null,M=0;function N(e){E!==e&&(E=e,T?.(e))}function P(e){w?.({loaded:e,total:f.size,fraction:f.size===0?1:e/f.size,resumedFrom:M})}function F(){let e={...v,"Tus-Resumable":i},t=y?.();return t&&!(`Authorization`in e)&&(e.Authorization=`Bearer ${t}`),e}function I(e){j=e}async function L(){S&&O&&k&&await S.set(x,{url:O,offset:D,size:f.size,idempotencyKey:k,updatedAt:Date.now()})}async function R(e){let n=await u({method:`HEAD`,url:e,headers:F(),withCredentials:b,register:I});if(n.status===404||n.status===410)throw new t.TempestApiError({status:n.status,detail:`O upload expirou no servidor. Comece de novo.`});let r=d(n);if(r===null)throw h(n,`HEAD sem Upload-Offset.`);return r}async function z(){let e=S?await S.get(x):null;if(e&&e.size===f.size&&(k=e.idempotencyKey,e.url))try{return D=await R(e.url),O=e.url,e.url}catch{D=0}N(`creating`),k??=r.generateIdempotencyKey(),O=null,D=0,S&&await S.set(x,{url:``,offset:0,size:f.size,idempotencyKey:k,updatedAt:Date.now()});let t={...F(),"Upload-Length":String(f.size),"Idempotency-Key":k},n=s(_);n&&(t[`Upload-Metadata`]=n);let i=await u({method:`POST`,url:o,headers:t,withCredentials:b,register:I});if(i.status!==201)throw h(i,`Criação do upload recusada.`);let a=i.header(`Location`);if(!a)throw h(i,`Criação do upload sem cabeçalho Location.`);return O=g(a),await L(),O}async function B(e,t){if(t.needed&&(D=await R(e),t.needed=!1,P(D),await L(),D>=f.size))return;let n=Math.min(D+p,f.size),r=D,i=await u({method:`PATCH`,url:e,headers:{...F(),"Content-Type":`application/offset+octet-stream`,"Upload-Offset":String(r)},body:f.slice(r,n),withCredentials:b,onProgress:e=>P(Math.min(r+e,f.size)),register:I});if(i.status===409||i.status===412)throw t.needed=!0,h(i,`Offset divergente — o servidor já tinha esses bytes.`);if(i.status!==204&&i.status!==200)throw h(i,`Chunk recusado pelo servidor.`);D=d(i)??n,P(D),await L()}async function V(){A=null;let e=await z();M=D,N(`uploading`),P(D);let t={needed:!1};for(;D<f.size&&!A;)await n.retry(()=>B(e,t),{retries:5,...C,shouldRetry:(e,n)=>{if(A||e instanceof DOMException&&e.name===`AbortError`)return!1;let r=C?.shouldRetry?.(e,n)??m(e);return r&&(t.needed=!0),r}});return A===`pause`?(N(`paused`),null):A===`abort`?(N(`aborted`),null):(N(`done`),S&&await S.delete(x),{url:e,size:f.size})}async function H(){try{return await V()}catch(e){if(A!==null||e instanceof DOMException&&e.name===`AbortError`)return N(A===`abort`?`aborted`:`paused`),null;throw N(`error`),e}finally{j=null}}function U(e){A=e,j?.abort(),j=null}return{start:H,resume:H,pause:()=>U(`pause`),abort:async({discard:e=!1}={})=>{U(`abort`),N(`aborted`),e&&(O&&await u({method:`DELETE`,url:O,headers:F(),withCredentials:b,register:()=>void 0}).catch(()=>void 0),S&&await S.delete(x))},get state(){return E},get offset(){return D},get url(){return O},key:x}}exports.DEFAULT_CHUNK_SIZE=a,exports.TUS_VERSION=i,exports.createLocalUploadStorage=l,exports.createResumableUpload=_,exports.uploadFingerprint=c;
1
+ const e=require("../utils/base64.cjs"),t=require("./credential-scope.cjs"),n=require("./errors.cjs"),r=require("./retry.cjs"),i=require("./idempotency.cjs");var a=`1.0.0`,o=5242880;function s(t){return e.bytesToBase64(new TextEncoder().encode(t))}function c(e){if(!e)return null;let t=Object.entries(e).map(([e,t])=>`${e} ${s(t)}`);return t.length>0?t.join(`,`):null}function l(e,t){let n=t,r=typeof n.name==`string`?n.name:`blob`,i=typeof n.lastModified==`number`?n.lastModified:0;return`${e}|${r}|${t.size}|${t.type}|${i}`}function u(e=`tempest-upload:`){function t(){try{return typeof localStorage>`u`?null:localStorage}catch{return null}}return{get(n){let r=t()?.getItem(e+n);if(!r)return null;try{return JSON.parse(r)}catch{return null}},set(n,r){t()?.setItem(e+n,JSON.stringify(r))},delete(n){t()?.removeItem(e+n)}}}function d(e){return new Promise((t,r)=>{let i=new XMLHttpRequest;i.open(e.method,e.url),i.withCredentials=e.withCredentials;for(let[t,n]of Object.entries(e.headers))i.setRequestHeader(t,n);if(e.onProgress){let t=e.onProgress;i.upload.onprogress=e=>t(e.loaded)}i.onload=()=>t({status:i.status,text:i.responseText,header:e=>i.getResponseHeader(e)}),i.onerror=()=>r(new n.TempestApiError({status:0,detail:`Falha de rede no upload resumível.`})),i.onabort=()=>r(new DOMException(`Aborted`,`AbortError`)),e.register(i),i.send(e.body)})}function f(e){let t=e.header(`Upload-Offset`);if(t===null)return null;let n=Number(t);return Number.isFinite(n)&&n>=0?n:null}function p(e){if(!e)return null;try{return JSON.parse(e)}catch{return e}}var m=new Set([409,412]);function h(e){return!n.isApiError(e)||m.has(e.status)?!0:n.isRetriableStatus(e.status)}function g(e,t){let r=p(e.text),i=n.buildApiError(e.status,r,{get:e.header}),a=typeof r==`object`&&!!r&&(`detail`in r||`message`in r);return new n.TempestApiError({...i,detail:a?i.detail:t})}function _(e){let t=typeof window>`u`?void 0:window.location.href;try{return new URL(e,t).href}catch{return e}}function v(e){let{endpoint:s,file:p,chunkSize:m=o,metadata:v,headers:y={},getToken:b,trustedOrigins:x,withCredentials:S=!1,key:C=l(s,p),storage:w=u(),retry:T,onProgress:E,onStateChange:D}=e,O=`idle`,k=0,A=null,j=null,M=null,N=null,P=0;function F(e){O!==e&&(O=e,D?.(e))}function I(e){E?.({loaded:e,total:p.size,fraction:p.size===0?1:e/p.size,resumedFrom:P})}function L(e){let n={...y,"Tus-Resumable":a},r=b?.();return r&&!(`Authorization`in n)&&(t.isTrustedCredentialTarget(e,s,x)?n.Authorization=`Bearer ${r}`:t.reportSuppressedCredential(e,s)),n}function R(e){N=e}async function z(){w&&A&&j&&await w.set(C,{url:A,offset:k,size:p.size,idempotencyKey:j,updatedAt:Date.now()})}async function B(e){let t=await d({method:`HEAD`,url:e,headers:L(e),withCredentials:S,register:R});if(t.status===404||t.status===410)throw new n.TempestApiError({status:t.status,detail:`O upload expirou no servidor. Comece de novo.`});let r=f(t);if(r===null)throw g(t,`HEAD sem Upload-Offset.`);return r}async function V(){let e=w?await w.get(C):null;if(e&&e.size===p.size&&(j=e.idempotencyKey,e.url))try{return k=await B(e.url),A=e.url,e.url}catch{k=0}F(`creating`),j??=i.generateIdempotencyKey(),A=null,k=0,w&&await w.set(C,{url:``,offset:0,size:p.size,idempotencyKey:j,updatedAt:Date.now()});let t={...L(s),"Upload-Length":String(p.size),"Idempotency-Key":j},n=c(v);n&&(t[`Upload-Metadata`]=n);let r=await d({method:`POST`,url:s,headers:t,withCredentials:S,register:R});if(r.status!==201)throw g(r,`Criação do upload recusada.`);let a=r.header(`Location`);if(!a)throw g(r,`Criação do upload sem cabeçalho Location.`);return A=_(a),await z(),A}async function H(e,t){if(t.needed&&(k=await B(e),t.needed=!1,I(k),await z(),k>=p.size))return;let n=Math.min(k+m,p.size),r=k,i=await d({method:`PATCH`,url:e,headers:{...L(e),"Content-Type":`application/offset+octet-stream`,"Upload-Offset":String(r)},body:p.slice(r,n),withCredentials:S,onProgress:e=>I(Math.min(r+e,p.size)),register:R});if(i.status===409||i.status===412)throw t.needed=!0,g(i,`Offset divergente — o servidor já tinha esses bytes.`);if(i.status!==204&&i.status!==200)throw g(i,`Chunk recusado pelo servidor.`);k=f(i)??n,I(k),await z()}async function U(){M=null;let e=await V();P=k,F(`uploading`),I(k);let t={needed:!1};for(;k<p.size&&!M;)await r.retry(()=>H(e,t),{retries:5,...T,shouldRetry:(e,n)=>{if(M||e instanceof DOMException&&e.name===`AbortError`)return!1;let r=T?.shouldRetry?.(e,n)??h(e);return r&&(t.needed=!0),r}});return M===`pause`?(F(`paused`),null):M===`abort`?(F(`aborted`),null):(F(`done`),w&&await w.delete(C),{url:e,size:p.size})}async function W(){try{return await U()}catch(e){if(M!==null||e instanceof DOMException&&e.name===`AbortError`)return F(M===`abort`?`aborted`:`paused`),null;throw F(`error`),e}finally{N=null}}function G(e){M=e,N?.abort(),N=null}return{start:W,resume:W,pause:()=>G(`pause`),abort:async({discard:e=!1}={})=>{G(`abort`),F(`aborted`),e&&(A&&await d({method:`DELETE`,url:A,headers:L(A),withCredentials:S,register:()=>void 0}).catch(()=>void 0),w&&await w.delete(C))},get state(){return O},get offset(){return k},get url(){return A},key:C}}exports.DEFAULT_CHUNK_SIZE=o,exports.TUS_VERSION=a,exports.createLocalUploadStorage=u,exports.createResumableUpload=v,exports.uploadFingerprint=l;
2
2
  //# sourceMappingURL=resumable-upload.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"resumable-upload.cjs","names":[],"sources":["../../src/http/resumable-upload.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — a resumable upload is one long-lived\n * state machine: chunk the file, negotiate the offset the server already has, upload\n * with retry and backoff, honour pause, resume and abort, and report progress\n * throughout. Every stage reads the same cursor and the same abort signal, and\n * createResumableUpload is the closure that owns them.\n */\nimport { bytesToBase64 } from \"@/utils/base64\";\nimport { buildApiError, isApiError, isRetriableStatus, TempestApiError } from \"./errors\";\nimport { generateIdempotencyKey } from \"./idempotency\";\nimport { retry, type RetryOptions } from \"./retry\";\n\n/** The tus protocol version this client speaks. */\nexport const TUS_VERSION = \"1.0.0\";\n\n/** Default chunk size: 5 MiB, the size most tus servers are tuned for. */\nexport const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;\n\n/**\n * Where a resumable upload is.\n *\n * `\"paused\"` and `\"aborted\"` are both \"not running\", but only `\"paused\"` keeps the\n * persisted offset — `abort({ discard: true })` throws it away.\n */\nexport type ResumableUploadState =\n \"idle\" | \"creating\" | \"uploading\" | \"paused\" | \"done\" | \"error\" | \"aborted\";\n\n/** Byte-level progress for a resumable upload. */\nexport interface ResumableUploadProgress {\n /** Bytes the server holds, including anything a resume skipped. */\n loaded: number;\n /** Total size of the file. */\n total: number;\n /** `loaded / total`, between 0 and 1. */\n fraction: number;\n /** Bytes already on the server when this run started. `0` on a fresh upload. */\n resumedFrom: number;\n}\n\n/** What has to survive a page reload for a resume to be possible. */\nexport interface ResumableUploadRecord {\n /** Upload URL the creation POST returned, absolute. */\n url: string;\n /** Last offset the server confirmed. */\n offset: number;\n /** File size, so a different file under the same key is not resumed into. */\n size: number;\n /** Idempotency key of the creation request, reused if creation is retried. */\n idempotencyKey: string;\n /** Epoch ms of the last write, so an app can sweep stale records. */\n updatedAt: number;\n}\n\n/**\n * Persistence for resume state. Sync or async — both are awaited.\n *\n * Implement it over anything: the default is `localStorage`, and\n * `createOfflineStore` from `@/offline` slots in when you already have a Dexie\n * database open.\n */\nexport interface ResumableUploadStorage {\n /** Read the record for `key`, or `null`. */\n get(key: string): Promise<ResumableUploadRecord | null> | ResumableUploadRecord | null;\n /** Write the record for `key`. */\n set(key: string, record: ResumableUploadRecord): Promise<void> | void;\n /** Forget the record for `key`. */\n delete(key: string): Promise<void> | void;\n}\n\n/** Options for {@link createResumableUpload}. */\nexport interface ResumableUploadOptions {\n /** tus creation endpoint, e.g. `\"/api/uploads\"`. */\n endpoint: string;\n /** The bytes to upload. A `File` also supplies the default resume key. */\n file: Blob | File;\n /** Bytes per `PATCH`. Default {@link DEFAULT_CHUNK_SIZE}. */\n chunkSize?: number;\n /** Sent as `Upload-Metadata` (base64-encoded values), e.g. `{ filename }`. */\n metadata?: Record<string, string>;\n /** Extra headers on every request. */\n headers?: Record<string, string>;\n /** Returns the current bearer token, read before each request. */\n getToken?: () => string | null | undefined;\n /** Send cookies. Default `false`. */\n withCredentials?: boolean;\n /**\n * Resume key. Defaults to a fingerprint of endpoint + file name/size/mtime, so\n * picking the same file after a reload resumes instead of restarting.\n */\n key?: string;\n /**\n * Where to persist resume state. Defaults to `localStorage`. Pass `null` to\n * disable persistence — resume then only survives a network blip, not a reload.\n */\n storage?: ResumableUploadStorage | null;\n /** Backoff for a failed chunk. Forwarded to `retry`. Default 5 attempts. */\n retry?: RetryOptions;\n /** Called on every upload-progress tick and after every confirmed chunk. */\n onProgress?: (progress: ResumableUploadProgress) => void;\n /** Called whenever {@link ResumableUpload.state} changes. */\n onStateChange?: (state: ResumableUploadState) => void;\n}\n\n/** What a finished upload resolves with. */\nexport interface ResumableUploadResult {\n /** The tus upload URL — hand this to your API to link the stored file. */\n url: string;\n /** Total bytes uploaded. */\n size: number;\n}\n\n/** A resumable upload in progress. Build one with {@link createResumableUpload}. */\nexport interface ResumableUpload {\n /**\n * Create (or re-attach to) the upload and push chunks until it is complete.\n *\n * Resolves `null` when the run stopped because of `pause()` or `abort()` —\n * neither is a failure. Rejects with a `TempestApiError` when the server\n * refused and the retries ran out.\n */\n start(): Promise<ResumableUploadResult | null>;\n /** Stop after the in-flight chunk is dropped, keeping the resume point. */\n pause(): void;\n /** Continue from the server's offset. Same resolution contract as `start`. */\n resume(): Promise<ResumableUploadResult | null>;\n /**\n * Stop for good.\n *\n * @param options - `discard: true` also sends `DELETE` (tus termination) and\n * forgets the persisted record, so the next `start()` uploads from zero.\n */\n abort(options?: { discard?: boolean }): Promise<void>;\n /** Current state. */\n readonly state: ResumableUploadState;\n /** Bytes the server has confirmed. */\n readonly offset: number;\n /** The upload URL, once creation succeeded. */\n readonly url: string | null;\n /** The resume key in use. */\n readonly key: string;\n}\n\ninterface RawResponse {\n status: number;\n text: string;\n header(name: string): string | null;\n}\n\n/**\n * Encode a string as standard base64 (padded), UTF-8 first.\n *\n * `Upload-Metadata` carries base64 values precisely so a filename with accents\n * survives an HTTP header, so the UTF-8 step is not optional: `btoa` alone throws\n * on any code point above U+00FF. Only that step is specific here — the\n * bytes-to-text half is {@link bytesToBase64}.\n *\n * @param value - Text to encode.\n * @returns Padded base64.\n */\nfunction base64Utf8(value: string): string {\n return bytesToBase64(new TextEncoder().encode(value));\n}\n\n/**\n * Build the `Upload-Metadata` header value: comma-separated `key base64(value)`.\n *\n * @param metadata - Plain string map.\n * @returns The header value, or `null` when there is nothing to send.\n */\nfunction encodeMetadata(metadata: Record<string, string> | undefined): string | null {\n if (!metadata) return null;\n const parts = Object.entries(metadata).map(([name, value]) => `${name} ${base64Utf8(value)}`);\n return parts.length > 0 ? parts.join(\",\") : null;\n}\n\n/**\n * A stable-enough identity for a file, used as the default resume key.\n *\n * Name + size + last-modified is what the tus reference clients fingerprint on:\n * it is cheap (hashing the bytes of a 400 MB recording is not) and it changes\n * whenever the file does, which is the property that matters — resuming into the\n * wrong file would corrupt it silently.\n *\n * @param endpoint - Creation endpoint, so the same file to two servers is two uploads.\n * @param file - The blob or file being uploaded.\n * @returns A key safe to use in `localStorage`.\n */\nexport function uploadFingerprint(endpoint: string, file: Blob | File): string {\n const named = file as File;\n const name = typeof named.name === \"string\" ? named.name : \"blob\";\n const modified = typeof named.lastModified === \"number\" ? named.lastModified : 0;\n return `${endpoint}|${name}|${file.size}|${file.type}|${modified}`;\n}\n\n/**\n * `localStorage`-backed resume storage — the default.\n *\n * `localStorage` and not IndexedDB on purpose. The record is four fields and a\n * URL; the requirement is only that it survives a reload, and pulling Dexie in for\n * that would put an IndexedDB dependency in the bundle of every app that uploads a\n * file. Apps that already have `createOfflineStore` open can pass their own\n * {@link ResumableUploadStorage} instead.\n *\n * @param prefix - Key prefix. Default `\"tempest-upload:\"`.\n * @returns A storage that no-ops when `localStorage` is unavailable.\n */\nexport function createLocalUploadStorage(prefix = \"tempest-upload:\"): ResumableUploadStorage {\n function backend(): Storage | null {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null;\n }\n }\n\n return {\n get(key) {\n const raw = backend()?.getItem(prefix + key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as ResumableUploadRecord;\n } catch {\n return null;\n }\n },\n set(key, record) {\n backend()?.setItem(prefix + key, JSON.stringify(record));\n },\n delete(key) {\n backend()?.removeItem(prefix + key);\n },\n };\n}\n\n/**\n * Send one request over `XMLHttpRequest`.\n *\n * `XMLHttpRequest` rather than `fetch` for the same reason `uploadWithProgress`\n * uses it — `fetch` still cannot report upload progress in any browser — plus one\n * more: tus answers every write with the new `Upload-Offset` in a **response\n * header**, and `uploadWithProgress` only hands back a parsed body, so it could\n * not be reused here.\n *\n * @param init - Method, URL, headers, optional body and progress callback.\n * @returns Status, raw text and a header reader.\n */\nfunction sendRequest(init: {\n method: \"POST\" | \"HEAD\" | \"PATCH\" | \"DELETE\";\n url: string;\n headers: Record<string, string>;\n body?: Blob;\n withCredentials: boolean;\n onProgress?: (loaded: number) => void;\n register: (xhr: XMLHttpRequest) => void;\n}): Promise<RawResponse> {\n return new Promise<RawResponse>((resolve, reject) => {\n const xhr = new XMLHttpRequest();\n xhr.open(init.method, init.url);\n xhr.withCredentials = init.withCredentials;\n for (const [name, value] of Object.entries(init.headers)) {\n xhr.setRequestHeader(name, value);\n }\n if (init.onProgress) {\n const report = init.onProgress;\n xhr.upload.onprogress = (event: ProgressEvent) => report(event.loaded);\n }\n xhr.onload = () =>\n resolve({\n status: xhr.status,\n text: xhr.responseText,\n header: (name) => xhr.getResponseHeader(name),\n });\n xhr.onerror = () =>\n reject(\n new TempestApiError({\n status: 0,\n detail: \"Falha de rede no upload resumível.\",\n }),\n );\n xhr.onabort = () => reject(new DOMException(\"Aborted\", \"AbortError\"));\n init.register(xhr);\n xhr.send(init.body);\n });\n}\n\nfunction parseOffset(response: RawResponse): number | null {\n const raw = response.header(\"Upload-Offset\");\n if (raw === null) return null;\n const value = Number(raw);\n return Number.isFinite(value) && value >= 0 ? value : null;\n}\n\n/**\n * Read an error body without assuming it is JSON.\n *\n * A tus proxy that rejects a chunk often answers with plain text or an HTML error\n * page, and `JSON.parse` throwing there would replace a useful status with a parse\n * error.\n *\n * @param text - Raw response text.\n * @returns The parsed object, the raw text, or `null` when the body was empty.\n */\nfunction parseErrorBody(text: string): unknown {\n if (!text) return null;\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/**\n * Turn a refused tus response into a `TempestApiError`.\n *\n * The fallback `detail` is used unless the server sent a real error envelope,\n * because `buildApiError`'s own fallback (`\"Erro 409\"`) says nothing about which\n * step of the protocol broke — and that is the whole diagnostic value here.\n *\n * @param response - The raw response that was not acceptable.\n * @param detail - Message to use when the body carries none.\n * @returns The error to throw.\n */\n/**\n * The two statuses a chunk retry fixes that the shared policy cannot know about.\n *\n * `409` and `412` are the offset-divergence answers, and they are the entire\n * reason `resync` exists: the next attempt re-reads the server's offset with\n * `HEAD` and writes from there. They are 4xx refusals a replay genuinely fixes,\n * which is the one thing {@link isRetriableStatus} has no way to tell — from\n * outside this protocol they look like any other deliberate rejection.\n */\nconst RESYNCABLE_STATUSES: ReadonlySet<number> = new Set([409, 412]);\n\n/**\n * Whether a chunk failure is worth another attempt.\n *\n * The default used to be `true` for everything, which cost five round trips\n * before surfacing an answer the first one already gave. Two groups matter here\n * and both are specific to the resume protocol:\n *\n * - **`409`/`412` retry**, even though the shared policy rejects 4xx: they mean\n * \"your offset is wrong\", and `resync` is how the next attempt fixes it.\n * - **`404`/`410` do not**, even though a lost resource can look transient.\n * `probe()` turns them into \"O upload expirou no servidor. Comece de novo.\" and\n * recreating the upload only happens in `ensureUpload`, at attach time — never\n * inside the chunk loop. So a retry here re-runs `HEAD` against a resource that\n * is gone, five times, and then reports the same thing with the backoff added\n * on top.\n *\n * Anything with no API shape still retries: a transport failure has no status to\n * judge, and losing a large upload to one dropped connection is the outcome this\n * whole module exists to avoid.\n *\n * @param error - Whatever the attempt threw.\n * @returns Whether the chunk loop should try again.\n */\nfunction isRetriableChunkFailure(error: unknown): boolean {\n if (!isApiError(error)) return true;\n if (RESYNCABLE_STATUSES.has(error.status)) return true;\n return isRetriableStatus(error.status);\n}\n\nfunction failed(response: RawResponse, detail: string): TempestApiError {\n const body = parseErrorBody(response.text);\n const envelope = buildApiError(response.status, body, { get: response.header });\n const hasDetail =\n typeof body === \"object\" && body !== null && (\"detail\" in body || \"message\" in body);\n return new TempestApiError({ ...envelope, detail: hasDetail ? envelope.detail : detail });\n}\n\n/**\n * Resolve a `Location` header against the page, so a relative upload URL works.\n *\n * tus servers are free to answer creation with either an absolute URL or a\n * path, and the spec does not prefer one — a client that only handles absolute\n * URLs breaks against half the implementations.\n *\n * @param value - The raw `Location` header.\n * @returns An absolute URL, or the input when there is no base to resolve against.\n */\nfunction resolveUploadUrl(value: string): string {\n const base = typeof window === \"undefined\" ? undefined : window.location.href;\n try {\n return new URL(value, base).href;\n } catch {\n return value;\n }\n}\n\n/**\n * Chunked, resumable upload speaking the **tus 1.0.0** protocol (core plus the\n * *creation* and *termination* extensions).\n *\n * ## Why tus and not a bespoke scheme\n *\n * A resumable client whose wire format is undocumented cannot be integrated, and\n * inventing one means the backend is ours forever. tus is a published spec with\n * off-the-shelf servers (`tusd`, `tuspy`, `tus-node-server`), so a caller can point\n * this at something they did not write.\n *\n * ## What the backend must implement\n *\n * Every request carries `Tus-Resumable: 1.0.0`.\n *\n * | Step | Request | Expected response |\n * | --- | --- | --- |\n * | Create | `POST {endpoint}` + `Upload-Length`, `Upload-Metadata`, `Idempotency-Key` | `201` + `Location` (the upload URL, absolute or endpoint-relative) |\n * | Probe | `HEAD {uploadUrl}` | `200`/`204` + `Upload-Offset` |\n * | Write | `PATCH {uploadUrl}` + `Upload-Offset`, `Content-Type: application/offset+octet-stream`, chunk body | `204` + the new `Upload-Offset`; `409` when the offset does not match |\n * | Discard | `DELETE {uploadUrl}` | `204` |\n *\n * ## The failure that actually happens\n *\n * A chunk that the server stored but whose response never arrived. The client\n * cannot tell that from a chunk that was lost, and re-sending it blindly would\n * duplicate bytes. Two things prevent that:\n *\n * - **Writes are addressed, not appended.** Every `PATCH` states the offset it\n * writes at, so a retry after a lost response is asked to write bytes the server\n * already has and answers `409`. On any retry the client re-reads the truth with\n * `HEAD` first and continues from there.\n * - **Creation carries an `Idempotency-Key`** (from `generateIdempotencyKey`),\n * persisted before the first attempt and reused on retry. tus has no idempotent\n * creation of its own, so without this a lost `201` leaves an orphan upload on\n * the server. A backend that honours the header returns the same `Location`; one\n * that ignores it still works, it just keeps the orphan.\n *\n * @param options - Endpoint, file, and the knobs above.\n * @returns A handle with `start`/`pause`/`resume`/`abort` and live `state`/`offset`.\n *\n * @example\n * const upload = createResumableUpload({\n * endpoint: \"/api/uploads\",\n * file: recording,\n * metadata: { filename: \"nota.webm\", ticket: ticketId },\n * getToken: () => auth.getToken(),\n * onProgress: ({ fraction }) => setPercent(Math.round(fraction * 100)),\n * });\n *\n * const done = await upload.start();\n * if (done) await api.post(\"/api/tickets/1/audio\", { body: { url: done.url } });\n */\nexport function createResumableUpload(options: ResumableUploadOptions): ResumableUpload {\n const {\n endpoint,\n file,\n chunkSize = DEFAULT_CHUNK_SIZE,\n metadata,\n headers = {},\n getToken,\n withCredentials = false,\n key = uploadFingerprint(endpoint, file),\n storage = createLocalUploadStorage(),\n retry: retryOptions,\n onProgress,\n onStateChange,\n } = options;\n\n let state: ResumableUploadState = \"idle\";\n let offset = 0;\n let url: string | null = null;\n let idempotencyKey: string | null = null;\n let stopping: \"pause\" | \"abort\" | null = null;\n let inFlight: XMLHttpRequest | null = null;\n let resumedFrom = 0;\n\n function setState(next: ResumableUploadState): void {\n if (state === next) return;\n state = next;\n onStateChange?.(next);\n }\n\n function report(loaded: number): void {\n onProgress?.({\n loaded,\n total: file.size,\n fraction: file.size === 0 ? 1 : loaded / file.size,\n resumedFrom,\n });\n }\n\n function baseHeaders(): Record<string, string> {\n const result: Record<string, string> = { ...headers, \"Tus-Resumable\": TUS_VERSION };\n const token = getToken?.();\n if (token && !(\"Authorization\" in result)) result.Authorization = `Bearer ${token}`;\n return result;\n }\n\n function register(xhr: XMLHttpRequest): void {\n inFlight = xhr;\n }\n\n async function persist(): Promise<void> {\n if (!storage || !url || !idempotencyKey) return;\n await storage.set(key, {\n url,\n offset,\n size: file.size,\n idempotencyKey,\n updatedAt: Date.now(),\n });\n }\n\n /**\n * Ask the server how much it holds. The only source of truth after any failure.\n */\n async function probe(target: string): Promise<number> {\n const response = await sendRequest({\n method: \"HEAD\",\n url: target,\n headers: baseHeaders(),\n withCredentials,\n register,\n });\n if (response.status === 404 || response.status === 410) {\n throw new TempestApiError({\n status: response.status,\n detail: \"O upload expirou no servidor. Comece de novo.\",\n });\n }\n const confirmed = parseOffset(response);\n if (confirmed === null) throw failed(response, \"HEAD sem Upload-Offset.\");\n return confirmed;\n }\n\n /**\n * Re-attach to a persisted upload, or create a new one.\n *\n * The persisted record is only trusted when the file size still matches, and the\n * offset it holds is re-checked with `HEAD` — the client's copy can be ahead of\n * the server's whenever the last response was lost.\n */\n async function ensureUpload(): Promise<string> {\n const stored = storage ? await storage.get(key) : null;\n if (stored && stored.size === file.size) {\n idempotencyKey = stored.idempotencyKey;\n if (stored.url) {\n try {\n offset = await probe(stored.url);\n url = stored.url;\n return stored.url;\n } catch {\n offset = 0;\n }\n }\n }\n\n setState(\"creating\");\n idempotencyKey ??= generateIdempotencyKey();\n url = null;\n offset = 0;\n if (storage) {\n await storage.set(key, {\n url: \"\",\n offset: 0,\n size: file.size,\n idempotencyKey,\n updatedAt: Date.now(),\n });\n }\n\n const creationHeaders: Record<string, string> = {\n ...baseHeaders(),\n \"Upload-Length\": String(file.size),\n \"Idempotency-Key\": idempotencyKey,\n };\n const encoded = encodeMetadata(metadata);\n if (encoded) creationHeaders[\"Upload-Metadata\"] = encoded;\n\n const response = await sendRequest({\n method: \"POST\",\n url: endpoint,\n headers: creationHeaders,\n withCredentials,\n register,\n });\n if (response.status !== 201) throw failed(response, \"Criação do upload recusada.\");\n const locationHeader = response.header(\"Location\");\n if (!locationHeader) throw failed(response, \"Criação do upload sem cabeçalho Location.\");\n\n url = resolveUploadUrl(locationHeader);\n await persist();\n return url;\n }\n\n /** Push one chunk, resyncing the offset first when a previous attempt failed. */\n async function writeChunk(target: string, resync: { needed: boolean }): Promise<void> {\n if (resync.needed) {\n offset = await probe(target);\n resync.needed = false;\n report(offset);\n await persist();\n if (offset >= file.size) return;\n }\n\n const end = Math.min(offset + chunkSize, file.size);\n const from = offset;\n const response = await sendRequest({\n method: \"PATCH\",\n url: target,\n headers: {\n ...baseHeaders(),\n \"Content-Type\": \"application/offset+octet-stream\",\n \"Upload-Offset\": String(from),\n },\n body: file.slice(from, end),\n withCredentials,\n onProgress: (loaded) => report(Math.min(from + loaded, file.size)),\n register,\n });\n\n if (response.status === 409 || response.status === 412) {\n resync.needed = true;\n throw failed(response, \"Offset divergente — o servidor já tinha esses bytes.\");\n }\n if (response.status !== 204 && response.status !== 200) {\n throw failed(response, \"Chunk recusado pelo servidor.\");\n }\n\n offset = parseOffset(response) ?? end;\n report(offset);\n await persist();\n }\n\n /**\n * Drive the whole upload: attach or create, then chunk until complete.\n *\n * The `shouldRetry` predicate does double duty — besides deciding, it arms\n * `resync` so the next attempt re-reads the server's offset with `HEAD` before\n * writing. That is deliberate: it is the one place that sees *every* chunk\n * failure, whatever the cause, and after any failure the client's idea of the\n * offset is exactly what cannot be trusted.\n *\n * `resync` is armed only when the attempt is actually going to happen. A\n * caller's own `shouldRetry` still wins the decision, and still arms the\n * resync when it says yes — the flag describes what the *next* attempt must\n * do, so setting it for an attempt that never comes describes nothing.\n * {@link isRetriableChunkFailure} is the default, and it is where `409`/`412`\n * earn a retry the shared policy would refuse and `404`/`410` lose one it\n * would have granted.\n *\n * @returns The result, or `null` when `pause`/`abort` stopped the run.\n */\n async function run(): Promise<ResumableUploadResult | null> {\n stopping = null;\n const target = await ensureUpload();\n resumedFrom = offset;\n setState(\"uploading\");\n report(offset);\n\n const resync = { needed: false };\n while (offset < file.size) {\n if (stopping) break;\n await retry(() => writeChunk(target, resync), {\n retries: 5,\n ...retryOptions,\n shouldRetry: (error, attempt) => {\n if (stopping) return false;\n if (error instanceof DOMException && error.name === \"AbortError\") return false;\n const again =\n retryOptions?.shouldRetry?.(error, attempt) ??\n isRetriableChunkFailure(error);\n if (again) resync.needed = true;\n return again;\n },\n });\n }\n\n if (stopping === \"pause\") {\n setState(\"paused\");\n return null;\n }\n if (stopping === \"abort\") {\n setState(\"aborted\");\n return null;\n }\n\n setState(\"done\");\n if (storage) await storage.delete(key);\n return { url: target, size: file.size };\n }\n\n async function guarded(): Promise<ResumableUploadResult | null> {\n try {\n return await run();\n } catch (error) {\n if (\n stopping !== null ||\n (error instanceof DOMException && error.name === \"AbortError\")\n ) {\n setState(stopping === \"abort\" ? \"aborted\" : \"paused\");\n return null;\n }\n setState(\"error\");\n throw error;\n } finally {\n inFlight = null;\n }\n }\n\n function stop(reason: \"pause\" | \"abort\"): void {\n stopping = reason;\n inFlight?.abort();\n inFlight = null;\n }\n\n return {\n start: guarded,\n resume: guarded,\n pause: () => stop(\"pause\"),\n abort: async ({ discard = false } = {}) => {\n stop(\"abort\");\n setState(\"aborted\");\n if (!discard) return;\n if (url) {\n await sendRequest({\n method: \"DELETE\",\n url,\n headers: baseHeaders(),\n withCredentials,\n register: () => undefined,\n }).catch(() => undefined);\n }\n if (storage) await storage.delete(key);\n },\n get state() {\n return state;\n },\n get offset() {\n return offset;\n },\n get url() {\n return url;\n },\n key,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"resumable-upload.cjs","names":[],"sources":["../../src/http/resumable-upload.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — a resumable upload is one long-lived\n * state machine: chunk the file, negotiate the offset the server already has, upload\n * with retry and backoff, honour pause, resume and abort, and report progress\n * throughout. Every stage reads the same cursor and the same abort signal, and\n * createResumableUpload is the closure that owns them.\n */\nimport { bytesToBase64 } from \"@/utils/base64\";\nimport { isTrustedCredentialTarget, reportSuppressedCredential } from \"./credential-scope\";\nimport { buildApiError, isApiError, isRetriableStatus, TempestApiError } from \"./errors\";\nimport { generateIdempotencyKey } from \"./idempotency\";\nimport { retry, type RetryOptions } from \"./retry\";\n\n/** The tus protocol version this client speaks. */\nexport const TUS_VERSION = \"1.0.0\";\n\n/** Default chunk size: 5 MiB, the size most tus servers are tuned for. */\nexport const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;\n\n/**\n * Where a resumable upload is.\n *\n * `\"paused\"` and `\"aborted\"` are both \"not running\", but only `\"paused\"` keeps the\n * persisted offset — `abort({ discard: true })` throws it away.\n */\nexport type ResumableUploadState =\n \"idle\" | \"creating\" | \"uploading\" | \"paused\" | \"done\" | \"error\" | \"aborted\";\n\n/** Byte-level progress for a resumable upload. */\nexport interface ResumableUploadProgress {\n /** Bytes the server holds, including anything a resume skipped. */\n loaded: number;\n /** Total size of the file. */\n total: number;\n /** `loaded / total`, between 0 and 1. */\n fraction: number;\n /** Bytes already on the server when this run started. `0` on a fresh upload. */\n resumedFrom: number;\n}\n\n/** What has to survive a page reload for a resume to be possible. */\nexport interface ResumableUploadRecord {\n /** Upload URL the creation POST returned, absolute. */\n url: string;\n /** Last offset the server confirmed. */\n offset: number;\n /** File size, so a different file under the same key is not resumed into. */\n size: number;\n /** Idempotency key of the creation request, reused if creation is retried. */\n idempotencyKey: string;\n /** Epoch ms of the last write, so an app can sweep stale records. */\n updatedAt: number;\n}\n\n/**\n * Persistence for resume state. Sync or async — both are awaited.\n *\n * Implement it over anything: the default is `localStorage`, and\n * `createOfflineStore` from `@/offline` slots in when you already have a Dexie\n * database open.\n */\nexport interface ResumableUploadStorage {\n /** Read the record for `key`, or `null`. */\n get(key: string): Promise<ResumableUploadRecord | null> | ResumableUploadRecord | null;\n /** Write the record for `key`. */\n set(key: string, record: ResumableUploadRecord): Promise<void> | void;\n /** Forget the record for `key`. */\n delete(key: string): Promise<void> | void;\n}\n\n/** Options for {@link createResumableUpload}. */\nexport interface ResumableUploadOptions {\n /** tus creation endpoint, e.g. `\"/api/uploads\"`. */\n endpoint: string;\n /** The bytes to upload. A `File` also supplies the default resume key. */\n file: Blob | File;\n /** Bytes per `PATCH`. Default {@link DEFAULT_CHUNK_SIZE}. */\n chunkSize?: number;\n /** Sent as `Upload-Metadata` (base64-encoded values), e.g. `{ filename }`. */\n metadata?: Record<string, string>;\n /** Extra headers on every request. */\n headers?: Record<string, string>;\n /** Returns the current bearer token, read before each request. */\n getToken?: () => string | null | undefined;\n /**\n * Origins besides `endpoint`'s that may receive the `getToken` credential.\n *\n * Since 0.66.0 the bearer token is scoped to the origin of `endpoint`, and\n * the creation response's `Location` no longer decides where it goes. A\n * server that hands the upload to object storage on another host still\n * works — the `PATCH` requests simply carry no `Authorization`, which is\n * what a presigned storage URL expects anyway.\n *\n * List the storage origin here when it does need the API's token.\n */\n trustedOrigins?: readonly string[];\n /** Send cookies. Default `false`. */\n withCredentials?: boolean;\n /**\n * Resume key. Defaults to a fingerprint of endpoint + file name/size/mtime, so\n * picking the same file after a reload resumes instead of restarting.\n */\n key?: string;\n /**\n * Where to persist resume state. Defaults to `localStorage`. Pass `null` to\n * disable persistence — resume then only survives a network blip, not a reload.\n */\n storage?: ResumableUploadStorage | null;\n /** Backoff for a failed chunk. Forwarded to `retry`. Default 5 attempts. */\n retry?: RetryOptions;\n /** Called on every upload-progress tick and after every confirmed chunk. */\n onProgress?: (progress: ResumableUploadProgress) => void;\n /** Called whenever {@link ResumableUpload.state} changes. */\n onStateChange?: (state: ResumableUploadState) => void;\n}\n\n/** What a finished upload resolves with. */\nexport interface ResumableUploadResult {\n /** The tus upload URL — hand this to your API to link the stored file. */\n url: string;\n /** Total bytes uploaded. */\n size: number;\n}\n\n/** A resumable upload in progress. Build one with {@link createResumableUpload}. */\nexport interface ResumableUpload {\n /**\n * Create (or re-attach to) the upload and push chunks until it is complete.\n *\n * Resolves `null` when the run stopped because of `pause()` or `abort()` —\n * neither is a failure. Rejects with a `TempestApiError` when the server\n * refused and the retries ran out.\n */\n start(): Promise<ResumableUploadResult | null>;\n /** Stop after the in-flight chunk is dropped, keeping the resume point. */\n pause(): void;\n /** Continue from the server's offset. Same resolution contract as `start`. */\n resume(): Promise<ResumableUploadResult | null>;\n /**\n * Stop for good.\n *\n * @param options - `discard: true` also sends `DELETE` (tus termination) and\n * forgets the persisted record, so the next `start()` uploads from zero.\n */\n abort(options?: { discard?: boolean }): Promise<void>;\n /** Current state. */\n readonly state: ResumableUploadState;\n /** Bytes the server has confirmed. */\n readonly offset: number;\n /** The upload URL, once creation succeeded. */\n readonly url: string | null;\n /** The resume key in use. */\n readonly key: string;\n}\n\ninterface RawResponse {\n status: number;\n text: string;\n header(name: string): string | null;\n}\n\n/**\n * Encode a string as standard base64 (padded), UTF-8 first.\n *\n * `Upload-Metadata` carries base64 values precisely so a filename with accents\n * survives an HTTP header, so the UTF-8 step is not optional: `btoa` alone throws\n * on any code point above U+00FF. Only that step is specific here — the\n * bytes-to-text half is {@link bytesToBase64}.\n *\n * @param value - Text to encode.\n * @returns Padded base64.\n */\nfunction base64Utf8(value: string): string {\n return bytesToBase64(new TextEncoder().encode(value));\n}\n\n/**\n * Build the `Upload-Metadata` header value: comma-separated `key base64(value)`.\n *\n * @param metadata - Plain string map.\n * @returns The header value, or `null` when there is nothing to send.\n */\nfunction encodeMetadata(metadata: Record<string, string> | undefined): string | null {\n if (!metadata) return null;\n const parts = Object.entries(metadata).map(([name, value]) => `${name} ${base64Utf8(value)}`);\n return parts.length > 0 ? parts.join(\",\") : null;\n}\n\n/**\n * A stable-enough identity for a file, used as the default resume key.\n *\n * Name + size + last-modified is what the tus reference clients fingerprint on:\n * it is cheap (hashing the bytes of a 400 MB recording is not) and it changes\n * whenever the file does, which is the property that matters — resuming into the\n * wrong file would corrupt it silently.\n *\n * @param endpoint - Creation endpoint, so the same file to two servers is two uploads.\n * @param file - The blob or file being uploaded.\n * @returns A key safe to use in `localStorage`.\n */\nexport function uploadFingerprint(endpoint: string, file: Blob | File): string {\n const named = file as File;\n const name = typeof named.name === \"string\" ? named.name : \"blob\";\n const modified = typeof named.lastModified === \"number\" ? named.lastModified : 0;\n return `${endpoint}|${name}|${file.size}|${file.type}|${modified}`;\n}\n\n/**\n * `localStorage`-backed resume storage — the default.\n *\n * `localStorage` and not IndexedDB on purpose. The record is four fields and a\n * URL; the requirement is only that it survives a reload, and pulling Dexie in for\n * that would put an IndexedDB dependency in the bundle of every app that uploads a\n * file. Apps that already have `createOfflineStore` open can pass their own\n * {@link ResumableUploadStorage} instead.\n *\n * @param prefix - Key prefix. Default `\"tempest-upload:\"`.\n * @returns A storage that no-ops when `localStorage` is unavailable.\n */\nexport function createLocalUploadStorage(prefix = \"tempest-upload:\"): ResumableUploadStorage {\n function backend(): Storage | null {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null;\n }\n }\n\n return {\n get(key) {\n const raw = backend()?.getItem(prefix + key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as ResumableUploadRecord;\n } catch {\n return null;\n }\n },\n set(key, record) {\n backend()?.setItem(prefix + key, JSON.stringify(record));\n },\n delete(key) {\n backend()?.removeItem(prefix + key);\n },\n };\n}\n\n/**\n * Send one request over `XMLHttpRequest`.\n *\n * `XMLHttpRequest` rather than `fetch` for the same reason `uploadWithProgress`\n * uses it — `fetch` still cannot report upload progress in any browser — plus one\n * more: tus answers every write with the new `Upload-Offset` in a **response\n * header**, and `uploadWithProgress` only hands back a parsed body, so it could\n * not be reused here.\n *\n * @param init - Method, URL, headers, optional body and progress callback.\n * @returns Status, raw text and a header reader.\n */\nfunction sendRequest(init: {\n method: \"POST\" | \"HEAD\" | \"PATCH\" | \"DELETE\";\n url: string;\n headers: Record<string, string>;\n body?: Blob;\n withCredentials: boolean;\n onProgress?: (loaded: number) => void;\n register: (xhr: XMLHttpRequest) => void;\n}): Promise<RawResponse> {\n return new Promise<RawResponse>((resolve, reject) => {\n const xhr = new XMLHttpRequest();\n xhr.open(init.method, init.url);\n xhr.withCredentials = init.withCredentials;\n for (const [name, value] of Object.entries(init.headers)) {\n xhr.setRequestHeader(name, value);\n }\n if (init.onProgress) {\n const report = init.onProgress;\n xhr.upload.onprogress = (event: ProgressEvent) => report(event.loaded);\n }\n xhr.onload = () =>\n resolve({\n status: xhr.status,\n text: xhr.responseText,\n header: (name) => xhr.getResponseHeader(name),\n });\n xhr.onerror = () =>\n reject(\n new TempestApiError({\n status: 0,\n detail: \"Falha de rede no upload resumível.\",\n }),\n );\n xhr.onabort = () => reject(new DOMException(\"Aborted\", \"AbortError\"));\n init.register(xhr);\n xhr.send(init.body);\n });\n}\n\nfunction parseOffset(response: RawResponse): number | null {\n const raw = response.header(\"Upload-Offset\");\n if (raw === null) return null;\n const value = Number(raw);\n return Number.isFinite(value) && value >= 0 ? value : null;\n}\n\n/**\n * Read an error body without assuming it is JSON.\n *\n * A tus proxy that rejects a chunk often answers with plain text or an HTML error\n * page, and `JSON.parse` throwing there would replace a useful status with a parse\n * error.\n *\n * @param text - Raw response text.\n * @returns The parsed object, the raw text, or `null` when the body was empty.\n */\nfunction parseErrorBody(text: string): unknown {\n if (!text) return null;\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/**\n * Turn a refused tus response into a `TempestApiError`.\n *\n * The fallback `detail` is used unless the server sent a real error envelope,\n * because `buildApiError`'s own fallback (`\"Erro 409\"`) says nothing about which\n * step of the protocol broke — and that is the whole diagnostic value here.\n *\n * @param response - The raw response that was not acceptable.\n * @param detail - Message to use when the body carries none.\n * @returns The error to throw.\n */\n/**\n * The two statuses a chunk retry fixes that the shared policy cannot know about.\n *\n * `409` and `412` are the offset-divergence answers, and they are the entire\n * reason `resync` exists: the next attempt re-reads the server's offset with\n * `HEAD` and writes from there. They are 4xx refusals a replay genuinely fixes,\n * which is the one thing {@link isRetriableStatus} has no way to tell — from\n * outside this protocol they look like any other deliberate rejection.\n */\nconst RESYNCABLE_STATUSES: ReadonlySet<number> = new Set([409, 412]);\n\n/**\n * Whether a chunk failure is worth another attempt.\n *\n * The default used to be `true` for everything, which cost five round trips\n * before surfacing an answer the first one already gave. Two groups matter here\n * and both are specific to the resume protocol:\n *\n * - **`409`/`412` retry**, even though the shared policy rejects 4xx: they mean\n * \"your offset is wrong\", and `resync` is how the next attempt fixes it.\n * - **`404`/`410` do not**, even though a lost resource can look transient.\n * `probe()` turns them into \"O upload expirou no servidor. Comece de novo.\" and\n * recreating the upload only happens in `ensureUpload`, at attach time — never\n * inside the chunk loop. So a retry here re-runs `HEAD` against a resource that\n * is gone, five times, and then reports the same thing with the backoff added\n * on top.\n *\n * Anything with no API shape still retries: a transport failure has no status to\n * judge, and losing a large upload to one dropped connection is the outcome this\n * whole module exists to avoid.\n *\n * @param error - Whatever the attempt threw.\n * @returns Whether the chunk loop should try again.\n */\nfunction isRetriableChunkFailure(error: unknown): boolean {\n if (!isApiError(error)) return true;\n if (RESYNCABLE_STATUSES.has(error.status)) return true;\n return isRetriableStatus(error.status);\n}\n\nfunction failed(response: RawResponse, detail: string): TempestApiError {\n const body = parseErrorBody(response.text);\n const envelope = buildApiError(response.status, body, { get: response.header });\n const hasDetail =\n typeof body === \"object\" && body !== null && (\"detail\" in body || \"message\" in body);\n return new TempestApiError({ ...envelope, detail: hasDetail ? envelope.detail : detail });\n}\n\n/**\n * Resolve a `Location` header against the page, so a relative upload URL works.\n *\n * tus servers are free to answer creation with either an absolute URL or a\n * path, and the spec does not prefer one — a client that only handles absolute\n * URLs breaks against half the implementations.\n *\n * @param value - The raw `Location` header.\n * @returns An absolute URL, or the input when there is no base to resolve against.\n */\nfunction resolveUploadUrl(value: string): string {\n const base = typeof window === \"undefined\" ? undefined : window.location.href;\n try {\n return new URL(value, base).href;\n } catch {\n return value;\n }\n}\n\n/**\n * Chunked, resumable upload speaking the **tus 1.0.0** protocol (core plus the\n * *creation* and *termination* extensions).\n *\n * ## Why tus and not a bespoke scheme\n *\n * A resumable client whose wire format is undocumented cannot be integrated, and\n * inventing one means the backend is ours forever. tus is a published spec with\n * off-the-shelf servers (`tusd`, `tuspy`, `tus-node-server`), so a caller can point\n * this at something they did not write.\n *\n * ## What the backend must implement\n *\n * Every request carries `Tus-Resumable: 1.0.0`.\n *\n * | Step | Request | Expected response |\n * | --- | --- | --- |\n * | Create | `POST {endpoint}` + `Upload-Length`, `Upload-Metadata`, `Idempotency-Key` | `201` + `Location` (the upload URL, absolute or endpoint-relative) |\n * | Probe | `HEAD {uploadUrl}` | `200`/`204` + `Upload-Offset` |\n * | Write | `PATCH {uploadUrl}` + `Upload-Offset`, `Content-Type: application/offset+octet-stream`, chunk body | `204` + the new `Upload-Offset`; `409` when the offset does not match |\n * | Discard | `DELETE {uploadUrl}` | `204` |\n *\n * ## The failure that actually happens\n *\n * A chunk that the server stored but whose response never arrived. The client\n * cannot tell that from a chunk that was lost, and re-sending it blindly would\n * duplicate bytes. Two things prevent that:\n *\n * - **Writes are addressed, not appended.** Every `PATCH` states the offset it\n * writes at, so a retry after a lost response is asked to write bytes the server\n * already has and answers `409`. On any retry the client re-reads the truth with\n * `HEAD` first and continues from there.\n * - **Creation carries an `Idempotency-Key`** (from `generateIdempotencyKey`),\n * persisted before the first attempt and reused on retry. tus has no idempotent\n * creation of its own, so without this a lost `201` leaves an orphan upload on\n * the server. A backend that honours the header returns the same `Location`; one\n * that ignores it still works, it just keeps the orphan.\n *\n * @param options - Endpoint, file, and the knobs above.\n * @returns A handle with `start`/`pause`/`resume`/`abort` and live `state`/`offset`.\n *\n * @example\n * const upload = createResumableUpload({\n * endpoint: \"/api/uploads\",\n * file: recording,\n * metadata: { filename: \"nota.webm\", ticket: ticketId },\n * getToken: () => auth.getToken(),\n * onProgress: ({ fraction }) => setPercent(Math.round(fraction * 100)),\n * });\n *\n * const done = await upload.start();\n * if (done) await api.post(\"/api/tickets/1/audio\", { body: { url: done.url } });\n */\nexport function createResumableUpload(options: ResumableUploadOptions): ResumableUpload {\n const {\n endpoint,\n file,\n chunkSize = DEFAULT_CHUNK_SIZE,\n metadata,\n headers = {},\n getToken,\n trustedOrigins,\n withCredentials = false,\n key = uploadFingerprint(endpoint, file),\n storage = createLocalUploadStorage(),\n retry: retryOptions,\n onProgress,\n onStateChange,\n } = options;\n\n let state: ResumableUploadState = \"idle\";\n let offset = 0;\n let url: string | null = null;\n let idempotencyKey: string | null = null;\n let stopping: \"pause\" | \"abort\" | null = null;\n let inFlight: XMLHttpRequest | null = null;\n let resumedFrom = 0;\n\n function setState(next: ResumableUploadState): void {\n if (state === next) return;\n state = next;\n onStateChange?.(next);\n }\n\n function report(loaded: number): void {\n onProgress?.({\n loaded,\n total: file.size,\n fraction: file.size === 0 ? 1 : loaded / file.size,\n resumedFrom,\n });\n }\n\n /**\n * Headers every tus request carries, with the credential scoped to the\n * endpoint's origin.\n *\n * The target is a parameter because after creation it is not ours: tus\n * answers `POST {endpoint}` with a `Location` the server chooses, and the\n * spec allows an absolute URL on another host — handing the upload to\n * object storage is the ordinary deployment, not an attack. Before 0.66.0\n * the bearer token followed that header wherever it pointed, along with the\n * file bytes.\n *\n * @param target - The URL this particular request goes to.\n * @returns The headers to send.\n */\n function baseHeaders(target: string): Record<string, string> {\n const result: Record<string, string> = { ...headers, \"Tus-Resumable\": TUS_VERSION };\n const token = getToken?.();\n if (token && !(\"Authorization\" in result)) {\n if (isTrustedCredentialTarget(target, endpoint, trustedOrigins)) {\n result.Authorization = `Bearer ${token}`;\n } else {\n reportSuppressedCredential(target, endpoint);\n }\n }\n return result;\n }\n\n function register(xhr: XMLHttpRequest): void {\n inFlight = xhr;\n }\n\n async function persist(): Promise<void> {\n if (!storage || !url || !idempotencyKey) return;\n await storage.set(key, {\n url,\n offset,\n size: file.size,\n idempotencyKey,\n updatedAt: Date.now(),\n });\n }\n\n /**\n * Ask the server how much it holds. The only source of truth after any failure.\n */\n async function probe(target: string): Promise<number> {\n const response = await sendRequest({\n method: \"HEAD\",\n url: target,\n headers: baseHeaders(target),\n withCredentials,\n register,\n });\n if (response.status === 404 || response.status === 410) {\n throw new TempestApiError({\n status: response.status,\n detail: \"O upload expirou no servidor. Comece de novo.\",\n });\n }\n const confirmed = parseOffset(response);\n if (confirmed === null) throw failed(response, \"HEAD sem Upload-Offset.\");\n return confirmed;\n }\n\n /**\n * Re-attach to a persisted upload, or create a new one.\n *\n * The persisted record is only trusted when the file size still matches, and the\n * offset it holds is re-checked with `HEAD` — the client's copy can be ahead of\n * the server's whenever the last response was lost.\n */\n async function ensureUpload(): Promise<string> {\n const stored = storage ? await storage.get(key) : null;\n if (stored && stored.size === file.size) {\n idempotencyKey = stored.idempotencyKey;\n if (stored.url) {\n try {\n offset = await probe(stored.url);\n url = stored.url;\n return stored.url;\n } catch {\n offset = 0;\n }\n }\n }\n\n setState(\"creating\");\n idempotencyKey ??= generateIdempotencyKey();\n url = null;\n offset = 0;\n if (storage) {\n await storage.set(key, {\n url: \"\",\n offset: 0,\n size: file.size,\n idempotencyKey,\n updatedAt: Date.now(),\n });\n }\n\n const creationHeaders: Record<string, string> = {\n ...baseHeaders(endpoint),\n \"Upload-Length\": String(file.size),\n \"Idempotency-Key\": idempotencyKey,\n };\n const encoded = encodeMetadata(metadata);\n if (encoded) creationHeaders[\"Upload-Metadata\"] = encoded;\n\n const response = await sendRequest({\n method: \"POST\",\n url: endpoint,\n headers: creationHeaders,\n withCredentials,\n register,\n });\n if (response.status !== 201) throw failed(response, \"Criação do upload recusada.\");\n const locationHeader = response.header(\"Location\");\n if (!locationHeader) throw failed(response, \"Criação do upload sem cabeçalho Location.\");\n\n url = resolveUploadUrl(locationHeader);\n await persist();\n return url;\n }\n\n /** Push one chunk, resyncing the offset first when a previous attempt failed. */\n async function writeChunk(target: string, resync: { needed: boolean }): Promise<void> {\n if (resync.needed) {\n offset = await probe(target);\n resync.needed = false;\n report(offset);\n await persist();\n if (offset >= file.size) return;\n }\n\n const end = Math.min(offset + chunkSize, file.size);\n const from = offset;\n const response = await sendRequest({\n method: \"PATCH\",\n url: target,\n headers: {\n ...baseHeaders(target),\n \"Content-Type\": \"application/offset+octet-stream\",\n \"Upload-Offset\": String(from),\n },\n body: file.slice(from, end),\n withCredentials,\n onProgress: (loaded) => report(Math.min(from + loaded, file.size)),\n register,\n });\n\n if (response.status === 409 || response.status === 412) {\n resync.needed = true;\n throw failed(response, \"Offset divergente — o servidor já tinha esses bytes.\");\n }\n if (response.status !== 204 && response.status !== 200) {\n throw failed(response, \"Chunk recusado pelo servidor.\");\n }\n\n offset = parseOffset(response) ?? end;\n report(offset);\n await persist();\n }\n\n /**\n * Drive the whole upload: attach or create, then chunk until complete.\n *\n * The `shouldRetry` predicate does double duty — besides deciding, it arms\n * `resync` so the next attempt re-reads the server's offset with `HEAD` before\n * writing. That is deliberate: it is the one place that sees *every* chunk\n * failure, whatever the cause, and after any failure the client's idea of the\n * offset is exactly what cannot be trusted.\n *\n * `resync` is armed only when the attempt is actually going to happen. A\n * caller's own `shouldRetry` still wins the decision, and still arms the\n * resync when it says yes — the flag describes what the *next* attempt must\n * do, so setting it for an attempt that never comes describes nothing.\n * {@link isRetriableChunkFailure} is the default, and it is where `409`/`412`\n * earn a retry the shared policy would refuse and `404`/`410` lose one it\n * would have granted.\n *\n * @returns The result, or `null` when `pause`/`abort` stopped the run.\n */\n async function run(): Promise<ResumableUploadResult | null> {\n stopping = null;\n const target = await ensureUpload();\n resumedFrom = offset;\n setState(\"uploading\");\n report(offset);\n\n const resync = { needed: false };\n while (offset < file.size) {\n if (stopping) break;\n await retry(() => writeChunk(target, resync), {\n retries: 5,\n ...retryOptions,\n shouldRetry: (error, attempt) => {\n if (stopping) return false;\n if (error instanceof DOMException && error.name === \"AbortError\") return false;\n const again =\n retryOptions?.shouldRetry?.(error, attempt) ??\n isRetriableChunkFailure(error);\n if (again) resync.needed = true;\n return again;\n },\n });\n }\n\n if (stopping === \"pause\") {\n setState(\"paused\");\n return null;\n }\n if (stopping === \"abort\") {\n setState(\"aborted\");\n return null;\n }\n\n setState(\"done\");\n if (storage) await storage.delete(key);\n return { url: target, size: file.size };\n }\n\n async function guarded(): Promise<ResumableUploadResult | null> {\n try {\n return await run();\n } catch (error) {\n if (\n stopping !== null ||\n (error instanceof DOMException && error.name === \"AbortError\")\n ) {\n setState(stopping === \"abort\" ? \"aborted\" : \"paused\");\n return null;\n }\n setState(\"error\");\n throw error;\n } finally {\n inFlight = null;\n }\n }\n\n function stop(reason: \"pause\" | \"abort\"): void {\n stopping = reason;\n inFlight?.abort();\n inFlight = null;\n }\n\n return {\n start: guarded,\n resume: guarded,\n pause: () => stop(\"pause\"),\n abort: async ({ discard = false } = {}) => {\n stop(\"abort\");\n setState(\"aborted\");\n if (!discard) return;\n if (url) {\n await sendRequest({\n method: \"DELETE\",\n url,\n headers: baseHeaders(url),\n withCredentials,\n register: () => undefined,\n }).catch(() => undefined);\n }\n if (storage) await storage.delete(key);\n },\n get state() {\n return state;\n },\n get offset() {\n return offset;\n },\n get url() {\n return url;\n },\n key,\n };\n}\n"],"mappings":"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"}