tempest-react-sdk 0.64.0 → 0.66.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 (121) hide show
  1. package/README.md +4 -4
  2. package/dist/auth/create-tempest-auth.cjs +1 -1
  3. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  4. package/dist/auth/create-tempest-auth.js +31 -36
  5. package/dist/auth/create-tempest-auth.js.map +1 -1
  6. package/dist/components/Command/Command.module.cjs.map +1 -1
  7. package/dist/components/Command/Command.module.js.map +1 -1
  8. package/dist/components/ContextMenu/ContextMenu.module.cjs.map +1 -1
  9. package/dist/components/ContextMenu/ContextMenu.module.js.map +1 -1
  10. package/dist/components/DataTable/DataTable.cjs +1 -1
  11. package/dist/components/DataTable/DataTable.cjs.map +1 -1
  12. package/dist/components/DataTable/DataTable.js +1 -0
  13. package/dist/components/DataTable/DataTable.js.map +1 -1
  14. package/dist/components/DataTable/use-dev-warnings.cjs +1 -1
  15. package/dist/components/DataTable/use-dev-warnings.cjs.map +1 -1
  16. package/dist/components/DataTable/use-dev-warnings.js +4 -3
  17. package/dist/components/DataTable/use-dev-warnings.js.map +1 -1
  18. package/dist/components/DropdownMenu/DropdownMenu.module.cjs.map +1 -1
  19. package/dist/components/DropdownMenu/DropdownMenu.module.js.map +1 -1
  20. package/dist/components/InstallBanner/InstallBanner.cjs +1 -1
  21. package/dist/components/InstallBanner/InstallBanner.cjs.map +1 -1
  22. package/dist/components/InstallBanner/InstallBanner.js +62 -37
  23. package/dist/components/InstallBanner/InstallBanner.js.map +1 -1
  24. package/dist/components/InstallBanner/InstallBanner.module.cjs +1 -1
  25. package/dist/components/InstallBanner/InstallBanner.module.cjs.map +1 -1
  26. package/dist/components/InstallBanner/InstallBanner.module.js +4 -3
  27. package/dist/components/InstallBanner/InstallBanner.module.js.map +1 -1
  28. package/dist/components/InstallButton/InstallButton.cjs +1 -1
  29. package/dist/components/InstallButton/InstallButton.cjs.map +1 -1
  30. package/dist/components/InstallButton/InstallButton.js +34 -10
  31. package/dist/components/InstallButton/InstallButton.js.map +1 -1
  32. package/dist/components/InstallButton/InstallButton.module.cjs +3 -0
  33. package/dist/components/InstallButton/InstallButton.module.cjs.map +1 -0
  34. package/dist/components/InstallButton/InstallButton.module.js +10 -0
  35. package/dist/components/InstallButton/InstallButton.module.js.map +1 -0
  36. package/dist/components/InstallButton/install-hints.cjs +2 -0
  37. package/dist/components/InstallButton/install-hints.cjs.map +1 -0
  38. package/dist/components/InstallButton/install-hints.js +32 -0
  39. package/dist/components/InstallButton/install-hints.js.map +1 -0
  40. package/dist/components/SegmentedControl/SegmentedControl.module.cjs.map +1 -1
  41. package/dist/components/SegmentedControl/SegmentedControl.module.js.map +1 -1
  42. package/dist/components/Select/Select.cjs +1 -1
  43. package/dist/components/Select/Select.cjs.map +1 -1
  44. package/dist/components/Select/Select.js +43 -40
  45. package/dist/components/Select/Select.js.map +1 -1
  46. package/dist/components/Select/Select.module.cjs +1 -1
  47. package/dist/components/Select/Select.module.cjs.map +1 -1
  48. package/dist/components/Select/Select.module.js +5 -3
  49. package/dist/components/Select/Select.module.js.map +1 -1
  50. package/dist/hooks/use-install-prompt.cjs +1 -1
  51. package/dist/hooks/use-install-prompt.cjs.map +1 -1
  52. package/dist/hooks/use-install-prompt.js +9 -2
  53. package/dist/hooks/use-install-prompt.js.map +1 -1
  54. package/dist/http/api-client.cjs +1 -1
  55. package/dist/http/api-client.cjs.map +1 -1
  56. package/dist/http/api-client.js +93 -85
  57. package/dist/http/api-client.js.map +1 -1
  58. package/dist/http/credential-scope.cjs +2 -0
  59. package/dist/http/credential-scope.cjs.map +1 -0
  60. package/dist/http/credential-scope.js +23 -0
  61. package/dist/http/credential-scope.js.map +1 -0
  62. package/dist/http/decode-response.cjs +2 -0
  63. package/dist/http/decode-response.cjs.map +1 -0
  64. package/dist/http/decode-response.js +28 -0
  65. package/dist/http/decode-response.js.map +1 -0
  66. package/dist/http/resumable-upload.cjs +1 -1
  67. package/dist/http/resumable-upload.cjs.map +1 -1
  68. package/dist/http/resumable-upload.js +121 -120
  69. package/dist/http/resumable-upload.js.map +1 -1
  70. package/dist/http/upload-with-progress.cjs +1 -1
  71. package/dist/http/upload-with-progress.cjs.map +1 -1
  72. package/dist/http/upload-with-progress.js +35 -34
  73. package/dist/http/upload-with-progress.js.map +1 -1
  74. package/dist/styles/InstallButton.css +2 -0
  75. package/dist/styles/actions.css +6 -1
  76. package/dist/styles/advanced.css +2 -2
  77. package/dist/styles/component/Command.css +1 -1
  78. package/dist/styles/component/ContextMenu.css +1 -1
  79. package/dist/styles/component/DropdownMenu.css +1 -1
  80. package/dist/styles/component/InstallBanner.css +2 -0
  81. package/dist/styles/component/InstallButton.css +4 -0
  82. package/dist/styles/component/SegmentedControl.css +1 -1
  83. package/dist/styles/component/Select.css +4 -0
  84. package/dist/styles/core.css +2 -2
  85. package/dist/styles/forms.css +4 -0
  86. package/dist/styles/manifest.json +2 -1
  87. package/dist/styles/navigation.css +1 -1
  88. package/dist/styles/tokens.css +2 -2
  89. package/dist/styles.css +1 -1
  90. package/dist/tempest-react-sdk.cjs +1 -1
  91. package/dist/tempest-react-sdk.d.ts +473 -40
  92. package/dist/tempest-react-sdk.js +321 -318
  93. package/dist/theme/ThemeProvider.cjs +1 -1
  94. package/dist/theme/ThemeProvider.cjs.map +1 -1
  95. package/dist/theme/ThemeProvider.js +40 -45
  96. package/dist/theme/ThemeProvider.js.map +1 -1
  97. package/dist/theme/create-theme.cjs +1 -1
  98. package/dist/theme/create-theme.cjs.map +1 -1
  99. package/dist/theme/create-theme.js +1 -1
  100. package/dist/theme/create-theme.js.map +1 -1
  101. package/dist/theme/initial-theme.cjs +2 -2
  102. package/dist/theme/initial-theme.cjs.map +1 -1
  103. package/dist/theme/initial-theme.js +7 -12
  104. package/dist/theme/initial-theme.js.map +1 -1
  105. package/dist/utils/absent.cjs +2 -0
  106. package/dist/utils/absent.cjs.map +1 -0
  107. package/dist/utils/absent.js +12 -0
  108. package/dist/utils/absent.js.map +1 -0
  109. package/dist/utils/format.cjs +1 -1
  110. package/dist/utils/format.cjs.map +1 -1
  111. package/dist/utils/format.js +42 -35
  112. package/dist/utils/format.js.map +1 -1
  113. package/dist/utils/numbers.cjs +1 -1
  114. package/dist/utils/numbers.cjs.map +1 -1
  115. package/dist/utils/numbers.js +14 -12
  116. package/dist/utils/numbers.js.map +1 -1
  117. package/dist/utils/storage.cjs +1 -1
  118. package/dist/utils/storage.cjs.map +1 -1
  119. package/dist/utils/storage.js +13 -0
  120. package/dist/utils/storage.js.map +1 -1
  121. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"use-install-prompt.js","names":[],"sources":["../../src/hooks/use-install-prompt.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport {\n buildOpenInChromeIntent,\n isAndroidWithoutPromptApi,\n isIOS,\n isStandalone,\n type BeforeInstallPromptEvent,\n} from \"./pwa-env\";\n\nconst DEFAULT_DECLINE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;\nconst DEFAULT_MANUAL_FALLBACK_DELAY_MS = 3000;\nconst DEFAULT_DECLINE_STORAGE_KEY = \"tempest:install-declined-at\";\n\nlet cachedEvent: BeforeInstallPromptEvent | null = null;\nconst eventSubscribers = new Set<(event: BeforeInstallPromptEvent | null) => void>();\n\nif (typeof window !== \"undefined\") {\n window.addEventListener(\"beforeinstallprompt\", (event: Event) => {\n event.preventDefault();\n cachedEvent = event as BeforeInstallPromptEvent;\n eventSubscribers.forEach((fn) => fn(cachedEvent));\n });\n window.addEventListener(\"appinstalled\", () => {\n cachedEvent = null;\n eventSubscribers.forEach((fn) => fn(null));\n });\n}\n\n/**\n * Strategy the UI should use to install the app:\n *\n * - `\"native\"`: trigger `BeforeInstallPromptEvent.prompt()`.\n * - `\"ios\"`: show iOS Safari Share → Add to Home Screen instructions.\n * - `\"manual\"`: show generic browser-menu instructions (Chromium forks that\n * strip the prompt API, plus the timeout fallback when no event arrives).\n * - `\"none\"`: nothing to offer (already installed or unsupported runtime).\n */\nexport type InstallMethod = \"native\" | \"ios\" | \"manual\" | \"none\";\n\n/** Options for {@link useInstallPrompt}. */\nexport interface UseInstallPromptOptions {\n /**\n * `localStorage` key used to persist the decline timestamp. Defaults to\n * `\"tempest:install-declined-at\"`.\n */\n declineStorageKey?: string;\n /**\n * How long, in ms, the install CTA stays hidden after the user declines.\n * Defaults to 7 days.\n */\n declineCooldownMs?: number;\n /**\n * How long, in ms, to wait for a `beforeinstallprompt` event before\n * resolving to the `\"manual\"` method. Defaults to `3000`.\n */\n manualFallbackDelayMs?: number;\n}\n\n/** State returned by {@link useInstallPrompt}. */\nexport interface UseInstallPromptResult {\n /** The cached `beforeinstallprompt` event, or `null` when unavailable. */\n deferredPrompt: BeforeInstallPromptEvent | null;\n /** True when the app can be installed through any supported method. */\n canInstall: boolean;\n /** True when running on iOS/iPadOS Safari. */\n isIOS: boolean;\n /** True when already running as an installed PWA. */\n isStandalone: boolean;\n /** True when the browser is an Android Chromium fork with no prompt API. */\n isManualAndroid: boolean;\n /** True when no `beforeinstallprompt` arrived within the timeout window. */\n promptTimedOut: boolean;\n /** The resolved install strategy the UI should follow. */\n method: InstallMethod;\n /** An `intent://` URL to re-open the page in Chrome on Android, else `null`. */\n openInChromeIntent: string | null;\n /**\n * Triggers the native prompt.\n *\n * @returns `true` when the user accepted the install, `false` otherwise.\n */\n install: () => Promise<boolean>;\n /** Records that the user declined the CTA, starting the cooldown window. */\n recordDecline: () => void;\n}\n\nfunction readDecline(key: string, cooldownMs: number): boolean {\n if (typeof window === \"undefined\") return false;\n const raw = window.localStorage.getItem(key);\n if (!raw) return false;\n const ts = Number(raw);\n if (!Number.isFinite(ts)) return false;\n return Date.now() - ts < cooldownMs;\n}\n\n/**\n * React hook that resolves how (and whether) to offer PWA installation.\n *\n * It caches the `beforeinstallprompt` event, detects iOS/iPadOS, detects\n * Android Chromium forks that lack the prompt API, detects standalone display\n * mode, and applies a decline cooldown persisted in `localStorage`. The\n * resulting `method` tells the UI which install affordance to render.\n *\n * The decline persistence is pluggable through\n * {@link UseInstallPromptOptions.declineStorageKey} and\n * {@link UseInstallPromptOptions.declineCooldownMs} — no app-specific storage\n * layer is required, and it is SSR-guarded.\n *\n * @param options - Optional storage key, cooldown, and fallback-delay tuning.\n * @returns The install state plus `install()` and `recordDecline()` actions.\n *\n * @example\n * const { method, install } = useInstallPrompt();\n * if (method === \"native\") return <button onClick={install}>Install</button>;\n */\nexport function useInstallPrompt(options: UseInstallPromptOptions = {}): UseInstallPromptResult {\n const {\n declineStorageKey = DEFAULT_DECLINE_STORAGE_KEY,\n declineCooldownMs = DEFAULT_DECLINE_COOLDOWN_MS,\n manualFallbackDelayMs = DEFAULT_MANUAL_FALLBACK_DELAY_MS,\n } = options;\n\n const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(cachedEvent);\n const [standalone, setStandalone] = useState<boolean>(isStandalone());\n const [promptTimedOut, setPromptTimedOut] = useState<boolean>(false);\n\n useEffect(() => {\n eventSubscribers.add(setEvent);\n return () => {\n eventSubscribers.delete(setEvent);\n };\n }, []);\n\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n const mq = window.matchMedia?.(\"(display-mode: standalone)\");\n const handler = (): void => setStandalone(isStandalone());\n mq?.addEventListener?.(\"change\", handler);\n window.addEventListener(\"appinstalled\", handler);\n return () => {\n mq?.removeEventListener?.(\"change\", handler);\n window.removeEventListener(\"appinstalled\", handler);\n };\n }, []);\n\n useEffect(() => {\n if (event) {\n setPromptTimedOut(false);\n return;\n }\n const timer = window.setTimeout(() => {\n if (!cachedEvent) setPromptTimedOut(true);\n }, manualFallbackDelayMs);\n return () => window.clearTimeout(timer);\n }, [event, manualFallbackDelayMs]);\n\n const ios = isIOS();\n const manualAndroid = isAndroidWithoutPromptApi();\n\n const declineActive = readDecline(declineStorageKey, declineCooldownMs);\n\n let method: InstallMethod = \"none\";\n if (!standalone && !declineActive) {\n if (event) method = \"native\";\n else if (ios) method = \"ios\";\n else if (manualAndroid || promptTimedOut) method = \"manual\";\n }\n\n const recordDecline = (): void => {\n if (typeof window === \"undefined\") return;\n window.localStorage.setItem(declineStorageKey, String(Date.now()));\n };\n\n const install = async (): Promise<boolean> => {\n if (!event) return false;\n await event.prompt();\n const choice = await event.userChoice;\n if (choice.outcome === \"dismissed\") recordDecline();\n cachedEvent = null;\n eventSubscribers.forEach((fn) => fn(null));\n return choice.outcome === \"accepted\";\n };\n\n return {\n deferredPrompt: event,\n canInstall: method !== \"none\",\n isIOS: ios,\n isStandalone: standalone,\n isManualAndroid: manualAndroid,\n promptTimedOut,\n method,\n openInChromeIntent: buildOpenInChromeIntent(),\n install,\n recordDecline,\n };\n}\n"],"mappings":";;;AASA,IAAM,IAA8B,QAC9B,IAAmC,KACnC,IAA8B,+BAEhC,IAA+C,MAC7C,oBAAmB,IAAI,IAAsD;AAE/E,OAAO,SAAW,QAClB,OAAO,iBAAiB,wBAAwB,MAAiB;CAG7D,AAFA,EAAM,eAAe,GACrB,IAAc,GACd,EAAiB,SAAS,MAAO,EAAG,CAAW,CAAC;AACpD,CAAC,GACD,OAAO,iBAAiB,sBAAsB;CAE1C,AADA,IAAc,MACd,EAAiB,SAAS,MAAO,EAAG,IAAI,CAAC;AAC7C,CAAC;AA6DL,SAAS,EAAY,GAAa,GAA6B;CAC3D,IAAI,OAAO,SAAW,KAAa,OAAO;CAC1C,IAAM,IAAM,OAAO,aAAa,QAAQ,CAAG;CAC3C,IAAI,CAAC,GAAK,OAAO;CACjB,IAAM,IAAK,OAAO,CAAG;CAErB,OADK,OAAO,SAAS,CAAE,IAChB,KAAK,IAAI,IAAI,IAAK,IADQ;AAErC;AAsBA,SAAgB,EAAiB,IAAmC,CAAC,GAA2B;CAC5F,IAAM,EACF,uBAAoB,GACpB,uBAAoB,GACpB,2BAAwB,MACxB,GAEE,CAAC,GAAO,KAAY,EAA0C,CAAW,GACzE,CAAC,GAAY,KAAiB,EAAkB,EAAa,CAAC,GAC9D,CAAC,GAAgB,KAAqB,EAAkB,EAAK;CAqBnE,AAnBA,SACI,EAAiB,IAAI,CAAQ,SAChB;EACT,EAAiB,OAAO,CAAQ;CACpC,IACD,CAAC,CAAC,GAEL,QAAgB;EACZ,IAAI,OAAO,SAAW,KAAa;EACnC,IAAM,IAAK,OAAO,aAAa,4BAA4B,GACrD,UAAsB,EAAc,EAAa,CAAC;EAGxD,OAFA,GAAI,mBAAmB,UAAU,CAAO,GACxC,OAAO,iBAAiB,gBAAgB,CAAO,SAClC;GAET,AADA,GAAI,sBAAsB,UAAU,CAAO,GAC3C,OAAO,oBAAoB,gBAAgB,CAAO;EACtD;CACJ,GAAG,CAAC,CAAC,GAEL,QAAgB;EACZ,IAAI,GAAO;GACP,EAAkB,EAAK;GACvB;EACJ;EACA,IAAM,IAAQ,OAAO,iBAAiB;GAClC,AAAK,KAAa,EAAkB,EAAI;EAC5C,GAAG,CAAqB;EACxB,aAAa,OAAO,aAAa,CAAK;CAC1C,GAAG,CAAC,GAAO,CAAqB,CAAC;CAEjC,IAAM,IAAM,EAAM,GACZ,IAAgB,EAA0B,GAE1C,IAAgB,EAAY,GAAmB,CAAiB,GAElE,IAAwB;CAC5B,AAAI,CAAC,KAAc,CAAC,MACZ,IAAO,IAAS,WACX,IAAK,IAAS,SACd,KAAiB,OAAgB,IAAS;CAGvD,IAAM,UAA4B;EAC1B,OAAO,SAAW,OACtB,OAAO,aAAa,QAAQ,GAAmB,OAAO,KAAK,IAAI,CAAC,CAAC;CACrE;CAYA,OAAO;EACH,gBAAgB;EAChB,YAAY,MAAW;EACvB,OAAO;EACP,cAAc;EACd,iBAAiB;EACjB;EACA;EACA,oBAAoB,EAAwB;EAC5C,qBAnB0C;GAC1C,IAAI,CAAC,GAAO,OAAO;GACnB,MAAM,EAAM,OAAO;GACnB,IAAM,IAAS,MAAM,EAAM;GAI3B,OAHI,EAAO,YAAY,eAAa,EAAc,GAClD,IAAc,MACd,EAAiB,SAAS,MAAO,EAAG,IAAI,CAAC,GAClC,EAAO,YAAY;EAC9B;EAYI;CACJ;AACJ"}
