@allanfsouza/aether-sdk 2.4.7 → 2.4.10

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.
package/dist/database.js CHANGED
@@ -199,7 +199,6 @@ export class CollectionReference {
199
199
  console.warn("[SDK] Realtime falhou: Token ou ProjectId ausentes.");
200
200
  return () => { };
201
201
  }
202
- // URL correta de subscribe
203
202
  const url = `${this.wsUrl}/v1/db/subscribe/${this.collectionName}?token=${token}&projectId=${projectId}`;
204
203
  let ws = null;
205
204
  try {
@@ -207,7 +206,7 @@ export class CollectionReference {
207
206
  if (!ws)
208
207
  return () => { };
209
208
  ws.onopen = () => {
210
- // Conectado
209
+ console.log(`[SDK] Realtime conectado: ${this.collectionName}`);
211
210
  };
212
211
  ws.onmessage = (event) => {
213
212
  try {
@@ -215,16 +214,27 @@ export class CollectionReference {
215
214
  if (raw === "pong")
216
215
  return;
217
216
  const payload = JSON.parse(raw);
218
- callback(payload.action, payload.data);
217
+ console.log(`[SDK] Evento recebido:`, payload.action, payload.data?.id);
218
+ // [FIX] Mapeia 'insert' do Postgres para 'create' do SDK
219
+ let action = payload.action;
220
+ if (payload.action === 'insert') {
221
+ action = 'create';
222
+ }
223
+ callback(action, payload.data);
219
224
  }
220
225
  catch (e) {
221
- // Erro silencioso de parse
226
+ console.error('[SDK] Erro ao parsear evento:', e);
222
227
  }
223
228
  };
229
+ ws.onerror = (err) => {
230
+ console.error('[SDK] WebSocket erro:', err);
231
+ };
232
+ ws.onclose = () => {
233
+ console.log(`[SDK] Realtime desconectado: ${this.collectionName}`);
234
+ };
224
235
  // Heartbeat
225
236
  const pingInterval = setInterval(() => {
226
- // [CORREÇÃO] Adicionada verificação explicita 'ws &&' para evitar erro 'possibly null'
227
- if (ws && ws.readyState === 1) { // 1 = OPEN
237
+ if (ws && ws.readyState === 1) {
228
238
  ws.send("ping");
229
239
  }
230
240
  }, 30000);
@@ -1,9 +1,27 @@
1
- import { type AxiosInstance } from "axios";
1
+ import { type AxiosInstance, type AxiosError, type AxiosRequestConfig } from "axios";
2
2
  import type { PlataformaClient } from "./index.js";
3
+ export interface RetryConfig {
4
+ /** Número máximo de tentativas (padrão: 3) */
5
+ maxRetries: number;
6
+ /** Delay base em ms para exponential backoff (padrão: 1000) */
7
+ baseDelay: number;
8
+ /** Delay máximo em ms (padrão: 30000) */
9
+ maxDelay: number;
10
+ /** Status codes que devem ser retried (padrão: [408, 429, 500, 502, 503, 504]) */
11
+ retryableStatuses: number[];
12
+ /** Métodos HTTP que podem ser retried (padrão: GET, HEAD, OPTIONS, PUT, DELETE) */
13
+ retryableMethods: string[];
14
+ /** Habilita retry para erros de rede (padrão: true) */
15
+ retryOnNetworkError: boolean;
16
+ /** Callback chamado antes de cada retry */
17
+ onRetry?: (retryCount: number, error: AxiosError, config: AxiosRequestConfig) => void;
18
+ }
19
+ export declare const DEFAULT_RETRY_CONFIG: RetryConfig;
3
20
  /**
4
- * Cria uma instância do Axios pré-configurada.
5
- * - Injeta automaticamente headers de Auth e Projeto.
6
- * - Renova token automaticamente quando expira (401).
7
- * - Converte erros em AetherError.
21
+ * Cria uma instância do Axios pré-configurada com:
22
+ * - Injeção automática de headers (Auth, Project)
23
+ * - Refresh automático de token (401)
24
+ * - Retry automático com exponential backoff
25
+ * - Tratamento de erros padronizado
8
26
  */
9
- export declare function createHttpClient(client: PlataformaClient): AxiosInstance;
27
+ export declare function createHttpClient(client: PlataformaClient, retryConfig?: Partial<RetryConfig>): AxiosInstance;
@@ -1,13 +1,88 @@
1
1
  // src/http-client.ts
2
+ // [MELHORIA] SDK com Retry Automático e Exponential Backoff
3
+ // Data: 09/12/2025
2
4
  import axios from "axios";
3
5
  import { handleAxiosError } from "./errors.js";
4
- // Flag para evitar múltiplos refreshes simultâneos
5
- let isRefreshing = false;
6
- // Fila de requisições aguardando o refresh
7
- let failedQueue = [];
6
+ export const DEFAULT_RETRY_CONFIG = {
7
+ maxRetries: 3,
8
+ baseDelay: 1000,
9
+ maxDelay: 30000,
10
+ retryableStatuses: [408, 429, 500, 502, 503, 504],
11
+ retryableMethods: ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"],
12
+ retryOnNetworkError: true,
13
+ };
14
+ // =============================================================================
15
+ // HELPERS
16
+ // =============================================================================
17
+ /**
18
+ * Calcula o delay com exponential backoff + jitter
19
+ */
20
+ function calculateDelay(retryCount, config) {
21
+ // Exponential: baseDelay * 2^retryCount
22
+ const exponentialDelay = config.baseDelay * Math.pow(2, retryCount);
23
+ // Adiciona jitter (±25%) para evitar thundering herd
24
+ const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
25
+ // Limita ao maxDelay
26
+ return Math.min(exponentialDelay + jitter, config.maxDelay);
27
+ }
8
28
  /**
9
- * Processa a fila de requisições após o refresh.
29
+ * Verifica se o erro é retryable
10
30
  */
31
+ function isRetryable(error, config) {
32
+ // Erro de rede (sem response)
33
+ if (!error.response && config.retryOnNetworkError) {
34
+ // Verifica se é erro de rede real (não cancelamento)
35
+ if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" ||
36
+ error.code === "ENOTFOUND" || error.code === "ENETUNREACH" ||
37
+ error.message === "Network Error") {
38
+ return true;
39
+ }
40
+ }
41
+ // Verifica status code
42
+ const status = error.response?.status;
43
+ if (status && config.retryableStatuses.includes(status)) {
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+ /**
49
+ * Verifica se o método HTTP pode ser retried
50
+ */
51
+ function isMethodRetryable(method, config) {
52
+ if (!method)
53
+ return false;
54
+ return config.retryableMethods.includes(method.toUpperCase());
55
+ }
56
+ /**
57
+ * Extrai Retry-After header (em ms)
58
+ */
59
+ function getRetryAfterMs(error) {
60
+ const retryAfter = error.response?.headers?.["retry-after"];
61
+ if (!retryAfter)
62
+ return null;
63
+ // Se for número, é segundos
64
+ const seconds = parseInt(retryAfter, 10);
65
+ if (!isNaN(seconds)) {
66
+ return seconds * 1000;
67
+ }
68
+ // Se for data HTTP, calcula diferença
69
+ const date = new Date(retryAfter);
70
+ if (!isNaN(date.getTime())) {
71
+ return Math.max(0, date.getTime() - Date.now());
72
+ }
73
+ return null;
74
+ }
75
+ /**
76
+ * Aguarda um tempo determinado
77
+ */
78
+ function sleep(ms) {
79
+ return new Promise(resolve => setTimeout(resolve, ms));
80
+ }
81
+ // =============================================================================
82
+ // FLAGS GLOBAIS (Refresh Token)
83
+ // =============================================================================
84
+ let isRefreshing = false;
85
+ let failedQueue = [];
11
86
  const processQueue = (error, token = null) => {
12
87
  failedQueue.forEach((prom) => {
13
88
  if (error) {
@@ -19,54 +94,111 @@ const processQueue = (error, token = null) => {
19
94
  });
20
95
  failedQueue = [];
21
96
  };
97
+ // =============================================================================
98
+ // CRIAR HTTP CLIENT
99
+ // =============================================================================
22
100
  /**
23
- * Cria uma instância do Axios pré-configurada.
24
- * - Injeta automaticamente headers de Auth e Projeto.
25
- * - Renova token automaticamente quando expira (401).
26
- * - Converte erros em AetherError.
101
+ * Cria uma instância do Axios pré-configurada com:
102
+ * - Injeção automática de headers (Auth, Project)
103
+ * - Refresh automático de token (401)
104
+ * - Retry automático com exponential backoff
105
+ * - Tratamento de erros padronizado
27
106
  */
28
- export function createHttpClient(client) {
107
+ export function createHttpClient(client, retryConfig = {}) {
108
+ const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
29
109
  const http = axios.create({
30
110
  baseURL: `${client.apiUrl}/v1`,
111
+ timeout: 30000, // 30 segundos
31
112
  headers: {
32
113
  "Content-Type": "application/json",
33
114
  },
34
115
  });
35
116
  // ===========================================================================
36
117
  // INTERCEPTOR DE REQUEST
37
- // Injeta token e projectId em todas as requisições
118
+ // Injeta token, projectId e API Key em todas as requisições
38
119
  // ===========================================================================
39
- http.interceptors.request.use((config) => {
120
+ http.interceptors.request.use((reqConfig) => {
40
121
  const token = client.getToken();
41
122
  if (token) {
42
- config.headers.Authorization = `Bearer ${token}`;
123
+ reqConfig.headers.Authorization = `Bearer ${token}`;
43
124
  }
44
125
  if (client.projectId) {
45
- config.headers["X-Project-ID"] = client.projectId;
126
+ reqConfig.headers["X-Project-ID"] = client.projectId;
46
127
  }
47
- return config;
128
+ // [NOVO] Envia API Key para autenticação de operações de banco de dados
129
+ if (client.serviceApiKey) {
130
+ reqConfig.headers["X-API-Key"] = client.serviceApiKey;
131
+ }
132
+ // Inicializa contador de retry
133
+ if (reqConfig._retryCount === undefined) {
134
+ reqConfig._retryCount = 0;
135
+ }
136
+ return reqConfig;
48
137
  });
49
138
  // ===========================================================================
50
139
  // INTERCEPTOR DE RESPONSE
51
- // Detecta 401 e tenta refresh automático do token
140
+ // 1. Retry automático para erros de rede/servidor
141
+ // 2. Refresh automático de token (401)
52
142
  // ===========================================================================
53
143
  http.interceptors.response.use((response) => response, async (error) => {
54
144
  const originalRequest = error.config;
55
- // Verifica se é erro 401 (não autorizado) e não é retry
145
+ if (!originalRequest) {
146
+ return handleAxiosError(error);
147
+ }
148
+ // -----------------------------------------------------------------
149
+ // PARTE 1: RETRY AUTOMÁTICO (erros de rede e servidor)
150
+ // -----------------------------------------------------------------
151
+ const retryCount = originalRequest._retryCount || 0;
152
+ const canRetry = retryCount < config.maxRetries;
153
+ const isRetryableError = isRetryable(error, config);
154
+ const isRetryableMethod = isMethodRetryable(originalRequest.method, config);
155
+ // Não faz retry para 401 (tratado pelo refresh token)
156
+ const is401 = error.response?.status === 401;
157
+ if (canRetry && isRetryableError && isRetryableMethod && !is401) {
158
+ originalRequest._retryCount = retryCount + 1;
159
+ // Calcula delay (respeita Retry-After se presente)
160
+ const delay = getRetryAfterMs(error) || calculateDelay(retryCount, config);
161
+ // Callback de retry (para logging/métricas)
162
+ if (config.onRetry) {
163
+ config.onRetry(originalRequest._retryCount, error, originalRequest);
164
+ }
165
+ // Emite evento para monitoramento
166
+ if (typeof window !== "undefined") {
167
+ window.dispatchEvent(new CustomEvent("aether:retry", {
168
+ detail: {
169
+ attempt: originalRequest._retryCount,
170
+ maxRetries: config.maxRetries,
171
+ delay,
172
+ url: originalRequest.url,
173
+ method: originalRequest.method,
174
+ status: error.response?.status,
175
+ error: error.message,
176
+ }
177
+ }));
178
+ }
179
+ // Log em desenvolvimento
180
+ if (process.env.NODE_ENV === "development") {
181
+ console.log(`[Aether SDK] Retry ${originalRequest._retryCount}/${config.maxRetries}`, `em ${delay}ms para ${originalRequest.method} ${originalRequest.url}`, `(${error.response?.status || error.message})`);
182
+ }
183
+ // Aguarda e refaz a requisição
184
+ await sleep(delay);
185
+ return http(originalRequest);
186
+ }
187
+ // -----------------------------------------------------------------
188
+ // PARTE 2: REFRESH TOKEN (erro 401)
189
+ // -----------------------------------------------------------------
56
190
  const isUnauthorized = error.response?.status === 401;
57
- const isRetry = originalRequest?._retry;
58
- const isAuthRoute = originalRequest?.url?.includes("/auth/");
191
+ const isRetry = originalRequest._retry;
192
+ const isAuthRoute = originalRequest.url?.includes("/auth/");
59
193
  // Não tenta refresh se:
60
194
  // - Não é 401
61
- // - Já é um retry
195
+ // - Já é um retry de refresh
62
196
  // - É uma rota de auth (login, register, refresh)
63
- // - Não tem refresh token disponível
64
197
  if (!isUnauthorized || isRetry || isAuthRoute) {
65
198
  return handleAxiosError(error);
66
199
  }
67
200
  const refreshToken = client.getRefreshToken();
68
201
  if (!refreshToken) {
69
- // Sem refresh token, limpa sessão e propaga erro
70
202
  client.clearSession();
71
203
  return handleAxiosError(error);
72
204
  }
@@ -85,26 +217,20 @@ export function createHttpClient(client) {
85
217
  originalRequest._retry = true;
86
218
  isRefreshing = true;
87
219
  try {
88
- // Chama endpoint de refresh diretamente (sem interceptor)
89
220
  const { data } = await axios.post(`${client.apiUrl}/v1/auth/refresh`, { refreshToken }, { headers: { "Content-Type": "application/json" } });
90
221
  const newAccessToken = data.accessToken;
91
222
  const newRefreshToken = data.refreshToken;
92
- // Atualiza tokens no client (persiste no localStorage)
93
223
  client.setToken(newAccessToken);
94
224
  if (newRefreshToken) {
95
225
  client.setRefreshToken(newRefreshToken);
96
226
  }
97
- // Processa fila de requisições pendentes
98
227
  processQueue(null, newAccessToken);
99
- // Refaz a requisição original com novo token
100
228
  originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
101
229
  return http(originalRequest);
102
230
  }
103
231
  catch (refreshError) {
104
- // Refresh falhou - sessão expirada
105
232
  processQueue(refreshError, null);
106
233
  client.clearSession();
107
- // Emite evento de sessão expirada (se houver listener)
108
234
  if (typeof window !== "undefined") {
109
235
  window.dispatchEvent(new CustomEvent("aether:session-expired"));
110
236
  }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,11 @@ export type ClientConfig = {
13
13
  baseUrl?: string;
14
14
  projectId?: string;
15
15
  apiKey?: string;
16
+ /**
17
+ * [NOVO] API Key de serviço para autenticação em operações de banco de dados.
18
+ * Necessário para apps client-side que usam tenant auth.
19
+ */
20
+ serviceApiKey?: string;
16
21
  /**
17
22
  * Habilita persistência automática de sessão no localStorage.
18
23
  * Padrão: true em browsers, false em Node.js/SSR.
@@ -29,6 +34,7 @@ export declare class PlataformaClient {
29
34
  database: DatabaseModule;
30
35
  apiUrl: string;
31
36
  projectId: string;
37
+ serviceApiKey: string | null;
32
38
  http: AxiosInstance;
33
39
  private _token;
34
40
  private _persistSession;
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ function isBrowser() {
24
24
  }
25
25
  export class PlataformaClient {
26
26
  constructor(config) {
27
+ this.serviceApiKey = null;
27
28
  this._token = null;
28
29
  // Resolve URL (prioridade para baseUrl se existir, senão apiUrl)
29
30
  const url = config.baseUrl || config.apiUrl;
@@ -33,6 +34,7 @@ export class PlataformaClient {
33
34
  }
34
35
  this.apiUrl = url.replace(/\/+$/, "");
35
36
  this.projectId = project;
37
+ this.serviceApiKey = config.serviceApiKey || null;
36
38
  // Persistência habilitada por padrão apenas em browsers
37
39
  this._persistSession = config.persistSession ?? isBrowser();
38
40
  // Restaura sessão salva ANTES de criar o httpClient
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allanfsouza/aether-sdk",
3
- "version": "2.4.7",
3
+ "version": "2.4.10",
4
4
  "description": "SDK do Cliente para a Plataforma Aether",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/database.ts CHANGED
@@ -285,7 +285,6 @@ export class CollectionReference<T> {
285
285
  return () => { };
286
286
  }
287
287
 
288
- // URL correta de subscribe
289
288
  const url = `${this.wsUrl}/v1/db/subscribe/${this.collectionName}?token=${token}&projectId=${projectId}`;
290
289
 
291
290
  let ws: WebSocket | null = null;
@@ -296,7 +295,7 @@ export class CollectionReference<T> {
296
295
  if (!ws) return () => { };
297
296
 
298
297
  ws.onopen = () => {
299
- // Conectado
298
+ console.log(`[SDK] Realtime conectado: ${this.collectionName}`);
300
299
  };
301
300
 
302
301
  ws.onmessage = (event: any) => {
@@ -304,17 +303,32 @@ export class CollectionReference<T> {
304
303
  const raw = event.data?.toString() || event.toString();
305
304
  if (raw === "pong") return;
306
305
 
307
- const payload = JSON.parse(raw) as WebSocketMessage<T>;
308
- callback(payload.action, payload.data);
306
+ const payload = JSON.parse(raw);
307
+ console.log(`[SDK] Evento recebido:`, payload.action, payload.data?.id);
308
+
309
+ // [FIX] Mapeia 'insert' do Postgres para 'create' do SDK
310
+ let action: "create" | "update" | "delete" = payload.action;
311
+ if (payload.action === 'insert') {
312
+ action = 'create';
313
+ }
314
+
315
+ callback(action, payload.data);
309
316
  } catch (e) {
310
- // Erro silencioso de parse
317
+ console.error('[SDK] Erro ao parsear evento:', e);
311
318
  }
312
319
  };
313
320
 
321
+ ws.onerror = (err) => {
322
+ console.error('[SDK] WebSocket erro:', err);
323
+ };
324
+
325
+ ws.onclose = () => {
326
+ console.log(`[SDK] Realtime desconectado: ${this.collectionName}`);
327
+ };
328
+
314
329
  // Heartbeat
315
330
  const pingInterval = setInterval(() => {
316
- // [CORREÇÃO] Adicionada verificação explicita 'ws &&' para evitar erro 'possibly null'
317
- if (ws && ws.readyState === 1) { // 1 = OPEN
331
+ if (ws && ws.readyState === 1) {
318
332
  ws.send("ping");
319
333
  }
320
334
  }, 30000);
@@ -1,24 +1,135 @@
1
1
  // src/http-client.ts
2
+ // [MELHORIA] SDK com Retry Automático e Exponential Backoff
3
+ // Data: 09/12/2025
4
+
2
5
  import axios, {
3
6
  type AxiosInstance,
4
7
  type AxiosError,
8
+ type AxiosRequestConfig,
5
9
  type InternalAxiosRequestConfig,
6
10
  } from "axios";
7
11
  import type { PlataformaClient } from "./index.js";
8
12
  import { handleAxiosError } from "./errors.js";
9
13
 
10
- // Flag para evitar múltiplos refreshes simultâneos
11
- let isRefreshing = false;
14
+ // =============================================================================
15
+ // CONFIGURAÇÕES DE RETRY
16
+ // =============================================================================
17
+
18
+ export interface RetryConfig {
19
+ /** Número máximo de tentativas (padrão: 3) */
20
+ maxRetries: number;
21
+ /** Delay base em ms para exponential backoff (padrão: 1000) */
22
+ baseDelay: number;
23
+ /** Delay máximo em ms (padrão: 30000) */
24
+ maxDelay: number;
25
+ /** Status codes que devem ser retried (padrão: [408, 429, 500, 502, 503, 504]) */
26
+ retryableStatuses: number[];
27
+ /** Métodos HTTP que podem ser retried (padrão: GET, HEAD, OPTIONS, PUT, DELETE) */
28
+ retryableMethods: string[];
29
+ /** Habilita retry para erros de rede (padrão: true) */
30
+ retryOnNetworkError: boolean;
31
+ /** Callback chamado antes de cada retry */
32
+ onRetry?: (retryCount: number, error: AxiosError, config: AxiosRequestConfig) => void;
33
+ }
34
+
35
+ export const DEFAULT_RETRY_CONFIG: RetryConfig = {
36
+ maxRetries: 3,
37
+ baseDelay: 1000,
38
+ maxDelay: 30000,
39
+ retryableStatuses: [408, 429, 500, 502, 503, 504],
40
+ retryableMethods: ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"],
41
+ retryOnNetworkError: true,
42
+ };
43
+
44
+ // =============================================================================
45
+ // HELPERS
46
+ // =============================================================================
47
+
48
+ /**
49
+ * Calcula o delay com exponential backoff + jitter
50
+ */
51
+ function calculateDelay(retryCount: number, config: RetryConfig): number {
52
+ // Exponential: baseDelay * 2^retryCount
53
+ const exponentialDelay = config.baseDelay * Math.pow(2, retryCount);
54
+
55
+ // Adiciona jitter (±25%) para evitar thundering herd
56
+ const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
57
+
58
+ // Limita ao maxDelay
59
+ return Math.min(exponentialDelay + jitter, config.maxDelay);
60
+ }
61
+
62
+ /**
63
+ * Verifica se o erro é retryable
64
+ */
65
+ function isRetryable(error: AxiosError, config: RetryConfig): boolean {
66
+ // Erro de rede (sem response)
67
+ if (!error.response && config.retryOnNetworkError) {
68
+ // Verifica se é erro de rede real (não cancelamento)
69
+ if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" ||
70
+ error.code === "ENOTFOUND" || error.code === "ENETUNREACH" ||
71
+ error.message === "Network Error") {
72
+ return true;
73
+ }
74
+ }
75
+
76
+ // Verifica status code
77
+ const status = error.response?.status;
78
+ if (status && config.retryableStatuses.includes(status)) {
79
+ return true;
80
+ }
81
+
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Verifica se o método HTTP pode ser retried
87
+ */
88
+ function isMethodRetryable(method: string | undefined, config: RetryConfig): boolean {
89
+ if (!method) return false;
90
+ return config.retryableMethods.includes(method.toUpperCase());
91
+ }
92
+
93
+ /**
94
+ * Extrai Retry-After header (em ms)
95
+ */
96
+ function getRetryAfterMs(error: AxiosError): number | null {
97
+ const retryAfter = error.response?.headers?.["retry-after"];
98
+
99
+ if (!retryAfter) return null;
100
+
101
+ // Se for número, é segundos
102
+ const seconds = parseInt(retryAfter, 10);
103
+ if (!isNaN(seconds)) {
104
+ return seconds * 1000;
105
+ }
106
+
107
+ // Se for data HTTP, calcula diferença
108
+ const date = new Date(retryAfter);
109
+ if (!isNaN(date.getTime())) {
110
+ return Math.max(0, date.getTime() - Date.now());
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Aguarda um tempo determinado
118
+ */
119
+ function sleep(ms: number): Promise<void> {
120
+ return new Promise(resolve => setTimeout(resolve, ms));
121
+ }
12
122
 
13
- // Fila de requisições aguardando o refresh
123
+ // =============================================================================
124
+ // FLAGS GLOBAIS (Refresh Token)
125
+ // =============================================================================
126
+
127
+ let isRefreshing = false;
14
128
  let failedQueue: Array<{
15
129
  resolve: (token: string) => void;
16
130
  reject: (error: any) => void;
17
131
  }> = [];
18
132
 
19
- /**
20
- * Processa a fila de requisições após o refresh.
21
- */
22
133
  const processQueue = (error: any, token: string | null = null) => {
23
134
  failedQueue.forEach((prom) => {
24
135
  if (error) {
@@ -30,15 +141,35 @@ const processQueue = (error: any, token: string | null = null) => {
30
141
  failedQueue = [];
31
142
  };
32
143
 
144
+ // =============================================================================
145
+ // EXTENDED CONFIG TYPE
146
+ // =============================================================================
147
+
148
+ interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig {
149
+ _retry?: boolean;
150
+ _retryCount?: number;
151
+ }
152
+
153
+ // =============================================================================
154
+ // CRIAR HTTP CLIENT
155
+ // =============================================================================
156
+
33
157
  /**
34
- * Cria uma instância do Axios pré-configurada.
35
- * - Injeta automaticamente headers de Auth e Projeto.
36
- * - Renova token automaticamente quando expira (401).
37
- * - Converte erros em AetherError.
158
+ * Cria uma instância do Axios pré-configurada com:
159
+ * - Injeção automática de headers (Auth, Project)
160
+ * - Refresh automático de token (401)
161
+ * - Retry automático com exponential backoff
162
+ * - Tratamento de erros padronizado
38
163
  */
39
- export function createHttpClient(client: PlataformaClient): AxiosInstance {
164
+ export function createHttpClient(
165
+ client: PlataformaClient,
166
+ retryConfig: Partial<RetryConfig> = {}
167
+ ): AxiosInstance {
168
+ const config: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
169
+
40
170
  const http = axios.create({
41
171
  baseURL: `${client.apiUrl}/v1`,
172
+ timeout: 30000, // 30 segundos
42
173
  headers: {
43
174
  "Content-Type": "application/json",
44
175
  },
@@ -46,49 +177,113 @@ export function createHttpClient(client: PlataformaClient): AxiosInstance {
46
177
 
47
178
  // ===========================================================================
48
179
  // INTERCEPTOR DE REQUEST
49
- // Injeta token e projectId em todas as requisições
180
+ // Injeta token, projectId e API Key em todas as requisições
50
181
  // ===========================================================================
51
- http.interceptors.request.use((config) => {
182
+ http.interceptors.request.use((reqConfig) => {
52
183
  const token = client.getToken();
53
184
  if (token) {
54
- config.headers.Authorization = `Bearer ${token}`;
185
+ reqConfig.headers.Authorization = `Bearer ${token}`;
55
186
  }
56
187
 
57
188
  if (client.projectId) {
58
- config.headers["X-Project-ID"] = client.projectId;
189
+ reqConfig.headers["X-Project-ID"] = client.projectId;
59
190
  }
60
191
 
61
- return config;
192
+ // [NOVO] Envia API Key para autenticação de operações de banco de dados
193
+ if (client.serviceApiKey) {
194
+ reqConfig.headers["X-API-Key"] = client.serviceApiKey;
195
+ }
196
+
197
+ // Inicializa contador de retry
198
+ if ((reqConfig as ExtendedAxiosRequestConfig)._retryCount === undefined) {
199
+ (reqConfig as ExtendedAxiosRequestConfig)._retryCount = 0;
200
+ }
201
+
202
+ return reqConfig;
62
203
  });
63
204
 
64
205
  // ===========================================================================
65
206
  // INTERCEPTOR DE RESPONSE
66
- // Detecta 401 e tenta refresh automático do token
207
+ // 1. Retry automático para erros de rede/servidor
208
+ // 2. Refresh automático de token (401)
67
209
  // ===========================================================================
68
210
  http.interceptors.response.use(
69
211
  (response) => response,
70
212
  async (error: AxiosError) => {
71
- const originalRequest = error.config as InternalAxiosRequestConfig & {
72
- _retry?: boolean;
73
- };
213
+ const originalRequest = error.config as ExtendedAxiosRequestConfig;
214
+
215
+ if (!originalRequest) {
216
+ return handleAxiosError(error);
217
+ }
218
+
219
+ // -----------------------------------------------------------------
220
+ // PARTE 1: RETRY AUTOMÁTICO (erros de rede e servidor)
221
+ // -----------------------------------------------------------------
222
+ const retryCount = originalRequest._retryCount || 0;
223
+ const canRetry = retryCount < config.maxRetries;
224
+ const isRetryableError = isRetryable(error, config);
225
+ const isRetryableMethod = isMethodRetryable(originalRequest.method, config);
226
+
227
+ // Não faz retry para 401 (tratado pelo refresh token)
228
+ const is401 = error.response?.status === 401;
229
+
230
+ if (canRetry && isRetryableError && isRetryableMethod && !is401) {
231
+ originalRequest._retryCount = retryCount + 1;
232
+
233
+ // Calcula delay (respeita Retry-After se presente)
234
+ const delay = getRetryAfterMs(error) || calculateDelay(retryCount, config);
235
+
236
+ // Callback de retry (para logging/métricas)
237
+ if (config.onRetry) {
238
+ config.onRetry(originalRequest._retryCount, error, originalRequest);
239
+ }
240
+
241
+ // Emite evento para monitoramento
242
+ if (typeof window !== "undefined") {
243
+ window.dispatchEvent(new CustomEvent("aether:retry", {
244
+ detail: {
245
+ attempt: originalRequest._retryCount,
246
+ maxRetries: config.maxRetries,
247
+ delay,
248
+ url: originalRequest.url,
249
+ method: originalRequest.method,
250
+ status: error.response?.status,
251
+ error: error.message,
252
+ }
253
+ }));
254
+ }
255
+
256
+ // Log em desenvolvimento
257
+ if (process.env.NODE_ENV === "development") {
258
+ console.log(
259
+ `[Aether SDK] Retry ${originalRequest._retryCount}/${config.maxRetries}`,
260
+ `em ${delay}ms para ${originalRequest.method} ${originalRequest.url}`,
261
+ `(${error.response?.status || error.message})`
262
+ );
263
+ }
264
+
265
+ // Aguarda e refaz a requisição
266
+ await sleep(delay);
267
+ return http(originalRequest);
268
+ }
74
269
 
75
- // Verifica se é erro 401 (não autorizado) e não é retry
270
+ // -----------------------------------------------------------------
271
+ // PARTE 2: REFRESH TOKEN (erro 401)
272
+ // -----------------------------------------------------------------
76
273
  const isUnauthorized = error.response?.status === 401;
77
- const isRetry = originalRequest?._retry;
78
- const isAuthRoute = originalRequest?.url?.includes("/auth/");
274
+ const isRetry = originalRequest._retry;
275
+ const isAuthRoute = originalRequest.url?.includes("/auth/");
79
276
 
80
277
  // Não tenta refresh se:
81
278
  // - Não é 401
82
- // - Já é um retry
279
+ // - Já é um retry de refresh
83
280
  // - É uma rota de auth (login, register, refresh)
84
- // - Não tem refresh token disponível
85
281
  if (!isUnauthorized || isRetry || isAuthRoute) {
86
282
  return handleAxiosError(error);
87
283
  }
88
284
 
89
285
  const refreshToken = client.getRefreshToken();
90
286
  if (!refreshToken) {
91
- // Sem refresh token, limpa sessão e propaga erro
92
287
  client.clearSession();
93
288
  return handleAxiosError(error);
94
289
  }
@@ -110,7 +305,6 @@ export function createHttpClient(client: PlataformaClient): AxiosInstance {
110
305
  isRefreshing = true;
111
306
 
112
307
  try {
113
- // Chama endpoint de refresh diretamente (sem interceptor)
114
308
  const { data } = await axios.post(
115
309
  `${client.apiUrl}/v1/auth/refresh`,
116
310
  { refreshToken },
@@ -120,24 +314,19 @@ export function createHttpClient(client: PlataformaClient): AxiosInstance {
120
314
  const newAccessToken = data.accessToken;
121
315
  const newRefreshToken = data.refreshToken;
122
316
 
123
- // Atualiza tokens no client (persiste no localStorage)
124
317
  client.setToken(newAccessToken);
125
318
  if (newRefreshToken) {
126
319
  client.setRefreshToken(newRefreshToken);
127
320
  }
128
321
 
129
- // Processa fila de requisições pendentes
130
322
  processQueue(null, newAccessToken);
131
323
 
132
- // Refaz a requisição original com novo token
133
324
  originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
134
325
  return http(originalRequest);
135
326
  } catch (refreshError: any) {
136
- // Refresh falhou - sessão expirada
137
327
  processQueue(refreshError, null);
138
328
  client.clearSession();
139
329
 
140
- // Emite evento de sessão expirada (se houver listener)
141
330
  if (typeof window !== "undefined") {
142
331
  window.dispatchEvent(new CustomEvent("aether:session-expired"));
143
332
  }
package/src/index.ts CHANGED
@@ -28,6 +28,12 @@ export type ClientConfig = {
28
28
  projectId?: string;
29
29
  apiKey?: string;
30
30
 
31
+ /**
32
+ * [NOVO] API Key de serviço para autenticação em operações de banco de dados.
33
+ * Necessário para apps client-side que usam tenant auth.
34
+ */
35
+ serviceApiKey?: string;
36
+
31
37
  /**
32
38
  * Habilita persistência automática de sessão no localStorage.
33
39
  * Padrão: true em browsers, false em Node.js/SSR.
@@ -59,6 +65,7 @@ export class PlataformaClient {
59
65
 
60
66
  public apiUrl: string;
61
67
  public projectId: string;
68
+ public serviceApiKey: string | null = null;
62
69
 
63
70
  public http: AxiosInstance;
64
71
 
@@ -76,6 +83,7 @@ export class PlataformaClient {
76
83
 
77
84
  this.apiUrl = url.replace(/\/+$/, "");
78
85
  this.projectId = project;
86
+ this.serviceApiKey = config.serviceApiKey || null;
79
87
 
80
88
  // Persistência habilitada por padrão apenas em browsers
81
89
  this._persistSession = config.persistSession ?? isBrowser();