@communecter/cocolight-api-client 1.0.15 → 1.0.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@communecter/cocolight-api-client",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Client Axios simplifié pour l'API cocolight",
5
5
  "repository": {
6
6
  "type": "git",
package/src/Api.js CHANGED
@@ -13,7 +13,7 @@ export default class Api {
13
13
  *
14
14
  * @param {string} email - L'adresse email.
15
15
  * @param {string} password - Le mot de passe.
16
- * @param {Object} options - Options pour l'ApiClient (baseURL, debug, etc.)
16
+ * @param {function|object} options - Options pour l'ApiClient (baseURL, debug, etc.) ou une instance d'ApiClient.
17
17
  * @returns {Promise<Api>}
18
18
  */
19
19
  static async userLogin(email, password, options) {
@@ -24,7 +24,7 @@ export default class Api {
24
24
  /**
25
25
  * Creates an instance of the UserApi class with the provided options.
26
26
  *
27
- * @param {Object} options - Configuration options for initializing the UserApi instance.
27
+ * @param @param {function|object} options - Options for the ApiClient (baseURL, debug, etc.) or an instance of ApiClient.
28
28
  * @returns {UserApi} An instance of the UserApi class.
29
29
  * @throws {Error} Throws an error if the UserApi instance cannot be created.
30
30
  */
package/src/ApiClient.js CHANGED
@@ -14,10 +14,28 @@ import endpointsJson from "./endpoints.module.js";
14
14
  import { ApiClientError, ApiResponseError, ApiValidationError, CircuitBreakerError } from "./error.js";
15
15
  import { MemoryStorageStrategy } from "./utils/TokenStorage.js";
16
16
 
17
+
17
18
  EJSON.addType("oid", value => {
18
19
  return new MongoID.ObjectID(value);
19
20
  });
20
21
 
22
+ /**
23
+ * Client générique pour consommer une API REST avec validation AJV, gestion des tokens,
24
+ * circuit breaker, retry automatique, et support offline.
25
+ *
26
+ * @extends EventEmitter
27
+ *
28
+ * @fires ApiClient#retryAttempt
29
+ * @fires ApiClient#queuedOffline
30
+ * @fires ApiClient#circuitBreakerOpen
31
+ * @fires ApiClient#circuitBreakerReset
32
+ * @fires ApiClient#refreshSuccess
33
+ * @fires ApiClient#refreshFailed
34
+ * @fires ApiClient#sessionReset
35
+ * @fires ApiClient#validationError
36
+ * @fires ApiClient#offlineModeChanged
37
+ * @fires ApiClient#userLoggedIn
38
+ */
21
39
  export default class ApiClient extends EventEmitter {
22
40
  /**
23
41
  * @param {Object} options
@@ -61,6 +79,7 @@ export default class ApiClient extends EventEmitter {
61
79
  this._endpoints = endpoints;
62
80
  this._debug = debug;
63
81
  let _userId = null;
82
+ this._offlineClientManager = null;
64
83
 
65
84
  // Active la transformation des données en EJSON globalement
66
85
  this._fromJSONValue = fromJSONValue;
@@ -112,6 +131,10 @@ export default class ApiClient extends EventEmitter {
112
131
  retryCondition: (error) => {
113
132
  // Retry sur erreurs 5xx ou erreurs réseau
114
133
  return axiosRetry.isNetworkError(error) || axiosRetry.isRetryableError(error);
134
+ },
135
+ onRetry: (retryCount, error, requestConfig) => {
136
+ this._logger.warn(`[Retry] Tentative #${retryCount} pour ${requestConfig?.url}`);
137
+ this.emit("retryAttempt", { retryCount, url: requestConfig?.url });
115
138
  }
116
139
  });
117
140
  this._logger.info(`[ApiClient] Retry activé : ${maxRetries} max`);
@@ -127,6 +150,18 @@ export default class ApiClient extends EventEmitter {
127
150
  this._accessToken = null;
128
151
  this._refreshToken = null;
129
152
 
153
+ this._tokenStorage = tokenStorageStrategy || new MemoryStorageStrategy();
154
+
155
+ if (
156
+ typeof this._tokenStorage?.getAccessToken !== "function" ||
157
+ typeof this._tokenStorage?.setAccessToken !== "function" ||
158
+ typeof this._tokenStorage?.getRefreshToken !== "function" ||
159
+ typeof this._tokenStorage?.setRefreshToken !== "function" ||
160
+ typeof this._tokenStorage?.clear !== "function"
161
+ ) {
162
+ throw new Error("[ApiClient] La stratégie de stockage des tokens n’est pas valide. Elle doit implémenter les méthodes requises.");
163
+ }
164
+
130
165
  // Applique un token initial s'il est fourni
131
166
  if(refreshToken){
132
167
  this.setRefreshToken(refreshToken);
@@ -136,8 +171,6 @@ export default class ApiClient extends EventEmitter {
136
171
  this.setToken(accessToken);
137
172
  }
138
173
 
139
- this._tokenStorage = tokenStorageStrategy || new MemoryStorageStrategy();
140
-
141
174
  const initialAccessToken = this._tokenStorage.getAccessToken();
142
175
  if (initialAccessToken) {
143
176
  this.setToken(initialAccessToken);
@@ -190,7 +223,6 @@ export default class ApiClient extends EventEmitter {
190
223
  }
191
224
  );
192
225
 
193
-
194
226
  }
195
227
 
196
228
  /**
@@ -306,6 +338,7 @@ export default class ApiClient extends EventEmitter {
306
338
  return false;
307
339
  } catch (err) {
308
340
  // Si on a une erreur, on reset la session
341
+ this.emit("refreshFailed", { error: err.message });
309
342
  this.resetSession();
310
343
  this._logger.error(`[ApiClient] Refresh Error : ${err.message}`);
311
344
  return false;
@@ -324,6 +357,7 @@ export default class ApiClient extends EventEmitter {
324
357
  this._breakerOpen = false;
325
358
  this._breakerErrorCount = 0;
326
359
  this._logger.warn("[ApiClient] Circuit breaker réinitialisé");
360
+ this.emit("circuitBreakerReset");
327
361
  return true;
328
362
  }
329
363
  return false;
@@ -340,6 +374,7 @@ export default class ApiClient extends EventEmitter {
340
374
  this._breakerOpen = true;
341
375
  this._lastBreakerOpenTime = Date.now();
342
376
  this._logger.error("[ApiClient] Circuit breaker ACTIVÉ - L'API est considérée indisponible");
377
+ this.emit("circuitBreakerOpen", { timestamp: this._lastBreakerOpenTime });
343
378
  }
344
379
  }
345
380
 
@@ -563,10 +598,7 @@ export default class ApiClient extends EventEmitter {
563
598
  * @throws {ApiClientError} If the endpoint is not found, token is required but not provided, or validation fails.
564
599
  */
565
600
  async callEndpoint(constant, data = {}, transformResponseData = true, validateResponseSchema = true) {
566
- if (!this._checkCircuitBreaker()) {
567
- throw new CircuitBreakerError("Le circuit breaker est activé, impossible d'appeler l'API");
568
- }
569
-
601
+
570
602
  const endpoint = this._endpoints.find((ep) => ep.constant === constant);
571
603
  if (!endpoint) {
572
604
  throw new ApiClientError(`Endpoint introuvable : ${constant}`, 404);
@@ -652,7 +684,59 @@ export default class ApiClient extends EventEmitter {
652
684
  data = this._resolveSpecialValuesInPlace(cleanedData, resolvedParams);
653
685
  }
654
686
 
655
- // === 3. Payload ===
687
+ // === 3. ENQUEUE SI OFFLINE (après validations, avant le breaker)
688
+ if (this._offlineClientManager?.isOffline()) {
689
+ if (typeof this._offlineClientManager._enqueueOfflineAction === "function") {
690
+ this._logger.warn(`[ApiClient] Mode dégradé : mise en file de ${constant}`);
691
+ await this._offlineClientManager._enqueueOfflineAction({
692
+ constant,
693
+ data,
694
+ options: { transformResponseData, validateResponseSchema }
695
+ });
696
+
697
+ this._logger.info(`[ApiClient] Requête ${constant} mise en file (offline mode)`);
698
+
699
+ this.emit("queuedOffline", {
700
+ constant,
701
+ reason: "offlineMode",
702
+ data
703
+ });
704
+
705
+ return { data: null, offline: true };
706
+ } else {
707
+ this._logger.warn("[ApiClient] Mode dégradé actif mais offlineManager non initialisé correctement");
708
+ throw new ApiClientError("Mode hors-ligne actif, mais gestionnaire offline non disponible.", 503);
709
+ }
710
+ }
711
+
712
+ // === 4. Circuit Breaker
713
+ if (!this._checkCircuitBreaker()) {
714
+ if (
715
+ this._offlineClientManager?.isOffline?.() === false && // pas déjà en mode offline
716
+ typeof this._offlineClientManager._enqueueOfflineAction === "function"
717
+ ) {
718
+ this._logger.warn(`[ApiClient] Circuit breaker actif → mise en file de ${constant}`);
719
+ await this._offlineClientManager._enqueueOfflineAction({
720
+ constant,
721
+ data,
722
+ options: { transformResponseData, validateResponseSchema }
723
+ });
724
+
725
+ this._logger.info(`[ApiClient] Requête ${constant} mise en file (circuit breaker)`);
726
+
727
+ this.emit("queuedOffline", {
728
+ constant,
729
+ reason: "circuitBreaker",
730
+ data
731
+ });
732
+
733
+ return { data: null, breaker: true };
734
+ }
735
+
736
+ throw new CircuitBreakerError("Le circuit breaker est activé, impossible d'appeler l'API");
737
+ }
738
+
739
+ // === 5. Payload ===
656
740
  let payload;
657
741
  if (realContentType === "application/json" || realContentType === "multipart/form-data") {
658
742
  payload = data;
@@ -1444,4 +1528,103 @@ export default class ApiClient extends EventEmitter {
1444
1528
  return this._endpoints.find(e => e.constant === constant)?.pathParams || null;
1445
1529
  }
1446
1530
 
1531
+ /**
1532
+ * Permet d'écouter facilement un ensemble d'événements importants émis par l'ApiClient.
1533
+ *
1534
+ * @param {Object} handlers - Un objet avec des fonctions à appeler selon l'événement.
1535
+ * @param {Function} [handlers.retryAttempt] - Lors d'une tentative de retry axios.
1536
+ * @param {Function} [handlers.queuedOffline] - Lorsqu'une requête est mise en file.
1537
+ * @param {Function} [handlers.circuitBreakerOpen] - Quand le breaker s'ouvre.
1538
+ * @param {Function} [handlers.circuitBreakerReset] - Quand le breaker se referme.
1539
+ * @param {Function} [handlers.refreshSuccess] - Quand le token est rafraîchi.
1540
+ * @param {Function} [handlers.refreshFailed] - Quand le refresh échoue.
1541
+ * @param {Function} [handlers.sessionReset] - Quand la session est réinitialisée.
1542
+ * @param {Function} [handlers.validationError] - Quand une validation échoue.
1543
+ * @param {Function} [handlers.offlineModeChanged] - Quand le mode offline change.
1544
+ * @param {Function} [handlers.userLoggedIn] - Quand un utilisateur se connecte.
1545
+ */
1546
+ onEvent(handlers = {}) {
1547
+ const availableEvents = [
1548
+ "retryAttempt",
1549
+ "queuedOffline",
1550
+ "circuitBreakerOpen",
1551
+ "circuitBreakerReset",
1552
+ "refreshSuccess",
1553
+ "refreshFailed",
1554
+ "sessionReset",
1555
+ "validationError",
1556
+ "offlineModeChanged",
1557
+ "userLoggedIn"
1558
+ ];
1559
+
1560
+ for (const eventName of availableEvents) {
1561
+ if (typeof handlers[eventName] === "function") {
1562
+ this.on(eventName, handlers[eventName]);
1563
+ }
1564
+ }
1565
+ }
1566
+
1567
+ /**
1568
+ * Retourne la liste des noms d'événements personnalisés déclarés dans les endpoints.
1569
+ * Utile pour introspection ou documentation.
1570
+ *
1571
+ * @returns {string[]} Liste des événements émis dynamiquement via postActions.
1572
+ */
1573
+ getDeclaredEvents() {
1574
+ const events = new Set();
1575
+ for (const endpoint of this._endpoints) {
1576
+ if (Array.isArray(endpoint.postActions)) {
1577
+ for (const action of endpoint.postActions) {
1578
+ if (action.type === "emitEvent" && action.event) {
1579
+ events.add(action.event);
1580
+ }
1581
+ }
1582
+ }
1583
+ }
1584
+ return Array.from(events);
1585
+ }
1586
+
1447
1587
  }
1588
+ /**
1589
+ * @event ApiClient#retryAttempt
1590
+ * @type {Object}
1591
+ * @property {number} retryCount - Le numéro de tentative.
1592
+ * @property {string} url - L'URL de la requête ayant échoué.
1593
+
1594
+ * @event ApiClient#queuedOffline
1595
+ * @type {Object}
1596
+ * @property {string} constant - Le nom de l'endpoint.
1597
+ * @property {Object} data - Les données envoyées.
1598
+ * @property {"offlineMode"|"circuitBreaker"} reason - La raison de la mise en file.
1599
+
1600
+ * @event ApiClient#circuitBreakerOpen
1601
+ * @type {Object}
1602
+ * @property {number} timestamp - Date (ms) à laquelle le breaker s'est ouvert.
1603
+
1604
+ * @event ApiClient#circuitBreakerReset
1605
+ * @type {void}
1606
+
1607
+ * @event ApiClient#refreshSuccess
1608
+ * @type {Object}
1609
+ * @property {string} token - Le nouveau token d'accès.
1610
+ * @property {string} [refreshToken] - Le nouveau refreshToken (optionnel).
1611
+
1612
+ * @event ApiClient#refreshFailed
1613
+ * @type {Object}
1614
+ * @property {string} error - Le message d’erreur de refresh.
1615
+
1616
+ * @event ApiClient#sessionReset
1617
+ * @type {void}
1618
+
1619
+ * @event ApiClient#validationError
1620
+ * @type {Object}
1621
+ * @property {"pathParams"|"request"|"response"} stage - Étape de validation échouée.
1622
+ * @property {Array<Object>} errors - Erreurs AJV brutes.
1623
+
1624
+ * @event ApiClient#offlineModeChanged
1625
+ * @type {boolean} - true si le client est offline, false sinon.
1626
+ *
1627
+ * @event ApiClient#userLoggedIn
1628
+ * @param {Object} user - Les données utilisateur extraites de la réponse.
1629
+ */
1630
+
@@ -8,10 +8,16 @@ import { Project } from "./Project.js";
8
8
  import { User } from "./User.js";
9
9
 
10
10
  export class UserApi {
11
- constructor(options) {
12
- // Injection de dépendance : ApiClient est créé à partir des options
13
- this.client = new ApiClient(options);
14
- // si l'option "tokenStorageStrategy" est définie, on l'utilise pour créer une instance de ApiClient
11
+
12
+ constructor(clientOrOptions) {
13
+ if (clientOrOptions instanceof ApiClient) {
14
+ this.client = clientOrOptions;
15
+ } else {
16
+ // Injection de dépendance : ApiClient est créé à partir des options
17
+ this.client = new ApiClient(clientOrOptions);
18
+ // si l'option "tokenStorageStrategy" est définie, on l'utilise pour créer une instance de ApiClient
19
+ }
20
+
15
21
  this.loggedUser = null;
16
22
  }
17
23
 
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ import Api from "./Api.js";
2
2
  import ApiClient from "./ApiClient.js";
3
3
  import * as error from "./error.js";
4
4
  import { createDefaultTokenStorageStrategy } from "./utils/createDefaultTokenStorageStrategy.js";
5
+ import OfflineClientManager from "./utils/OfflineClientManager.js";
5
6
  import { TokenStorageStrategy } from "./utils/TokenStorage.js";
6
7
 
7
- export default { ApiClient, Api, error, tokenStorageStrategy: { createDefaultTokenStorageStrategy, TokenStorageStrategy } };
8
+ export default { ApiClient, Api, error, tokenStorageStrategy: { createDefaultTokenStorageStrategy, TokenStorageStrategy }, OfflineClientManager };
@@ -0,0 +1,47 @@
1
+ // src/utils/FileOfflineStorage.node.js
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+
6
+ import { OfflineQueueStorageStrategy } from "./OfflineQueueStorageStrategy.js";
7
+
8
+ export class FileOfflineStorage extends OfflineQueueStorageStrategy {
9
+ constructor(filename = "offline-queue.json", dir = path.join(os.homedir(), ".config", "cocolight")) {
10
+ super();
11
+ this.dir = dir;
12
+ this.filePath = path.join(dir, filename);
13
+ this._ensureDirectoryExists();
14
+ }
15
+
16
+ _ensureDirectoryExists() {
17
+ if (!fs.existsSync(this.dir)) {
18
+ fs.mkdirSync(this.dir, { recursive: true });
19
+ }
20
+ }
21
+ _readFile() {
22
+ if (!fs.existsSync(this.filePath)) return [];
23
+ try {
24
+ const json = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
25
+ return json;
26
+ } catch (e) {
27
+ console.error("Erreur lecture offline queue :", e);
28
+ return [];
29
+ }
30
+ }
31
+
32
+ _writeFile(queue) {
33
+ try {
34
+ fs.writeFileSync(this.filePath, JSON.stringify(queue, null, 2), "utf8");
35
+ } catch (e) {
36
+ console.warn("[OfflineQueue] Erreur d'écriture:", e);
37
+ }
38
+ }
39
+
40
+ async loadQueue() {
41
+ return this._readFile();
42
+ }
43
+
44
+ async saveQueue(queue) {
45
+ this._writeFile(queue);
46
+ }
47
+ }
@@ -0,0 +1,220 @@
1
+ // OfflineClientManager.js
2
+ import ApiClient from "../ApiClient.js";
3
+ import { createDefaultOfflineStrategy } from "./createDefaultOfflineStrategy.js";
4
+
5
+ export class OfflineClientManager {
6
+ /**
7
+ * Initialise un gestionnaire de mode offline pour un ApiClient donné.
8
+ *
9
+ * @param {ApiClient} apiClient - Instance du client principal.
10
+ */
11
+ constructor(apiClient) {
12
+ if(!apiClient || !(apiClient instanceof ApiClient)) {
13
+ throw new Error("[OfflineClientManager] apiClient doit être une instance de ApiClient");
14
+ }
15
+ this._clients = new Map();
16
+ this._client = apiClient;
17
+ }
18
+
19
+ /**
20
+ * Attache le gestionnaire offline à une instance ApiClient.
21
+ * Configure la file d'attente offline, le monitoring réseau, et le ping serveur.
22
+ *
23
+ * @param {ApiClient} apiClient - L'instance du client à enrichir.
24
+ * @param {Object} options
25
+ * @param {Object} [options.offlineStorageStrategy] - Stratégie de stockage offline.
26
+ * @param {boolean} [options.disableOfflineMonitoring] - Si true, désactive le monitoring réseau.
27
+ */
28
+ static async attachTo(apiClient, options = {}) {
29
+ apiClient._offlineClientManager = new OfflineClientManager(apiClient);
30
+ const strategy = await createDefaultOfflineStrategy(options?.offlineStorageStrategy || "auto");
31
+ await apiClient._offlineClientManager._initOfflineQueue(strategy);
32
+ if (!options?.disableOfflineMonitoring) {
33
+ apiClient._offlineClientManager.startOfflineMonitoring(options);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Retourne un ApiClient configuré avec les bons tokens/baseURL pour rejouer une action offline.
39
+ * Met en cache l'instance pour éviter les recréations.
40
+ *
41
+ * @param {Object} meta - Métadonnées contenant baseURL et tokens.
42
+ * @returns {Promise<ApiClient>}
43
+ */
44
+ async getClient(meta) {
45
+ const key = `${meta.baseURL}|${meta.accessToken}`;
46
+ if (this._clients.has(key)) return this._clients.get(key);
47
+
48
+ const client = new ApiClient({
49
+ baseURL: meta.baseURL,
50
+ accessToken: meta.accessToken,
51
+ refreshToken: meta.refreshToken,
52
+ debug: this._client._debug,
53
+ });
54
+
55
+ this._clients.set(key, client);
56
+ return client;
57
+ }
58
+
59
+ /**
60
+ * Rejoue une action unique depuis la file offline.
61
+ *
62
+ * @param {Object} action - Objet contenant constant, data, options et meta.
63
+ * @returns {Promise<any>}
64
+ */
65
+ async replayAction(action) {
66
+ const client = await this.getClient(action.meta);
67
+ try {
68
+
69
+ const result = await client.callEndpoint(
70
+ action.constant,
71
+ action.data,
72
+ action.options?.transformResponseData ?? true,
73
+ action.options?.validateResponseSchema ?? true
74
+ );
75
+ this._client._logger.info(`[OfflineReplay] Succès ${action.constant} sur ${action.meta.baseURL}`);
76
+ return result;
77
+ } catch (err) {
78
+ this._client._logger.error(`[OfflineReplay] Échec ${action.constant}`, err.message);
79
+ throw err;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Démarre la surveillance de l'état réseau.
85
+ * Basé sur navigator.onLine + un ping serveur régulier.
86
+ *
87
+ * @param {Object} [options]
88
+ * @param {number} [options.interval=30000] - Intervalle de vérification en ms.
89
+ * @param {boolean} [options.disableOfflineMonitoring=false] - Pour désactiver.
90
+ */
91
+ startOfflineMonitoring({ interval = 30000, disableOfflineMonitoring = false } = {}) {
92
+ if (disableOfflineMonitoring || this._monitoringStarted) return;
93
+
94
+ this._monitoringStarted = true;
95
+ this._offlineMode = typeof window !== "undefined" && typeof navigator !== "undefined" && !navigator.onLine;
96
+
97
+ if (typeof window !== "undefined") {
98
+ window.addEventListener("online", () => this.setOfflineMode(false));
99
+ window.addEventListener("offline", () => this.setOfflineMode(true));
100
+ }
101
+
102
+ this._pingInterval = setInterval(async () => {
103
+ const wasOffline = this._offlineMode;
104
+ try {
105
+ await this._client._client.head("/api/cocolight/infoserver", { timeout: 5000 }); // ✅ utilise la fonction injectée
106
+ if (wasOffline) this.setOfflineMode(false);
107
+
108
+ // eslint-disable-next-line no-unused-vars
109
+ } catch (err) {
110
+ this._client._logger.error("[OfflineMonitor] ping() échoué → passage en offline");
111
+ this.setOfflineMode(true);
112
+ }
113
+ }, interval);
114
+ }
115
+
116
+ /**
117
+ * Change l'état offline et émet un événement si l'état change.
118
+ * Déclenche la relecture de la file si on revient online.
119
+ *
120
+ * @param {boolean} isOffline
121
+ */
122
+ setOfflineMode(isOffline) {
123
+ if (this._offlineMode === isOffline) return;
124
+ this._offlineMode = isOffline;
125
+ if (typeof this._emit === "function") {
126
+ this._client.emit("offlineModeChanged", isOffline);
127
+ }
128
+ this._client._logger.info(`[ApiClient] Mode offline : ${isOffline} (set depuis monitoring)`);
129
+
130
+ if (!isOffline && typeof this._replayOfflineQueue === "function") {
131
+ this._replayOfflineQueue();
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Indique si le client est actuellement en mode offline.
137
+ *
138
+ * @returns {boolean}
139
+ */
140
+ isOffline() {
141
+ return !!this._offlineMode;
142
+ }
143
+
144
+ /**
145
+ * Met une action API dans la file offline.
146
+ *
147
+ * @param {Object} params
148
+ * @param {string} params.constant - Le nom de l'endpoint.
149
+ * @param {Object} params.data - Données à envoyer.
150
+ * @param {Object} [params.options] - Options de transformation/validation.
151
+ */
152
+ async _enqueueOfflineAction({ constant, data, options = {} }) {
153
+ const hasToken = this._client.getToken() || this._client.getRefreshToken();
154
+ const endpoint = this._client._endpoints.find(e => e.constant === constant);
155
+ const authPolicy = endpoint?.authPolicy || (endpoint?.auth === "none" ? "none" : "required");
156
+ if (authPolicy === "required" && !hasToken) {
157
+ this._client._logger.warn(`[Offline] Action ignorée : token requis mais absent (${constant})`);
158
+ return;
159
+ }
160
+
161
+ const action = {
162
+ constant,
163
+ data,
164
+ options,
165
+ meta: {
166
+ baseURL: this._client._baseURL,
167
+ accessToken: this._client.getToken(),
168
+ refreshToken: this._client.getRefreshToken(),
169
+ timestamp: Date.now()
170
+ }
171
+ };
172
+
173
+ this._offlineQueue.push(action);
174
+ await this._offlineQueueStorage.saveQueue(this._offlineQueue);
175
+ this._client._logger.info(`[Offline] Action mise en file : ${constant}`);
176
+ }
177
+
178
+ /**
179
+ * Rejoue toutes les actions offline.
180
+ * Ré-insère celles qui échouent dans la file.
181
+ */
182
+ async _replayOfflineQueue() {
183
+ const queueCopy = [...this._offlineQueue];
184
+ this._offlineQueue = [];
185
+ await this._offlineQueueStorage.saveQueue([]);
186
+
187
+ for (const action of queueCopy) {
188
+ const endpoint = this._client._endpoints.find(e => e.constant === action.constant);
189
+ const authPolicy = endpoint?.authPolicy || (endpoint?.auth === "none" ? "none" : "required");
190
+ const hasToken = action.meta.accessToken || action.meta.refreshToken;
191
+
192
+ if (authPolicy === "required" && !hasToken) {
193
+ this._client._logger.warn(`[Offline] Rejetée : token requis manquant (${action.constant})`);
194
+ continue;
195
+ }
196
+
197
+ try {
198
+ await this.replayAction(action);
199
+
200
+ } catch (e) {
201
+ this._client._logger.error("[Offline] Relecture échouée :", e.message);
202
+ this._offlineQueue.push(action); // requeue si erreur
203
+ }
204
+ }
205
+
206
+ await this._offlineQueueStorage.saveQueue(this._offlineQueue);
207
+ }
208
+
209
+ /**
210
+ * Initialise la stratégie de stockage offline et charge la file d’attente.
211
+ *
212
+ * @param {OfflineQueueStorageStrategy} storageStrategy
213
+ */
214
+ async _initOfflineQueue(storageStrategy) {
215
+ this._offlineQueueStorage = storageStrategy;
216
+ this._offlineQueue = await storageStrategy.loadQueue();
217
+ }
218
+ }
219
+
220
+ export default OfflineClientManager;
@@ -0,0 +1,51 @@
1
+ // OfflineQueueStorageStrategy.js
2
+
3
+ export class OfflineQueueStorageStrategy {
4
+ async loadQueue() {
5
+ throw new Error("loadQueue() doit être implémenté");
6
+ }
7
+
8
+ // eslint-disable-next-line no-unused-vars
9
+ async saveQueue(queue) {
10
+ throw new Error("saveQueue(queue) doit être implémenté");
11
+ }
12
+ }
13
+
14
+ export class MemoryOfflineStorage extends OfflineQueueStorageStrategy {
15
+ constructor() {
16
+ super();
17
+ this._queue = [];
18
+ }
19
+
20
+ async loadQueue() {
21
+ return this._queue;
22
+ }
23
+
24
+ async saveQueue(queue) {
25
+ this._queue = [...queue];
26
+ }
27
+ }
28
+
29
+ export class LocalStorageOfflineStorage extends OfflineQueueStorageStrategy {
30
+ constructor(key = "cocolight-api-offline-queue") {
31
+ super();
32
+ this.key = key;
33
+ }
34
+
35
+ async loadQueue() {
36
+ try {
37
+ const raw = localStorage.getItem(this.key);
38
+ return raw ? JSON.parse(raw) : [];
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ async saveQueue(queue) {
45
+ try {
46
+ localStorage.setItem(this.key, JSON.stringify(queue));
47
+ } catch (e) {
48
+ console.warn("[Offline] Échec du stockage localStorage", e);
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,29 @@
1
+ import { LocalStorageOfflineStorage, MemoryOfflineStorage } from "./OfflineQueueStorageStrategy.js";
2
+
3
+ export async function createDefaultOfflineStrategy(offlineStorageStrategy = "auto") {
4
+ if (offlineStorageStrategy === "memory") {
5
+ return new MemoryOfflineStorage();
6
+ }
7
+ if (offlineStorageStrategy === "localStorage") {
8
+ if (typeof window !== "undefined" && window.localStorage) {
9
+ return new LocalStorageOfflineStorage();
10
+ } else {
11
+ throw new Error("localStorage is not available in this environment.");
12
+ }
13
+ }
14
+ if (offlineStorageStrategy === "file") {
15
+ if (typeof window !== "undefined" && window.localStorage) {
16
+ throw new Error("file storage is not available in this environment.");
17
+ }
18
+ const { FileOfflineStorage } = await import("./FileOfflineStorageStrategy.node.js");
19
+ return new FileOfflineStorage();
20
+ }
21
+ if (offlineStorageStrategy === "auto") {
22
+ if (typeof window !== "undefined" && window.localStorage) {
23
+ return new LocalStorageOfflineStorage();
24
+ } else {
25
+ const { FileOfflineStorage } = await import("./FileOfflineStorageStrategy.node.js");
26
+ return new FileOfflineStorage();
27
+ }
28
+ }
29
+ }