1
+ {"version":3,"file":"use-install-prompt.js","names":[],"sources":["../../src/hooks/use-install-prompt.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport {\n buildOpenInChromeIntent,\n isAndroidWithoutPromptApi,\n isIOS,\n isStandalone,\n type BeforeInstallPromptEvent,\n} from \"./pwa-env\";\n\nconst DEFAULT_DECLINE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;\nconst DEFAULT_MANUAL_FALLBACK_DELAY_MS = 3000;\nconst DEFAULT_DECLINE_STORAGE_KEY = \"tempest:install-declined-at\";\n\nlet cachedEvent: BeforeInstallPromptEvent | null = null;\nconst eventSubscribers = new Set<(event: BeforeInstallPromptEvent | null) => void>();\n\nif (typeof window !== \"undefined\") {\n window.addEventListener(\"beforeinstallprompt\", (event: Event) => {\n event.preventDefault();\n cachedEvent = event as BeforeInstallPromptEvent;\n eventSubscribers.forEach((fn) => fn(cachedEvent));\n });\n window.addEventListener(\"appinstalled\", () => {\n cachedEvent = null;\n eventSubscribers.forEach((fn) => fn(null));\n });\n}\n\n/**\n * Strategy the UI should use to install the app:\n *\n * - `\"native\"`: trigger `BeforeInstallPromptEvent.prompt()`.\n * - `\"ios\"`: show iOS Safari Share → Add to Home Screen instructions.\n * - `\"manual\"`: show generic browser-menu instructions (Chromium forks that\n * strip the prompt API, plus the timeout fallback when no event arrives).\n * - `\"none\"`: nothing to offer (already installed or unsupported runtime).\n */\nexport type InstallMethod = \"native\" | \"ios\" | \"manual\" | \"none\";\n\n/** Options for {@link useInstallPrompt}. */\nexport interface UseInstallPromptOptions {\n /**\n * `localStorage` key used to persist the decline timestamp. Defaults to\n * `\"tempest:install-declined-at\"`.\n */\n declineStorageKey?: string;\n /**\n * How long, in ms, the install CTA stays hidden after the user declines.\n * Defaults to 7 days.\n */\n declineCooldownMs?: number;\n /**\n * How long, in ms, to wait for a `beforeinstallprompt` event before\n * resolving to the `\"manual\"` method. Defaults to `3000`.\n */\n manualFallbackDelayMs?: number;\n}\n\n/** State returned by {@link useInstallPrompt}. */\nexport interface UseInstallPromptResult {\n /** The cached `beforeinstallprompt` event, or `null` when unavailable. */\n deferredPrompt: BeforeInstallPromptEvent | null;\n /** True when the app can be installed through any supported method. */\n canInstall: boolean;\n /** True when running on iOS/iPadOS Safari. */\n isIOS: boolean;\n /** True when already running as an installed PWA. */\n isStandalone: boolean;\n /** True when the browser is an Android Chromium fork with no prompt API. */\n isManualAndroid: boolean;\n /** True when no `beforeinstallprompt` arrived within the timeout window. */\n promptTimedOut: boolean;\n /** The resolved install strategy the UI should follow. */\n method: InstallMethod;\n /** An `intent://` URL to re-open the page in Chrome on Android, else `null`. */\n openInChromeIntent: string | null;\n /**\n * Triggers the native prompt.\n *\n * @returns `true` when the user accepted the install, `false` otherwise.\n */\n install: () => Promise<boolean>;\n /** Records that the user declined the CTA, starting the cooldown window. */\n recordDecline: () => void;\n}\n\n/**\n * Whether a recorded decline is still inside its cooldown window.\n *\n * Reading is guarded because `localStorage.getItem` **throws** where this hook\n * matters most: Safari private mode and a cross-origin frame with storage\n * blocked. Unguarded, that exception escapes during render and takes the page\n * down — the install CTA is a courtesy, and no courtesy is worth a blank screen.\n *\n * @param key - Storage key holding the decline timestamp.\n * @param cooldownMs - How long a decline suppresses the CTA.\n * @returns Whether the CTA should stay hidden.\n */\nfunction readDecline(key: string, cooldownMs: number): boolean {\n if (typeof window === \"undefined\") return false;\n let raw: string | null;\n try {\n raw = window.localStorage.getItem(key);\n } catch {\n return false;\n }\n if (!raw) return false;\n const ts = Number(raw);\n if (!Number.isFinite(ts)) return false;\n return Date.now() - ts < cooldownMs;\n}\n\n/**\n * React hook that resolves how (and whether) to offer PWA installation.\n *\n * It caches the `beforeinstallprompt` event, detects iOS/iPadOS, detects\n * Android Chromium forks that lack the prompt API, detects standalone display\n * mode, and applies a decline cooldown persisted in `localStorage`. The\n * resulting `method` tells the UI which install affordance to render.\n *\n * Both sides of that persistence are best-effort: `localStorage` throws in\n * Safari private mode and in a cross-origin frame with storage blocked, and an\n * exception raised while resolving an install CTA would take the page down with\n * it. A refused read means \"no decline recorded\"; a refused write means the CTA\n * may come back next visit.\n *\n * The decline persistence is pluggable through\n * {@link UseInstallPromptOptions.declineStorageKey} and\n * {@link UseInstallPromptOptions.declineCooldownMs} — no app-specific storage\n * layer is required, and it is SSR-guarded.\n *\n * @param options - Optional storage key, cooldown, and fallback-delay tuning.\n * @returns The install state plus `install()` and `recordDecline()` actions.\n *\n * @example\n * const { method, install } = useInstallPrompt();\n * if (method === \"native\") return <button onClick={install}>Install</button>;\n */\nexport function useInstallPrompt(options: UseInstallPromptOptions = {}): UseInstallPromptResult {\n const {\n declineStorageKey = DEFAULT_DECLINE_STORAGE_KEY,\n declineCooldownMs = DEFAULT_DECLINE_COOLDOWN_MS,\n manualFallbackDelayMs = DEFAULT_MANUAL_FALLBACK_DELAY_MS,\n } = options;\n\n const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(cachedEvent);\n const [standalone, setStandalone] = useState<boolean>(isStandalone());\n const [promptTimedOut, setPromptTimedOut] = useState<boolean>(false);\n\n useEffect(() => {\n eventSubscribers.add(setEvent);\n return () => {\n eventSubscribers.delete(setEvent);\n };\n }, []);\n\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n const mq = window.matchMedia?.(\"(display-mode: standalone)\");\n const handler = (): void => setStandalone(isStandalone());\n mq?.addEventListener?.(\"change\", handler);\n window.addEventListener(\"appinstalled\", handler);\n return () => {\n mq?.removeEventListener?.(\"change\", handler);\n window.removeEventListener(\"appinstalled\", handler);\n };\n }, []);\n\n useEffect(() => {\n if (event) {\n setPromptTimedOut(false);\n return;\n }\n const timer = window.setTimeout(() => {\n if (!cachedEvent) setPromptTimedOut(true);\n }, manualFallbackDelayMs);\n return () => window.clearTimeout(timer);\n }, [event, manualFallbackDelayMs]);\n\n const ios = isIOS();\n const manualAndroid = isAndroidWithoutPromptApi();\n\n const declineActive = readDecline(declineStorageKey, declineCooldownMs);\n\n let method: InstallMethod = \"none\";\n if (!standalone && !declineActive) {\n if (event) method = \"native\";\n else if (ios) method = \"ios\";\n else if (manualAndroid || promptTimedOut) method = \"manual\";\n }\n\n const recordDecline = (): void => {\n if (typeof window === \"undefined\") return;\n try {\n window.localStorage.setItem(declineStorageKey, String(Date.now()));\n } catch {\n /* empty */\n }\n };\n\n const install = async (): Promise<boolean> => {\n if (!event) return false;\n await event.prompt();\n const choice = await event.userChoice;\n if (choice.outcome === \"dismissed\") recordDecline();\n cachedEvent = null;\n eventSubscribers.forEach((fn) => fn(null));\n return choice.outcome === \"accepted\";\n };\n\n return {\n deferredPrompt: event,\n canInstall: method !== \"none\",\n isIOS: ios,\n isStandalone: standalone,\n isManualAndroid: manualAndroid,\n promptTimedOut,\n method,\n openInChromeIntent: buildOpenInChromeIntent(),\n install,\n recordDecline,\n };\n}\n"],"mappings":";;;AASA,IAAM,IAA8B,QAC9B,IAAmC,KACnC,IAA8B,+BAEhC,IAA+C,MAC7C,oBAAmB,IAAI,IAAsD;AAE/E,OAAO,SAAW,QAClB,OAAO,iBAAiB,wBAAwB,MAAiB;CAG7D,AAFA,EAAM,eAAe,GACrB,IAAc,GACd,EAAiB,SAAS,MAAO,EAAG,CAAW,CAAC;AACpD,CAAC,GACD,OAAO,iBAAiB,sBAAsB;CAE1C,AADA,IAAc,MACd,EAAiB,SAAS,MAAO,EAAG,IAAI,CAAC;AAC7C,CAAC;AAyEL,SAAS,EAAY,GAAa,GAA6B;CAC3D,IAAI,OAAO,SAAW,KAAa,OAAO;CAC1C,IAAI;CACJ,IAAI;EACA,IAAM,OAAO,aAAa,QAAQ,CAAG;CACzC,QAAQ;EACJ,OAAO;CACX;CACA,IAAI,CAAC,GAAK,OAAO;CACjB,IAAM,IAAK,OAAO,CAAG;CAErB,OADK,OAAO,SAAS,CAAE,IAChB,KAAK,IAAI,IAAI,IAAK,IADQ;AAErC;AA4BA,SAAgB,EAAiB,IAAmC,CAAC,GAA2B;CAC5F,IAAM,EACF,uBAAoB,GACpB,uBAAoB,GACpB,2BAAwB,MACxB,GAEE,CAAC,GAAO,KAAY,EAA0C,CAAW,GACzE,CAAC,GAAY,KAAiB,EAAkB,EAAa,CAAC,GAC9D,CAAC,GAAgB,KAAqB,EAAkB,EAAK;CAqBnE,AAnBA,SACI,EAAiB,IAAI,CAAQ,SAChB;EACT,EAAiB,OAAO,CAAQ;CACpC,IACD,CAAC,CAAC,GAEL,QAAgB;EACZ,IAAI,OAAO,SAAW,KAAa;EACnC,IAAM,IAAK,OAAO,aAAa,4BAA4B,GACrD,UAAsB,EAAc,EAAa,CAAC;EAGxD,OAFA,GAAI,mBAAmB,UAAU,CAAO,GACxC,OAAO,iBAAiB,gBAAgB,CAAO,SAClC;GAET,AADA,GAAI,sBAAsB,UAAU,CAAO,GAC3C,OAAO,oBAAoB,gBAAgB,CAAO;EACtD;CACJ,GAAG,CAAC,CAAC,GAEL,QAAgB;EACZ,IAAI,GAAO;GACP,EAAkB,EAAK;GACvB;EACJ;EACA,IAAM,IAAQ,OAAO,iBAAiB;GAClC,AAAK,KAAa,EAAkB,EAAI;EAC5C,GAAG,CAAqB;EACxB,aAAa,OAAO,aAAa,CAAK;CAC1C,GAAG,CAAC,GAAO,CAAqB,CAAC;CAEjC,IAAM,IAAM,EAAM,GACZ,IAAgB,EAA0B,GAE1C,IAAgB,EAAY,GAAmB,CAAiB,GAElE,IAAwB;CAC5B,AAAI,CAAC,KAAc,CAAC,MACZ,IAAO,IAAS,WACX,IAAK,IAAS,SACd,KAAiB,OAAgB,IAAS;CAGvD,IAAM,UAA4B;EAC1B,aAAO,SAAW,MACtB,IAAI;GACA,OAAO,aAAa,QAAQ,GAAmB,OAAO,KAAK,IAAI,CAAC,CAAC;EACrE,QAAQ,CAER;CACJ;CAYA,OAAO;EACH,gBAAgB;EAChB,YAAY,MAAW;EACvB,OAAO;EACP,cAAc;EACd,iBAAiB;EACjB;EACA;EACA,oBAAoB,EAAwB;EAC5C,qBAnB0C;GAC1C,IAAI,CAAC,GAAO,OAAO;GACnB,MAAM,EAAM,OAAO;GACnB,IAAM,IAAS,MAAM,EAAM;GAI3B,OAHI,EAAO,YAAY,eAAa,EAAc,GAClD,IAAc,MACd,EAAiB,SAAS,MAAO,EAAG,IAAI,CAAC,GAClC,EAAO,YAAY;EAC9B;EAYI;CACJ;AACJ"}
@@ -1,2 +1,2 @@
1
- const e=require("../utils/ids.cjs"),t=require("./build-url.cjs"),n=require("./errors.cjs"),r=require("./retry.cjs"),i=require("./timeout.cjs");var a=new Set([`GET`,`HEAD`,`OPTIONS`]);function o(e,t){return!a.has(t)||!(e instanceof n.TempestApiError)?!1:n.isRetriableStatus(e.status)}function s(e){return e?e===!0?{}:e:null}var c=15e3,l=3e5;function u(e){return typeof FormData<`u`&&e instanceof FormData}function d(e){return Math.round(performance.now()-e)}async function f(e,t){let r;try{r=await e.clone().json()}catch{try{r=await e.text()}catch{r=null}}return new n.TempestApiError(n.buildApiError(e.status,r,e.headers,t))}function p(a){let p=a.fetcher??globalThis.fetch.bind(globalThis);function m(){let e=a.getToken?.();return e?{Authorization:`Bearer ${e}`}:{}}async function h(e,r,o){let{body:s,params:d,headers:f,signal:h,timeout:g,..._}=r,v=u(s),y=v?a.uploadTimeout:a.timeout,b=g===void 0?y===void 0?v?l:c:y:g,x={...v?{}:{"Content-Type":`application/json`},...o?{"X-Request-ID":o}:{},...a.headers,...m(),...f},S=i.withTimeout(h,b),C={..._,signal:S.signal,headers:x,credentials:a.withCredentials?`include`:_.credentials,body:s==null?void 0:v?s:JSON.stringify(s)},w=t.buildApiUrl(a.baseURL,e,{prefix:a.prefix,params:d});try{return await p(w,C)}catch(e){throw S.timedOut()?new n.TempestApiError({status:0,detail:`A requisição excedeu ${b}ms e foi abandonada.`}):e}finally{S.dispose()}}async function g(e,t,n,r){let i=a.logger;if(!i)return h(e,t,n);let o=performance.now();try{let a=await h(e,t,n),s={requestId:n,status:a.status,ms:d(o)},c=`${r} ${e} → ${a.status}`;return a.status>=400?i.warn(c,s):i.debug(c,s),a}catch(t){throw i.warn(`${r} ${e} → no response`,{requestId:n,ms:d(o),error:t}),t}}async function _(e,t){if(a.onUnauthorized){a.logger?.warn(`unauthorized — calling onUnauthorized`,{requestId:t,status:e.status});try{await a.onUnauthorized(e)}catch(n){a.logger?.warn(`onUnauthorized threw — keeping the original response error`,{requestId:t,status:e.status,error:n})}}}async function v(t,n){let r=a.requestId?a.requestId():e.randomId(),i=(n.method??`GET`).toUpperCase(),o=await g(t,n,r,i);if(o.status===401){if(a.refresh){try{await a.refresh(),o=await g(t,n,r,i)}catch{throw await _(o,r),await f(o,r)}o.status===401&&await _(o,r)}else await _(o,r)}if(!o.ok)throw await f(o,r);if(o.status!==204)return(o.headers.get(`content-type`)??``).includes(`application/json`)?await o.json():await o.text()}async function y(e,t={}){let n=s(a.retry);if(!n)return v(e,t);let i=(t.method??`GET`).toUpperCase();return r.retry(()=>v(e,t),{...n,shouldRetry:n.shouldRetry??(e=>o(e,i))})}async function b(e,t,n=`POST`,r){return y(e,{...r,method:n,body:t})}return{request:y,get:(e,t)=>y(e,{...t,method:`GET`}),post:(e,t)=>y(e,{...t,method:`POST`}),put:(e,t)=>y(e,{...t,method:`PUT`}),patch:(e,t)=>y(e,{...t,method:`PATCH`}),delete:(e,t)=>y(e,{...t,method:`DELETE`}),upload:b}}exports.createApiClient=p;
1
+ const e=require("../utils/ids.cjs"),t=require("./build-url.cjs"),n=require("./credential-scope.cjs"),r=require("./decode-response.cjs"),i=require("./errors.cjs"),a=require("./retry.cjs"),o=require("./timeout.cjs");var s=new Set([`GET`,`HEAD`,`OPTIONS`]);function c(e,t){return!s.has(t)||!(e instanceof i.TempestApiError)?!1:i.isRetriableStatus(e.status)}function l(e){return e?e===!0?{}:e:null}var u=15e3,d=3e5;function f(e){return typeof FormData<`u`&&e instanceof FormData}function p(e){return Math.round(performance.now()-e)}async function m(e,t){let n;try{n=await e.clone().json()}catch{try{n=await e.text()}catch{n=null}}return new i.TempestApiError(i.buildApiError(e.status,n,e.headers,t))}function h(s){let h=s.fetcher??globalThis.fetch.bind(globalThis);function g(e,t){if(t)return{};let r=s.getToken?.();return r?n.isTrustedCredentialTarget(e,s.baseURL,s.trustedOrigins)?{Authorization:`Bearer ${r}`}:(n.reportSuppressedCredential(e,s.baseURL),{}):{}}async function _(e,n,r,a){let{body:c,params:l,headers:p,signal:m,timeout:_,...v}=n,y=f(c),b=y?s.uploadTimeout:s.timeout,x=_===void 0?b===void 0?y?d:u:b:_,S=t.buildApiUrl(s.baseURL,e,{prefix:s.prefix,params:l}),C={...y?{}:{"Content-Type":`application/json`},...r?{"X-Request-ID":r}:{},...s.headers,...g(S,a),...p},w=o.withTimeout(m,x),T={...v,signal:w.signal,headers:C,credentials:s.withCredentials?`include`:v.credentials,body:c==null?void 0:y?c:JSON.stringify(c)};try{return await h(S,T)}catch(e){throw w.timedOut()?new i.TempestApiError({status:0,detail:`A requisição excedeu ${x}ms e foi abandonada.`}):e}finally{w.dispose()}}async function v(e,t,n,r,i){let a=s.logger;if(!a)return _(e,t,n,i);let o=performance.now();try{let s=await _(e,t,n,i),c={requestId:n,status:s.status,ms:p(o)},l=`${r} ${e} → ${s.status}`;return s.status>=400?a.warn(l,c):a.debug(l,c),s}catch(t){throw a.warn(`${r} ${e} → no response`,{requestId:n,ms:p(o),error:t}),t}}async function y(e,t){if(s.onUnauthorized){s.logger?.warn(`unauthorized — calling onUnauthorized`,{requestId:t,status:e.status});try{await s.onUnauthorized(e)}catch(n){s.logger?.warn(`onUnauthorized threw — keeping the original response error`,{requestId:t,status:e.status,error:n})}}}async function b(t,n,r){let{skipAuth:i=!1,skipAuthRetry:a=!1,...o}=n,c=s.requestId?s.requestId():e.randomId(),l=(o.method??`GET`).toUpperCase(),u=await v(t,o,c,l,i);if(u.status===401&&!i&&!a){if(s.refresh){try{await s.refresh(),u=await v(t,o,c,l,i)}catch{throw await y(u,c),await m(u,c)}u.status===401&&await y(u,c)}else await y(u,c)}if(!u.ok)throw await m(u,c);return r(u)}async function x(e,t,n){let{retry:r,...i}=t,o=l(r===void 0?s.retry:r);if(!o)return b(e,i,n);let u=(i.method??`GET`).toUpperCase();return a.retry(()=>b(e,i,n),{...o,shouldRetry:o.shouldRetry??(e=>c(e,u))})}async function S(e,t={}){return x(e,t,r.decodeByContentType)}async function C(e,t,n=`POST`,r){return S(e,{...r,method:n,body:t})}return{request:S,get:(e,t)=>S(e,{...t,method:`GET`}),post:(e,t)=>S(e,{...t,method:`POST`}),put:(e,t)=>S(e,{...t,method:`PUT`}),patch:(e,t)=>S(e,{...t,method:`PATCH`}),delete:(e,t)=>S(e,{...t,method:`DELETE`}),blob:(e,t)=>x(e,{...t},e=>e.blob()),arrayBuffer:(e,t)=>x(e,{...t},e=>e.arrayBuffer()),upload:C}}exports.createApiClient=h;
2
2
  //# sourceMappingURL=api-client.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.cjs","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { 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 * **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`/`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>(path: string, options: RequestOptions): 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 if (response.status === 204) {\n return undefined as T;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (contentType.includes(\"application/json\")) {\n return (await response.json()) as T;\n }\n return (await response.text()) as unknown as T;\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n const retryOptions = resolveRetry(config.retry);\n if (!retryOptions) return attempt<T>(path, options);\n\n const method = (options.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, options), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\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 upload,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"api-client.cjs","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { isTrustedCredentialTarget, reportSuppressedCredential } from \"./credential-scope\";\nimport { decodeByContentType } from \"./decode-response\";\nimport type { ResponseDecoder } from \"./decode-response\";\nimport { buildApiError, TempestApiError, isRetriableStatus } from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport { withTimeout } from \"./timeout\";\nimport type { ApiClient, ApiClientConfig, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return isRetriableStatus(error.status);\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\n/** Default milliseconds before a request is abandoned. */\nconst DEFAULT_TIMEOUT = 15_000;\n\n/** Default milliseconds before a `FormData` request is abandoned. */\nconst DEFAULT_UPLOAD_TIMEOUT = 300_000;\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\n/**\n * Milliseconds since a `performance.now()` reading, rounded.\n *\n * `performance.now()` rather than `Date.now()` because this measures a\n * duration: the wall clock can step sideways mid-request (an NTP correction, a\n * VM resuming, the user changing the clock) and turn a 40 ms call into a\n * negative number. The monotonic clock cannot.\n *\n * @param startedAt - The reading taken before the work started.\n * @returns Whole milliseconds elapsed.\n */\nfunction elapsedMs(startedAt: number): number {\n return Math.round(performance.now() - startedAt);\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Binary bodies** have their own methods — `blob()` and `arrayBuffer()` — which\n * run the same pipeline and skip the parse. `request()` no longer decodes an\n * unknown `Content-Type` as text either: it returns a `Blob` and says so once in\n * a development build, because reading `image/jpeg` as UTF-8 destroyed the bytes\n * on the way in.\n *\n * **The bearer token is scoped to `baseURL`'s origin** since 0.66.0. A path may\n * be an absolute URL, which overrides the base entirely, so without a scope the\n * destination of a credentialed request could come from a value read off the\n * network. A request to another origin still goes out; it goes without the\n * header, and a development build says so once. Declare the exceptions in\n * {@link ApiClientConfig.trustedOrigins}.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write. A single call overrides it\n * with {@link RequestOptions.retry}, and opts out of auth entirely with\n * {@link RequestOptions.skipAuth}.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`blob`/\n * `arrayBuffer`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n /**\n * The `Authorization` header for a request, or nothing.\n *\n * Takes the resolved URL rather than reading `config.baseURL`, because an\n * absolute path overrides the base entirely (see `buildApiUrl`) and the\n * credential is scoped to an origin, not to a client.\n *\n * @param url - The absolute URL the request is going to.\n * @param skipAuth - Whether the caller opted this request out of auth.\n * @returns The header, or an empty object.\n */\n function authHeaders(url: string, skipAuth: boolean): Record<string, string> {\n if (skipAuth) return {};\n const token = config.getToken?.();\n if (!token) return {};\n if (!isTrustedCredentialTarget(url, config.baseURL, config.trustedOrigins)) {\n reportSuppressedCredential(url, config.baseURL);\n return {};\n }\n return { Authorization: `Bearer ${token}` };\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId: string | undefined,\n skipAuth: boolean,\n ): Promise<Response> {\n const { body, params, headers, signal, timeout, ...rest } = options;\n const isForm = isFormData(body);\n const configured = isForm ? config.uploadTimeout : config.timeout;\n const fallback = isForm ? DEFAULT_UPLOAD_TIMEOUT : DEFAULT_TIMEOUT;\n const limit =\n timeout !== undefined ? timeout : configured !== undefined ? configured : fallback;\n\n const url = buildApiUrl(config.baseURL, path, { prefix: config.prefix, params });\n\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(url, skipAuth),\n ...(headers as Record<string, string> | undefined),\n };\n\n const timed = withTimeout(signal, limit);\n const init: RequestInit = {\n ...rest,\n signal: timed.signal,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n try {\n return await fetcher(url, init);\n } catch (cause) {\n if (timed.timedOut()) {\n throw new TempestApiError({\n status: 0,\n detail: `A requisição excedeu ${limit}ms e foi abandonada.`,\n });\n }\n throw cause;\n } finally {\n timed.dispose();\n }\n }\n\n async function send(\n path: string,\n options: RequestOptions,\n requestId: string,\n method: string,\n skipAuth: boolean,\n ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(path, options, requestId, skipAuth);\n\n const startedAt = performance.now();\n try {\n const response = await rawRequest(path, options, requestId, skipAuth);\n const entry = { requestId, status: response.status, ms: elapsedMs(startedAt) };\n const line = `${method} ${path} → ${response.status}`;\n if (response.status >= 400) log.warn(line, entry);\n else log.debug(line, entry);\n return response;\n } catch (error) {\n log.warn(`${method} ${path} → no response`, {\n requestId,\n ms: elapsedMs(startedAt),\n error,\n });\n throw error;\n }\n }\n\n async function notifyUnauthorized(response: Response, requestId: string): Promise<void> {\n if (!config.onUnauthorized) return;\n config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n requestId,\n status: response.status,\n });\n try {\n await config.onUnauthorized(response);\n } catch (error) {\n config.logger?.warn(`onUnauthorized threw — keeping the original response error`, {\n requestId,\n status: response.status,\n error,\n });\n }\n }\n\n async function attempt<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { skipAuth = false, skipAuthRetry = false, ...init } = options;\n const requestId = config.requestId ? config.requestId() : randomId();\n const method = (init.method ?? \"GET\").toUpperCase();\n let response = await send(path, init, requestId, method, skipAuth);\n\n if (response.status === 401 && !skipAuth && !skipAuthRetry) {\n if (config.refresh) {\n try {\n await config.refresh();\n response = await send(path, init, requestId, method, skipAuth);\n } catch {\n await notifyUnauthorized(response, requestId);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await notifyUnauthorized(response, requestId);\n }\n } else {\n await notifyUnauthorized(response, requestId);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n return decode(response);\n }\n\n async function run<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { retry: perRequest, ...rest } = options;\n const retryOptions = resolveRetry(perRequest !== undefined ? perRequest : config.retry);\n if (!retryOptions) return attempt<T>(path, rest, decode);\n\n const method = (rest.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, rest, decode), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return run<T>(path, options, decodeByContentType);\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n options?: Omit<RequestOptions, \"body\" | \"method\">,\n ): Promise<T> {\n return request<T>(path, { ...options, method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n blob: (path: string, options?: RequestOptions) =>\n run<Blob>(path, { ...options }, (response) => response.blob()),\n arrayBuffer: (path: string, options?: RequestOptions) =>\n run<ArrayBuffer>(path, { ...options }, (response) => response.arrayBuffer()),\n upload,\n };\n}\n"],"mappings":"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"}
