client.js 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. export const apiBaseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:3001";
  2. export function extractErrorMessage(payload, status) {
  3. if (payload?.msg) return payload.msg;
  4. if (payload?.error) return payload.error;
  5. if (payload?.message) return payload.message;
  6. return `http_error:${status}`;
  7. }
  8. export class ApiError extends Error {
  9. constructor(category, message, extra = {}) {
  10. super(message || category);
  11. this.category = category;
  12. Object.assign(this, extra);
  13. }
  14. }
  15. export function categorizeHttpStatus(status) {
  16. if (status === 401) return "invalid_credentials";
  17. if (status === 429) return "locked";
  18. return "server";
  19. }
  20. let _accessToken = null;
  21. let _refreshFn = null;
  22. let _authErrorFn = null;
  23. let _refreshPromise = null;
  24. export function setAccessToken(token) {
  25. _accessToken = token ?? null;
  26. }
  27. export function setRefreshCallback(fn) {
  28. _refreshFn = fn ?? null;
  29. }
  30. export function setAuthErrorCallback(fn) {
  31. _authErrorFn = fn ?? null;
  32. }
  33. export function buildAuthHeaders(extra = {}) {
  34. const headers = { "content-type": "application/json", ...extra };
  35. if (_accessToken) headers["Authorization"] = `Bearer ${_accessToken}`;
  36. return headers;
  37. }
  38. export async function refreshAccessToken() {
  39. if (!_refreshFn) return null;
  40. if (!_refreshPromise) {
  41. _refreshPromise = _refreshFn().finally(() => { _refreshPromise = null; });
  42. }
  43. return _refreshPromise;
  44. }
  45. export function notifyAuthError() {
  46. _authErrorFn?.();
  47. }
  48. export async function apiFetch(path, { method = "GET", body } = {}) {
  49. const headers = { "content-type": "application/json" };
  50. if (_accessToken) headers["Authorization"] = `Bearer ${_accessToken}`;
  51. let res;
  52. try {
  53. res = await fetch(`${apiBaseUrl}${path}`, {
  54. method,
  55. headers,
  56. body: body ? JSON.stringify(body) : undefined
  57. });
  58. } catch {
  59. throw new ApiError("network", "network_error");
  60. }
  61. if (res.status === 401 && _refreshFn) {
  62. let newToken;
  63. try {
  64. newToken = await refreshAccessToken();
  65. } catch {
  66. _authErrorFn?.();
  67. throw new Error("session_expired");
  68. }
  69. if (newToken) {
  70. headers["Authorization"] = `Bearer ${newToken}`;
  71. try {
  72. res = await fetch(`${apiBaseUrl}${path}`, {
  73. method,
  74. headers,
  75. body: body ? JSON.stringify(body) : undefined
  76. });
  77. } catch {
  78. throw new ApiError("network", "network_error");
  79. }
  80. }
  81. }
  82. if (res.status === 401) {
  83. _authErrorFn?.();
  84. throw new Error("session_expired");
  85. }
  86. if (!res.ok) {
  87. const text = await res.text().catch(() => "");
  88. let payload = null;
  89. try { payload = JSON.parse(text); } catch {}
  90. throw new Error(extractErrorMessage(payload, res.status) || text || `http_error:${res.status}`);
  91. }
  92. const contentType = res.headers.get("content-type") ?? "";
  93. if (contentType.includes("application/json")) return res.json();
  94. return res.text();
  95. }