@communecter/cocolight-api-client 1.0.15 → 1.0.16
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/339.cocolight-api-client.mjs.js +1 -0
- package/dist/394.cocolight-api-client.browser.js +1 -0
- package/dist/394.cocolight-api-client.cjs +1 -0
- package/dist/cocolight-api-client.browser.js +3 -3
- package/dist/cocolight-api-client.cjs +1 -1
- package/dist/cocolight-api-client.mjs.js +1 -1
- package/package.json +1 -1
- package/src/Api.js +2 -2
- package/src/ApiClient.js +191 -8
- package/src/api/UserApi.js +10 -4
- package/src/index.js +2 -1
- package/src/utils/FileOfflineStorageStrategy.node.js +47 -0
- package/src/utils/OfflineClientManager.js +220 -0
- package/src/utils/OfflineQueueStorageStrategy.js +51 -0
- package/src/utils/createDefaultOfflineStrategy.js +29 -0
package/package.json
CHANGED
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 {
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
+
|
package/src/api/UserApi.js
CHANGED
|
@@ -8,10 +8,16 @@ import { Project } from "./Project.js";
|
|
|
8
8
|
import { User } from "./User.js";
|
|
9
9
|
|
|
10
10
|
export class UserApi {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
}
|