@@ -1,103 +1,106 @@
1
1
  import { randomId as e } from "../utils/ids.js";
2
2
  import { buildApiUrl as t } from "./build-url.js";
3
- import { TempestApiError as n, buildApiError as r, isRetriableStatus as i } from "./errors.js";
4
- import { retry as a } from "./retry.js";
5
- import { withTimeout as o } from "./timeout.js";
3
+ import { isTrustedCredentialTarget as n, reportSuppressedCredential as r } from "./credential-scope.js";
4
+ import { decodeByContentType as i } from "./decode-response.js";
5
+ import { TempestApiError as a, buildApiError as o, isRetriableStatus as s } from "./errors.js";
6
+ import { retry as c } from "./retry.js";
7
+ import { withTimeout as l } from "./timeout.js";
6
8
  //#region src/http/api-client.ts
7
- var s = /* @__PURE__ */ new Set([
9
+ var u = /* @__PURE__ */ new Set([
8
10
  "GET",
9
11
  "HEAD",
10
12
  "OPTIONS"
11
13
  ]);
12
- function c(e, t) {
13
- return !s.has(t) || !(e instanceof n) ? !1 : i(e.status);
14
+ function d(e, t) {
15
+ return !u.has(t) || !(e instanceof a) ? !1 : s(e.status);
14
16
  }
15
- function l(e) {
17
+ function f(e) {
16
18
  return e ? e === !0 ? {} : e : null;
17
19
  }
18
- var u = 15e3, d = 3e5;
19
- function f(e) {
20
+ var p = 15e3, m = 3e5;
21
+ function h(e) {
20
22
  return typeof FormData < "u" && e instanceof FormData;
21
23
  }
22
- function p(e) {
24
+ function g(e) {
23
25
  return Math.round(performance.now() - e);
24
26
  }
25
- async function m(e, t) {
26
- let i;
27
+ async function _(e, t) {
28
+ let n;
27
29
  try {
28
- i = await e.clone().json();
30
+ n = await e.clone().json();
29
31
  } catch {
30
32
  try {
31
- i = await e.text();
33
+ n = await e.text();
32
34
  } catch {
33
- i = null;
35
+ n = null;
34
36
  }
35
37
  }
36
- return new n(r(e.status, i, e.headers, t));
38
+ return new a(o(e.status, n, e.headers, t));
37
39
  }
38
- function h(r) {
39
- let i = r.fetcher ?? globalThis.fetch.bind(globalThis);
40
- function s() {
41
- let e = r.getToken?.();
42
- return e ? { Authorization: `Bearer ${e}` } : {};
40
+ function v(o) {
41
+ let s = o.fetcher ?? globalThis.fetch.bind(globalThis);
42
+ function u(e, t) {
43
+ if (t) return {};
44
+ let i = o.getToken?.();
45
+ return i ? n(e, o.baseURL, o.trustedOrigins) ? { Authorization: `Bearer ${i}` } : (r(e, o.baseURL), {}) : {};
43
46
  }
44
- async function h(e, a, c) {
45
- let { body: l, params: p, headers: m, signal: h, timeout: g, ..._ } = a, v = f(l), y = v ? r.uploadTimeout : r.timeout, b = g === void 0 ? y === void 0 ? v ? d : u : y : g, x = {
46
- ...v ? {} : { "Content-Type": "application/json" },
47
- ...c ? { "X-Request-ID": c } : {},
48
- ...r.headers,
49
- ...s(),
50
- ...m
51
- }, S = o(h, b), C = {
52
- ..._,
53
- signal: S.signal,
54
- headers: x,
55
- credentials: r.withCredentials ? "include" : _.credentials,
56
- body: l == null ? void 0 : v ? l : JSON.stringify(l)
57
- }, w = t(r.baseURL, e, {
58
- prefix: r.prefix,
59
- params: p
60
- });
47
+ async function v(e, n, r, i) {
48
+ let { body: c, params: d, headers: f, signal: g, timeout: _, ...v } = n, y = h(c), b = y ? o.uploadTimeout : o.timeout, x = _ === void 0 ? b === void 0 ? y ? m : p : b : _, S = t(o.baseURL, e, {
49
+ prefix: o.prefix,
50
+ params: d
51
+ }), C = {
52
+ ...y ? {} : { "Content-Type": "application/json" },
53
+ ...r ? { "X-Request-ID": r } : {},
54
+ ...o.headers,
55
+ ...u(S, i),
56
+ ...f
57
+ }, w = l(g, x), T = {
58
+ ...v,
59
+ signal: w.signal,
60
+ headers: C,
61
+ credentials: o.withCredentials ? "include" : v.credentials,
62
+ body: c == null ? void 0 : y ? c : JSON.stringify(c)
63
+ };
61
64
  try {
62
- return await i(w, C);
65
+ return await s(S, T);
63
66
  } catch (e) {
64
- throw S.timedOut() ? new n({
67
+ throw w.timedOut() ? new a({
65
68
  status: 0,
66
- detail: `A requisição excedeu ${b}ms e foi abandonada.`
69
+ detail: `A requisição excedeu ${x}ms e foi abandonada.`
67
70
  }) : e;
68
71
  } finally {
69
- S.dispose();
72
+ w.dispose();
70
73
  }
71
74
  }
72
- async function g(e, t, n, i) {
73
- let a = r.logger;
74
- if (!a) return h(e, t, n);
75
- let o = performance.now();
75
+ async function y(e, t, n, r, i) {
76
+ let a = o.logger;
77
+ if (!a) return v(e, t, n, i);
78
+ let s = performance.now();
76
79
  try {
77
- let r = await h(e, t, n), s = {
80
+ let o = await v(e, t, n, i), c = {
78
81
  requestId: n,
79
- status: r.status,
80
- ms: p(o)
81
- }, c = `${i} ${e} → ${r.status}`;
82
- return r.status >= 400 ? a.warn(c, s) : a.debug(c, s), r;
82
+ status: o.status,
83
+ ms: g(s)
84
+ }, l = `${r} ${e} → ${o.status}`;
85
+ return o.status >= 400 ? a.warn(l, c) : a.debug(l, c), o;
83
86
  } catch (t) {
84
- throw a.warn(`${i} ${e} → no response`, {
87
+ throw a.warn(`${r} ${e} → no response`, {
85
88
  requestId: n,
86
- ms: p(o),
89
+ ms: g(s),
87
90
  error: t
88
91
  }), t;
89
92
  }
90
93
  }
91
- async function _(e, t) {
92
- if (r.onUnauthorized) {
93
- r.logger?.warn("unauthorized — calling onUnauthorized", {
94
+ async function b(e, t) {
95
+ if (o.onUnauthorized) {
96
+ o.logger?.warn("unauthorized — calling onUnauthorized", {
94
97
  requestId: t,
95
98
  status: e.status
96
99
  });
97
100
  try {
98
- await r.onUnauthorized(e);
101
+ await o.onUnauthorized(e);
99
102
  } catch (n) {
100
- r.logger?.warn("onUnauthorized threw — keeping the original response error", {
103
+ o.logger?.warn("onUnauthorized threw — keeping the original response error", {
101
104
  requestId: t,
102
105
  status: e.status,
103
106
  error: n
@@ -105,63 +108,68 @@ function h(r) {
105
108
  }
106
109
  }
107
110
  }
108
- async function v(t, n) {
109
- let i = r.requestId ? r.requestId() : e(), a = (n.method ?? "GET").toUpperCase(), o = await g(t, n, i, a);
110
- if (o.status === 401) {
111
- if (r.refresh) {
111
+ async function x(t, n, r) {
112
+ let { skipAuth: i = !1, skipAuthRetry: a = !1, ...s } = n, c = o.requestId ? o.requestId() : e(), l = (s.method ?? "GET").toUpperCase(), u = await y(t, s, c, l, i);
113
+ if (u.status === 401 && !i && !a) {
114
+ if (o.refresh) {
112
115
  try {
113
- await r.refresh(), o = await g(t, n, i, a);
116
+ await o.refresh(), u = await y(t, s, c, l, i);
114
117
  } catch {
115
- throw await _(o, i), await m(o, i);
118
+ throw await b(u, c), await _(u, c);
116
119
  }
117
- o.status === 401 && await _(o, i);
118
- } else await _(o, i);
120
+ u.status === 401 && await b(u, c);
121
+ } else await b(u, c);
119
122
  }
120
- if (!o.ok) throw await m(o, i);
121
- if (o.status !== 204) return (o.headers.get("content-type") ?? "").includes("application/json") ? await o.json() : await o.text();
123
+ if (!u.ok) throw await _(u, c);
124
+ return r(u);
122
125
  }
123
- async function y(e, t = {}) {
124
- let n = l(r.retry);
125
- if (!n) return v(e, t);
126
- let i = (t.method ?? "GET").toUpperCase();
127
- return a(() => v(e, t), {
128
- ...n,
129
- shouldRetry: n.shouldRetry ?? ((e) => c(e, i))
126
+ async function S(e, t, n) {
127
+ let { retry: r, ...i } = t, a = f(r === void 0 ? o.retry : r);
128
+ if (!a) return x(e, i, n);
129
+ let s = (i.method ?? "GET").toUpperCase();
130
+ return c(() => x(e, i, n), {
131
+ ...a,
132
+ shouldRetry: a.shouldRetry ?? ((e) => d(e, s))
130
133
  });
131
134
  }
132
- async function b(e, t, n = "POST", r) {
133
- return y(e, {
135
+ async function C(e, t = {}) {
136
+ return S(e, t, i);
137
+ }
138
+ async function w(e, t, n = "POST", r) {
139
+ return C(e, {
134
140
  ...r,
135
141
  method: n,
136
142
  body: t
137
143
  });
138
144
  }
139
145
  return {
140
- request: y,
141
- get: (e, t) => y(e, {
146
+ request: C,
147
+ get: (e, t) => C(e, {
142
148
  ...t,
143
149
  method: "GET"
144
150
  }),
145
- post: (e, t) => y(e, {
151
+ post: (e, t) => C(e, {
146
152
  ...t,
147
153
  method: "POST"
148
154
  }),
149
- put: (e, t) => y(e, {
155
+ put: (e, t) => C(e, {
150
156
  ...t,
151
157
  method: "PUT"
152
158
  }),
153
- patch: (e, t) => y(e, {
159
+ patch: (e, t) => C(e, {
154
160
  ...t,
155
161
  method: "PATCH"
156
162
  }),
157
- delete: (e, t) => y(e, {
163
+ delete: (e, t) => C(e, {
158
164
  ...t,
159
165
  method: "DELETE"
160
166
  }),
161
- upload: b
167
+ blob: (e, t) => S(e, { ...t }, (e) => e.blob()),
168
+ arrayBuffer: (e, t) => S(e, { ...t }, (e) => e.arrayBuffer()),
169
+ upload: w
162
170
  };
163
171
  }
164
172
  //#endregion
165
- export { h as createApiClient };
173
+ export { v as createApiClient };
166
174
 
167
175
  //# sourceMappingURL=api-client.js.map
@@ -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 { 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 * **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`/`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>(path: string, options: RequestOptions): 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 if (response.status === 204) {\n return undefined as T;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (contentType.includes(\"application/json\")) {\n return (await response.json()) as T;\n }\n return (await response.text()) as unknown as T;\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n const retryOptions = resolveRetry(config.retry);\n if (!retryOptions) return attempt<T>(path, options);\n\n const method = (options.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, options), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\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 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"}
@@ -0,0 +1,2 @@
1
+ const e=require("../utils/dev-mode.cjs");var t=[`xml`,`javascript`,`ecmascript`,`urlencoded`,`csv`];function n(e){return e.toLowerCase().includes(`json`)}function r(e){let n=e.trim().toLowerCase();return n===``||n.startsWith(`text/`)?!0:t.some(e=>n.includes(e))}function i(t){e.isDevBuild()&&console.warn(`[tempest] <ApiClient> received a binary response (content-type: ${t}) and returned a Blob. Call \`client.blob()\` or \`client.arrayBuffer()\` so the type matches what arrives.`)}async function a(e){if(e.status===204)return;let t=e.headers.get(`content-type`)??``;return n(t)?await e.json():r(t)?await e.text():(i(t),await e.blob())}exports.decodeByContentType=a,exports.isJsonContentType=n,exports.isTextualContentType=r;
2
+ //# sourceMappingURL=decode-response.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decode-response.cjs","names":[],"sources":["../../src/http/decode-response.ts"],"sourcesContent":["import { isDevBuild } from \"../utils/dev-mode\";\n\n/**\n * Media-type fragments that name something safe to read as text, for the types\n * that do not open with `text/` — `application/xml`, `application/javascript`,\n * `application/x-www-form-urlencoded`.\n */\nconst TEXTUAL_TOKENS: readonly string[] = [\"xml\", \"javascript\", \"ecmascript\", \"urlencoded\", \"csv\"];\n\n/** How a {@link createApiClient} response body becomes the value a caller awaited. */\nexport type ResponseDecoder<T> = (response: Response) => Promise<T>;\n\n/**\n * Whether a `Content-Type` names a JSON dialect.\n *\n * Matches the suffix forms as well as the plain one, so `application/json`,\n * `application/problem+json` (RFC 9457, what a FastAPI error handler sends) and\n * `application/vnd.tempest.v2+json` all decode as JSON instead of falling\n * through to text.\n *\n * @param contentType - The raw header value, possibly with parameters.\n * @returns Whether the body should be read with `response.json()`.\n */\nexport function isJsonContentType(contentType: string): boolean {\n return contentType.toLowerCase().includes(\"json\");\n}\n\n/**\n * Whether a `Content-Type` names something that survives being read as text.\n *\n * An absent header counts as textual: a backend that answers without one is\n * almost always sending text, and treating it as binary would change what\n * existing callers get back for no measured gain.\n *\n * @param contentType - The raw header value, possibly with parameters.\n * @returns Whether the body should be read with `response.text()`.\n */\nexport function isTextualContentType(contentType: string): boolean {\n const type = contentType.trim().toLowerCase();\n if (type === \"\") return true;\n if (type.startsWith(\"text/\")) return true;\n return TEXTUAL_TOKENS.some((token) => type.includes(token));\n}\n\n/**\n * Warn, in a development build, that a binary body reached the JSON/text path.\n *\n * @param contentType - The `Content-Type` that was decoded as a `Blob`.\n */\nfunction warnBinaryBody(contentType: string): void {\n if (!isDevBuild()) return;\n console.warn(\n `[tempest] <ApiClient> received a binary response (content-type: ${contentType}) and returned a Blob. ` +\n \"Call `client.blob()` or `client.arrayBuffer()` so the type matches what arrives.\",\n );\n}\n\n/**\n * Decode a response the way its `Content-Type` asks to be decoded.\n *\n * JSON is parsed, text is read as text, and **everything else comes back as a\n * `Blob`**. That last branch is the fix for a silent data defect: the client\n * used to fall back to `response.text()` for any non-JSON body, so an\n * `image/jpeg` was decoded as UTF-8 and the bytes were destroyed on the way in.\n * Measured on a bare JPEG header — `ff d8 ff e0 00 10 4a 46 49 46` — the\n * round-trip through text returns 18 bytes for the 10 that arrived, 4 of them\n * replaced by `U+FFFD`. No caller can undo that, which is why the download had\n * to leave the client entirely.\n *\n * A `204` has no body at all and resolves to `undefined`, whatever the headers\n * claim.\n *\n * @typeParam T - What the caller expects back.\n * @param response - A response already known to be successful.\n * @returns The decoded body.\n */\nexport async function decodeByContentType<T>(response: Response): Promise<T> {\n if (response.status === 204) {\n return undefined as T;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (isJsonContentType(contentType)) {\n return (await response.json()) as T;\n }\n if (isTextualContentType(contentType)) {\n return (await response.text()) as unknown as T;\n }\n\n warnBinaryBody(contentType);\n return (await response.blob()) as unknown as T;\n}\n"],"mappings":"yCAOA,IAAM,EAAoC,CAAC,MAAO,aAAc,aAAc,aAAc,KAAK,EAgBjG,SAAgB,EAAkB,EAA8B,CAC5D,OAAO,EAAY,YAAY,CAAC,CAAC,SAAS,MAAM,CACpD,CAYA,SAAgB,EAAqB,EAA8B,CAC/D,IAAM,EAAO,EAAY,KAAK,CAAC,CAAC,YAAY,EAG5C,OAFI,IAAS,IACT,EAAK,WAAW,OAAO,EAAU,GAC9B,EAAe,KAAM,GAAU,EAAK,SAAS,CAAK,CAAC,CAC9D,CAOA,SAAS,EAAe,EAA2B,CAC1C,EAAA,WAAW,GAChB,QAAQ,KACJ,mEAAmE,EAAY,4GAEnF,CACJ,CAqBA,eAAsB,EAAuB,EAAgC,CACzE,GAAI,EAAS,SAAW,IACpB,OAGJ,IAAM,EAAc,EAAS,QAAQ,IAAI,cAAc,GAAK,GAS5D,OARI,EAAkB,CAAW,EACrB,MAAM,EAAS,KAAK,EAE5B,EAAqB,CAAW,EACxB,MAAM,EAAS,KAAK,GAGhC,EAAe,CAAW,EAClB,MAAM,EAAS,KAAK,EAChC"}