@sia-ui/api 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @sia-ui/api
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Français et anglais livrés, temps réel, calendrier réécrit, suivi d'opérations.
8
+ - **Deux langues** — `FRENCH` et `ENGLISH` (`@sia-ui/headless`), au format
9
+ `SiaLocale` : la langue de formatage (`language`), le vocabulaire commun et
10
+ un groupe typé par composant. Plus aucun texte en dur dans les composants,
11
+ `aria-label` compris ; dates, nombres et pluriels suivent `language`. Une
12
+ application qui a sa propre i18n remplit le même format. `formatMessage` et
13
+ `plural` (`{count}`, clés `_one` / `_other`). `ENGLISH_STATUS_MESSAGES`
14
+ (`@sia-ui/api`) à côté de `FRENCH_STATUS_MESSAGES`.
15
+ Seul changement de forme : `EventCalendarLabels.more`, qui était une
16
+ fonction, devient `more_one` / `more_other` avec `{count}`.
17
+ - **`SiaProvider` seul fournisseur** — il prend `locale` et `defaults`, et
18
+ s'imbrique pour une section. `SiaConfigProvider` est obsolète.
19
+ - **Temps réel** — `createRealtimeBinding`, `socketIoSource`,
20
+ `eventSourceSource` (`@sia-ui/api`), `useRealtimeStatus`, `LiveIndicator`.
21
+ - **`EventCalendar` réécrit** — vues mois, semaine, jour, agenda ; variantes ;
22
+ mode étroit. **`EventManager`** : créer, consulter, modifier, supprimer.
23
+ - **Nouveaux composants** — `AccountMenu`, `ColorModeToggle`, `LogStream`,
24
+ `JsonView`, `JobProgress`, `BrandIcon` ; `SunIcon`, `MoonIcon`.
25
+ - **Formulaires** — `validateAsync` par champ, `submitProps.disabled`
26
+ respecté ; `controlProps` sur une propriété de ressource.
27
+ - **Tons lisibles en mode sombre** — `--sia-<ton>-foreground`, calculés pour
28
+ le contraste ; noms de variables CSS en kebab-case.
29
+ - **Couches en portail** — menus, calendriers, boîtes et toasts, rendus hors
30
+ de `[data-sia-root]`, reprennent la police et la couleur de texte du
31
+ système au lieu de celles du navigateur.
32
+ - **`Drawer` et `Modal`** — la fermeture est signalée tout de suite ; un
33
+ double clic à l'ouverture ne referme plus.
34
+ - **Documentation** — la présentation décrit chaque brique métier (session,
35
+ greffons d'API, ressources, formulaires, temps réel, langues, utilitaires)
36
+ avec son scénario et un exemple.
37
+ - **Props des sous-composants** — chaque composant composé expose celles de
38
+ ce qu'il utilise (`…Props`) ; `pnpm check:slot-props` le vérifie.
39
+
3
40
  ## 0.7.1
4
41
 
5
42
  ### Patch Changes
package/README.md CHANGED
@@ -37,6 +37,9 @@ le projet utilise déjà axios. Le paquet ne dépend d'aucun des deux.
37
37
  | `HttpError` | statut, corps, et `fields` — trois formes d'erreurs par champ |
38
38
  | Greffons | auth, rafraîchissement de jeton, 401, journal, idempotence, lecture seule, signal d'en-tête |
39
39
  | `TokenStorage` | mémoire ou navigateur, chaque accès protégé |
40
+ | Cache | `createQueryResource` : clés et options TanStack Query, invalidation après écriture |
41
+ | Temps réel | `createRealtimeBinding` : Socket.IO ou SSE qui tient le cache à jour, rattrapage à la reconnexion |
42
+ | Messages | `FRENCH_STATUS_MESSAGES`, `ENGLISH_STATUS_MESSAGES` : une phrase lisible par statut |
40
43
  | Filtres | `$and` / `$or` portés par un en-tête plutôt que par l'URL |
41
44
 
42
45
  Voir [docs/fondations/module-api.md](https://beatjo.github.io/sia-ui-site/fondations/module-api.html).
@@ -57,6 +60,43 @@ Le contenu ci-dessous reprend intégralement `CHANGELOG.md` pour rester visible
57
60
  <!-- sia:changelog:start -->
58
61
  # @sia-ui/api
59
62
 
63
+ ## 0.8.0
64
+
65
+ ### Minor Changes
66
+
67
+ - Français et anglais livrés, temps réel, calendrier réécrit, suivi d'opérations.
68
+ - **Deux langues** — `FRENCH` et `ENGLISH` (`@sia-ui/headless`), au format
69
+ `SiaLocale` : la langue de formatage (`language`), le vocabulaire commun et
70
+ un groupe typé par composant. Plus aucun texte en dur dans les composants,
71
+ `aria-label` compris ; dates, nombres et pluriels suivent `language`. Une
72
+ application qui a sa propre i18n remplit le même format. `formatMessage` et
73
+ `plural` (`{count}`, clés `_one` / `_other`). `ENGLISH_STATUS_MESSAGES`
74
+ (`@sia-ui/api`) à côté de `FRENCH_STATUS_MESSAGES`.
75
+ Seul changement de forme : `EventCalendarLabels.more`, qui était une
76
+ fonction, devient `more_one` / `more_other` avec `{count}`.
77
+ - **`SiaProvider` seul fournisseur** — il prend `locale` et `defaults`, et
78
+ s'imbrique pour une section. `SiaConfigProvider` est obsolète.
79
+ - **Temps réel** — `createRealtimeBinding`, `socketIoSource`,
80
+ `eventSourceSource` (`@sia-ui/api`), `useRealtimeStatus`, `LiveIndicator`.
81
+ - **`EventCalendar` réécrit** — vues mois, semaine, jour, agenda ; variantes ;
82
+ mode étroit. **`EventManager`** : créer, consulter, modifier, supprimer.
83
+ - **Nouveaux composants** — `AccountMenu`, `ColorModeToggle`, `LogStream`,
84
+ `JsonView`, `JobProgress`, `BrandIcon` ; `SunIcon`, `MoonIcon`.
85
+ - **Formulaires** — `validateAsync` par champ, `submitProps.disabled`
86
+ respecté ; `controlProps` sur une propriété de ressource.
87
+ - **Tons lisibles en mode sombre** — `--sia-<ton>-foreground`, calculés pour
88
+ le contraste ; noms de variables CSS en kebab-case.
89
+ - **Couches en portail** — menus, calendriers, boîtes et toasts, rendus hors
90
+ de `[data-sia-root]`, reprennent la police et la couleur de texte du
91
+ système au lieu de celles du navigateur.
92
+ - **`Drawer` et `Modal`** — la fermeture est signalée tout de suite ; un
93
+ double clic à l'ouverture ne referme plus.
94
+ - **Documentation** — la présentation décrit chaque brique métier (session,
95
+ greffons d'API, ressources, formulaires, temps réel, langues, utilitaires)
96
+ avec son scénario et un exemple.
97
+ - **Props des sous-composants** — chaque composant composé expose celles de
98
+ ce qu'il utilise (`…Props`) ; `pnpm check:slot-props` le vérifie.
99
+
60
100
  ## 0.7.1
61
101
 
62
102
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -156,6 +156,22 @@ var FRENCH_STATUS_MESSAGES = {
156
156
  503: "Le service est momentan\xE9ment indisponible.",
157
157
  504: "Le serveur a mis trop de temps \xE0 r\xE9pondre."
158
158
  };
159
+ var ENGLISH_STATUS_MESSAGES = {
160
+ 0: "The server can't be reached. Check your connection.",
161
+ 400: "The request is invalid.",
162
+ 401: "Your session has expired. Please sign in again.",
163
+ 403: "You don't have permission to do this.",
164
+ 404: "The requested item can't be found.",
165
+ 408: "The server took too long to respond.",
166
+ 409: "This action conflicts with the current state. Reload and try again.",
167
+ 413: "The content sent is too large.",
168
+ 422: "Some values are invalid.",
169
+ 429: "Too many attempts. Try again in a moment.",
170
+ 500: "Something went wrong on the server.",
171
+ 502: "The service is temporarily unavailable.",
172
+ 503: "The service is temporarily unavailable.",
173
+ 504: "The server took too long to respond."
174
+ };
159
175
  var ECHEC_RESEAU = /failed to fetch|networkerror|load failed/i;
160
176
  var PHRASES = {
161
177
  400: "bad request",
@@ -1054,9 +1070,108 @@ function createQueryResource(service, options = {}) {
1054
1070
  };
1055
1071
  }
1056
1072
 
1073
+ // src/realtime.ts
1074
+ function createRealtimeBinding(options) {
1075
+ const { source, cache, on, refetchOnReconnect = [] } = options;
1076
+ const contexte = {
1077
+ cache,
1078
+ patch: (queryKey, updater) => {
1079
+ cache.setQueryData(queryKey, updater);
1080
+ },
1081
+ invalidate: (...queryKeys) => {
1082
+ for (const queryKey of queryKeys) void cache.invalidateQueries({ queryKey });
1083
+ }
1084
+ };
1085
+ return {
1086
+ source,
1087
+ start() {
1088
+ const desabonnements = Object.entries(on).map(
1089
+ ([topic, gestionnaire]) => source.subscribe(topic, (event) => {
1090
+ try {
1091
+ gestionnaire(event, contexte);
1092
+ } catch (error) {
1093
+ console.error(`Temps r\xE9el : le gestionnaire de \xAB ${topic} \xBB a \xE9chou\xE9.`, error);
1094
+ }
1095
+ })
1096
+ );
1097
+ let precedent = source.status();
1098
+ const statut = source.onStatusChange((suivant) => {
1099
+ if (suivant === "open" && precedent !== "open" && refetchOnReconnect.length > 0) {
1100
+ contexte.invalidate(...refetchOnReconnect);
1101
+ }
1102
+ precedent = suivant;
1103
+ });
1104
+ return () => {
1105
+ for (const desabonner of desabonnements) desabonner();
1106
+ statut();
1107
+ };
1108
+ }
1109
+ };
1110
+ }
1111
+ function etatObservable(initial) {
1112
+ let courant = initial;
1113
+ const ecouteurs = /* @__PURE__ */ new Set();
1114
+ return {
1115
+ lire: () => courant,
1116
+ poser: (suivant) => {
1117
+ if (suivant === courant) return;
1118
+ courant = suivant;
1119
+ for (const ecouteur of ecouteurs) ecouteur(suivant);
1120
+ },
1121
+ ecouter: (ecouteur) => {
1122
+ ecouteurs.add(ecouteur);
1123
+ return () => {
1124
+ ecouteurs.delete(ecouteur);
1125
+ };
1126
+ }
1127
+ };
1128
+ }
1129
+ function socketIoSource(socket) {
1130
+ const etat = etatObservable(socket.connected ? "open" : "connecting");
1131
+ const ouvert = () => etat.poser("open");
1132
+ const ferme = () => etat.poser("closed");
1133
+ const reconnexion = () => etat.poser("connecting");
1134
+ socket.on("connect", ouvert);
1135
+ socket.on("disconnect", ferme);
1136
+ socket.on("connect_error", reconnexion);
1137
+ return {
1138
+ subscribe(topic, handler) {
1139
+ const listener = (event) => handler(event);
1140
+ socket.on(topic, listener);
1141
+ return () => {
1142
+ socket.off(topic, listener);
1143
+ };
1144
+ },
1145
+ status: etat.lire,
1146
+ onStatusChange: etat.ecouter
1147
+ };
1148
+ }
1149
+ function eventSourceSource(stream) {
1150
+ const etat = etatObservable(stream.readyState === 1 ? "open" : stream.readyState === 2 ? "closed" : "connecting");
1151
+ stream.onopen = () => etat.poser("open");
1152
+ stream.onerror = () => etat.poser(stream.readyState === 2 ? "closed" : "connecting");
1153
+ return {
1154
+ subscribe(topic, handler) {
1155
+ const listener = (event) => {
1156
+ let contenu = event.data;
1157
+ try {
1158
+ contenu = JSON.parse(event.data);
1159
+ } catch {
1160
+ }
1161
+ handler(contenu);
1162
+ };
1163
+ stream.addEventListener(topic, listener);
1164
+ return () => stream.removeEventListener(topic, listener);
1165
+ },
1166
+ status: etat.lire,
1167
+ onStatusChange: etat.ecouter
1168
+ };
1169
+ }
1170
+
1057
1171
  exports.ACCESS_TOKEN_KEY = ACCESS_TOKEN_KEY;
1058
1172
  exports.ApiClient = ApiClient;
1059
1173
  exports.BaseService = BaseService;
1174
+ exports.ENGLISH_STATUS_MESSAGES = ENGLISH_STATUS_MESSAGES;
1060
1175
  exports.FILTERS_HEADER = FILTERS_HEADER;
1061
1176
  exports.FRENCH_STATUS_MESSAGES = FRENCH_STATUS_MESSAGES;
1062
1177
  exports.HttpError = HttpError;
@@ -1074,11 +1189,14 @@ exports.createLoggerPlugin = createLoggerPlugin;
1074
1189
  exports.createMemoryTokenStorage = createMemoryTokenStorage;
1075
1190
  exports.createQueryResource = createQueryResource;
1076
1191
  exports.createReadOnlyPlugin = createReadOnlyPlugin;
1192
+ exports.createRealtimeBinding = createRealtimeBinding;
1077
1193
  exports.createRefreshTokenPlugin = createRefreshTokenPlugin;
1078
1194
  exports.createResourceService = createResourceService;
1079
1195
  exports.createUnauthorizedPlugin = createUnauthorizedPlugin;
1196
+ exports.eventSourceSource = eventSourceSource;
1080
1197
  exports.filtersHeader = filtersHeader;
1081
1198
  exports.isGenericMessage = isGenericMessage;
1082
1199
  exports.localizeError = localizeError;
1083
1200
  exports.nestFieldErrors = nestFieldErrors;
1084
1201
  exports.normalizeFilters = normalizeFilters;
1202
+ exports.socketIoSource = socketIoSource;
package/dist/index.d.cts CHANGED
@@ -250,6 +250,8 @@ declare function createApiClient(options: ApiClientOptions): ApiClient;
250
250
  * `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
251
251
  */
252
252
  declare const FRENCH_STATUS_MESSAGES: Readonly<Record<number, string>>;
253
+ /** Les mêmes, en anglais. */
254
+ declare const ENGLISH_STATUS_MESSAGES: Readonly<Record<number, string>>;
253
255
  /**
254
256
  * Vrai quand le message du serveur est générique.
255
257
  *
@@ -560,6 +562,89 @@ declare function createQueryResource<TEntity, TCreate = Partial<TEntity>, TUpdat
560
562
  };
561
563
  type QueryResource<TEntity> = ReturnType<typeof createQueryResource<TEntity>>;
562
564
 
565
+ /** L'état d'une connexion temps réel. */
566
+ type RealtimeStatus = "connecting" | "open" | "closed";
567
+ /**
568
+ * Un flux d'événements, quel qu'il soit : Socket.IO, SSE, WebSocket nu.
569
+ *
570
+ * La forme minimale qu'une liaison demande — s'abonner à un sujet, et suivre
571
+ * l'état de la connexion. `socketIoSource` et `eventSourceSource` en
572
+ * fabriquent une ; un autre transport n'a qu'à fournir ces trois membres.
573
+ */
574
+ interface RealtimeSource {
575
+ subscribe(topic: string, handler: (event: unknown) => void): () => void;
576
+ status(): RealtimeStatus;
577
+ onStatusChange(listener: (status: RealtimeStatus) => void): () => void;
578
+ }
579
+ /**
580
+ * Ce que la liaison demande du cache de requêtes : invalider, et patcher.
581
+ * Le `QueryClient` de TanStack Query le satisfait tel quel.
582
+ */
583
+ interface QueryCacheClient extends QueryInvalidator {
584
+ setQueryData: (queryKey: readonly unknown[], updater: (old: unknown) => unknown) => unknown;
585
+ }
586
+ /** Les outils remis à chaque gestionnaire d'événement. */
587
+ interface RealtimeContext {
588
+ /** Remplace une entrée du cache, sans requête : le détail d'une ligne. */
589
+ patch: <T>(queryKey: readonly unknown[], updater: (old: T | undefined) => T | undefined) => void;
590
+ /** Rend des requêtes périmées : les listes, un compteur. */
591
+ invalidate: (...queryKeys: Array<readonly unknown[]>) => void;
592
+ cache: QueryCacheClient;
593
+ }
594
+ interface RealtimeBindingOptions {
595
+ source: RealtimeSource;
596
+ cache: QueryCacheClient;
597
+ /** Un gestionnaire par sujet : \`"operation.updated": (event, { patch, invalidate }) => …\`. */
598
+ on: Record<string, (event: never, context: RealtimeContext) => void>;
599
+ /**
600
+ * À la reconnexion, ce qui a pu être manqué pendant la coupure est
601
+ * invalidé : ces clés, typiquement les listes suivies.
602
+ */
603
+ refetchOnReconnect?: Array<readonly unknown[]>;
604
+ }
605
+ /**
606
+ * Relie un flux d'événements au cache de requêtes.
607
+ *
608
+ * Chaque projet réécrivait la même chose : s'abonner, patcher le détail,
609
+ * invalider les listes, se désabonner au bon moment, et rattraper ce qui
610
+ * s'est passé pendant une coupure. \`start()\` branche tout et rend la
611
+ * fonction qui débranche tout — la forme exacte d'un nettoyage d'effet :
612
+ *
613
+ * \`\`\`ts
614
+ * useEffect(() => liaison.start(), [liaison]);
615
+ * \`\`\`
616
+ */
617
+ declare function createRealtimeBinding(options: RealtimeBindingOptions): {
618
+ source: RealtimeSource;
619
+ start(): () => void;
620
+ };
621
+ type RealtimeBinding = ReturnType<typeof createRealtimeBinding>;
622
+ /** La forme d'un socket Socket.IO, sans en dépendre. */
623
+ interface SocketLike {
624
+ connected: boolean;
625
+ on(event: string, listener: (...args: never[]) => void): unknown;
626
+ off(event: string, listener: (...args: never[]) => void): unknown;
627
+ }
628
+ /** Un socket Socket.IO en source temps réel. L'authentification reste au socket. */
629
+ declare function socketIoSource(socket: SocketLike): RealtimeSource;
630
+ /** La forme d'un \`EventSource\`, sans en dépendre (et testable sans navigateur). */
631
+ interface EventSourceLike {
632
+ readyState: number;
633
+ onopen: ((event: unknown) => void) | null;
634
+ onerror: ((event: unknown) => void) | null;
635
+ addEventListener(type: string, listener: (event: {
636
+ data: string;
637
+ }) => void): void;
638
+ removeEventListener(type: string, listener: (event: {
639
+ data: string;
640
+ }) => void): void;
641
+ }
642
+ /**
643
+ * Un flux SSE en source temps réel. Chaque événement nommé devient un sujet ;
644
+ * son \`data\` est lu en JSON, ou passé tel quel s'il n'en est pas.
645
+ */
646
+ declare function eventSourceSource(stream: EventSourceLike): RealtimeSource;
647
+
563
648
  interface AxiosLikeInstance {
564
649
  request<T = unknown>(config: {
565
650
  url: string;
@@ -582,4 +667,4 @@ interface AxiosLikeInstance {
582
667
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
583
668
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
584
669
 
585
- export { ACCESS_TOKEN_KEY, ApiClient, type ApiClientOptions, type ApiFilters, type ApiMethod, type ApiPlugin, type ApiPluginHook, type ApiRequestConfig, type ApiRequestContext, type ApiRequestInput, type ApiTransport, type ApiTransportRequest, type ApiTransportResponse, type AuthPluginOptions, BaseService, type BrowserTokenStorageOptions, type CursorMeta, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryFnContext, type QueryInvalidator, type QueryParamObject, type QueryParams, type QueryResource, type QueryResourceOptions, REFRESH_TOKEN_KEY, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type SaveVariables, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters };
670
+ export { ACCESS_TOKEN_KEY, ApiClient, type ApiClientOptions, type ApiFilters, type ApiMethod, type ApiPlugin, type ApiPluginHook, type ApiRequestConfig, type ApiRequestContext, type ApiRequestInput, type ApiTransport, type ApiTransportRequest, type ApiTransportResponse, type AuthPluginOptions, BaseService, type BrowserTokenStorageOptions, type CursorMeta, ENGLISH_STATUS_MESSAGES, type EventSourceLike, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryCacheClient, type QueryFnContext, type QueryInvalidator, type QueryParamObject, type QueryParams, type QueryResource, type QueryResourceOptions, REFRESH_TOKEN_KEY, type RealtimeBinding, type RealtimeBindingOptions, type RealtimeContext, type RealtimeSource, type RealtimeStatus, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type SaveVariables, type SocketLike, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRealtimeBinding, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, eventSourceSource, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters, socketIoSource };
package/dist/index.d.ts CHANGED
@@ -250,6 +250,8 @@ declare function createApiClient(options: ApiClientOptions): ApiClient;
250
250
  * `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
251
251
  */
252
252
  declare const FRENCH_STATUS_MESSAGES: Readonly<Record<number, string>>;
253
+ /** Les mêmes, en anglais. */
254
+ declare const ENGLISH_STATUS_MESSAGES: Readonly<Record<number, string>>;
253
255
  /**
254
256
  * Vrai quand le message du serveur est générique.
255
257
  *
@@ -560,6 +562,89 @@ declare function createQueryResource<TEntity, TCreate = Partial<TEntity>, TUpdat
560
562
  };
561
563
  type QueryResource<TEntity> = ReturnType<typeof createQueryResource<TEntity>>;
562
564
 
565
+ /** L'état d'une connexion temps réel. */
566
+ type RealtimeStatus = "connecting" | "open" | "closed";
567
+ /**
568
+ * Un flux d'événements, quel qu'il soit : Socket.IO, SSE, WebSocket nu.
569
+ *
570
+ * La forme minimale qu'une liaison demande — s'abonner à un sujet, et suivre
571
+ * l'état de la connexion. `socketIoSource` et `eventSourceSource` en
572
+ * fabriquent une ; un autre transport n'a qu'à fournir ces trois membres.
573
+ */
574
+ interface RealtimeSource {
575
+ subscribe(topic: string, handler: (event: unknown) => void): () => void;
576
+ status(): RealtimeStatus;
577
+ onStatusChange(listener: (status: RealtimeStatus) => void): () => void;
578
+ }
579
+ /**
580
+ * Ce que la liaison demande du cache de requêtes : invalider, et patcher.
581
+ * Le `QueryClient` de TanStack Query le satisfait tel quel.
582
+ */
583
+ interface QueryCacheClient extends QueryInvalidator {
584
+ setQueryData: (queryKey: readonly unknown[], updater: (old: unknown) => unknown) => unknown;
585
+ }
586
+ /** Les outils remis à chaque gestionnaire d'événement. */
587
+ interface RealtimeContext {
588
+ /** Remplace une entrée du cache, sans requête : le détail d'une ligne. */
589
+ patch: <T>(queryKey: readonly unknown[], updater: (old: T | undefined) => T | undefined) => void;
590
+ /** Rend des requêtes périmées : les listes, un compteur. */
591
+ invalidate: (...queryKeys: Array<readonly unknown[]>) => void;
592
+ cache: QueryCacheClient;
593
+ }
594
+ interface RealtimeBindingOptions {
595
+ source: RealtimeSource;
596
+ cache: QueryCacheClient;
597
+ /** Un gestionnaire par sujet : \`"operation.updated": (event, { patch, invalidate }) => …\`. */
598
+ on: Record<string, (event: never, context: RealtimeContext) => void>;
599
+ /**
600
+ * À la reconnexion, ce qui a pu être manqué pendant la coupure est
601
+ * invalidé : ces clés, typiquement les listes suivies.
602
+ */
603
+ refetchOnReconnect?: Array<readonly unknown[]>;
604
+ }
605
+ /**
606
+ * Relie un flux d'événements au cache de requêtes.
607
+ *
608
+ * Chaque projet réécrivait la même chose : s'abonner, patcher le détail,
609
+ * invalider les listes, se désabonner au bon moment, et rattraper ce qui
610
+ * s'est passé pendant une coupure. \`start()\` branche tout et rend la
611
+ * fonction qui débranche tout — la forme exacte d'un nettoyage d'effet :
612
+ *
613
+ * \`\`\`ts
614
+ * useEffect(() => liaison.start(), [liaison]);
615
+ * \`\`\`
616
+ */
617
+ declare function createRealtimeBinding(options: RealtimeBindingOptions): {
618
+ source: RealtimeSource;
619
+ start(): () => void;
620
+ };
621
+ type RealtimeBinding = ReturnType<typeof createRealtimeBinding>;
622
+ /** La forme d'un socket Socket.IO, sans en dépendre. */
623
+ interface SocketLike {
624
+ connected: boolean;
625
+ on(event: string, listener: (...args: never[]) => void): unknown;
626
+ off(event: string, listener: (...args: never[]) => void): unknown;
627
+ }
628
+ /** Un socket Socket.IO en source temps réel. L'authentification reste au socket. */
629
+ declare function socketIoSource(socket: SocketLike): RealtimeSource;
630
+ /** La forme d'un \`EventSource\`, sans en dépendre (et testable sans navigateur). */
631
+ interface EventSourceLike {
632
+ readyState: number;
633
+ onopen: ((event: unknown) => void) | null;
634
+ onerror: ((event: unknown) => void) | null;
635
+ addEventListener(type: string, listener: (event: {
636
+ data: string;
637
+ }) => void): void;
638
+ removeEventListener(type: string, listener: (event: {
639
+ data: string;
640
+ }) => void): void;
641
+ }
642
+ /**
643
+ * Un flux SSE en source temps réel. Chaque événement nommé devient un sujet ;
644
+ * son \`data\` est lu en JSON, ou passé tel quel s'il n'en est pas.
645
+ */
646
+ declare function eventSourceSource(stream: EventSourceLike): RealtimeSource;
647
+
563
648
  interface AxiosLikeInstance {
564
649
  request<T = unknown>(config: {
565
650
  url: string;
@@ -582,4 +667,4 @@ interface AxiosLikeInstance {
582
667
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
583
668
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
584
669
 
585
- export { ACCESS_TOKEN_KEY, ApiClient, type ApiClientOptions, type ApiFilters, type ApiMethod, type ApiPlugin, type ApiPluginHook, type ApiRequestConfig, type ApiRequestContext, type ApiRequestInput, type ApiTransport, type ApiTransportRequest, type ApiTransportResponse, type AuthPluginOptions, BaseService, type BrowserTokenStorageOptions, type CursorMeta, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryFnContext, type QueryInvalidator, type QueryParamObject, type QueryParams, type QueryResource, type QueryResourceOptions, REFRESH_TOKEN_KEY, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type SaveVariables, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters };
670
+ export { ACCESS_TOKEN_KEY, ApiClient, type ApiClientOptions, type ApiFilters, type ApiMethod, type ApiPlugin, type ApiPluginHook, type ApiRequestConfig, type ApiRequestContext, type ApiRequestInput, type ApiTransport, type ApiTransportRequest, type ApiTransportResponse, type AuthPluginOptions, BaseService, type BrowserTokenStorageOptions, type CursorMeta, ENGLISH_STATUS_MESSAGES, type EventSourceLike, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryCacheClient, type QueryFnContext, type QueryInvalidator, type QueryParamObject, type QueryParams, type QueryResource, type QueryResourceOptions, REFRESH_TOKEN_KEY, type RealtimeBinding, type RealtimeBindingOptions, type RealtimeContext, type RealtimeSource, type RealtimeStatus, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type SaveVariables, type SocketLike, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRealtimeBinding, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, eventSourceSource, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters, socketIoSource };
package/dist/index.js CHANGED
@@ -154,6 +154,22 @@ var FRENCH_STATUS_MESSAGES = {
154
154
  503: "Le service est momentan\xE9ment indisponible.",
155
155
  504: "Le serveur a mis trop de temps \xE0 r\xE9pondre."
156
156
  };
157
+ var ENGLISH_STATUS_MESSAGES = {
158
+ 0: "The server can't be reached. Check your connection.",
159
+ 400: "The request is invalid.",
160
+ 401: "Your session has expired. Please sign in again.",
161
+ 403: "You don't have permission to do this.",
162
+ 404: "The requested item can't be found.",
163
+ 408: "The server took too long to respond.",
164
+ 409: "This action conflicts with the current state. Reload and try again.",
165
+ 413: "The content sent is too large.",
166
+ 422: "Some values are invalid.",
167
+ 429: "Too many attempts. Try again in a moment.",
168
+ 500: "Something went wrong on the server.",
169
+ 502: "The service is temporarily unavailable.",
170
+ 503: "The service is temporarily unavailable.",
171
+ 504: "The server took too long to respond."
172
+ };
157
173
  var ECHEC_RESEAU = /failed to fetch|networkerror|load failed/i;
158
174
  var PHRASES = {
159
175
  400: "bad request",
@@ -1052,4 +1068,102 @@ function createQueryResource(service, options = {}) {
1052
1068
  };
1053
1069
  }
1054
1070
 
1055
- export { ACCESS_TOKEN_KEY, ApiClient, BaseService, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, HttpError, REFRESH_TOKEN_KEY, ResponseHandler, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters };
1071
+ // src/realtime.ts
1072
+ function createRealtimeBinding(options) {
1073
+ const { source, cache, on, refetchOnReconnect = [] } = options;
1074
+ const contexte = {
1075
+ cache,
1076
+ patch: (queryKey, updater) => {
1077
+ cache.setQueryData(queryKey, updater);
1078
+ },
1079
+ invalidate: (...queryKeys) => {
1080
+ for (const queryKey of queryKeys) void cache.invalidateQueries({ queryKey });
1081
+ }
1082
+ };
1083
+ return {
1084
+ source,
1085
+ start() {
1086
+ const desabonnements = Object.entries(on).map(
1087
+ ([topic, gestionnaire]) => source.subscribe(topic, (event) => {
1088
+ try {
1089
+ gestionnaire(event, contexte);
1090
+ } catch (error) {
1091
+ console.error(`Temps r\xE9el : le gestionnaire de \xAB ${topic} \xBB a \xE9chou\xE9.`, error);
1092
+ }
1093
+ })
1094
+ );
1095
+ let precedent = source.status();
1096
+ const statut = source.onStatusChange((suivant) => {
1097
+ if (suivant === "open" && precedent !== "open" && refetchOnReconnect.length > 0) {
1098
+ contexte.invalidate(...refetchOnReconnect);
1099
+ }
1100
+ precedent = suivant;
1101
+ });
1102
+ return () => {
1103
+ for (const desabonner of desabonnements) desabonner();
1104
+ statut();
1105
+ };
1106
+ }
1107
+ };
1108
+ }
1109
+ function etatObservable(initial) {
1110
+ let courant = initial;
1111
+ const ecouteurs = /* @__PURE__ */ new Set();
1112
+ return {
1113
+ lire: () => courant,
1114
+ poser: (suivant) => {
1115
+ if (suivant === courant) return;
1116
+ courant = suivant;
1117
+ for (const ecouteur of ecouteurs) ecouteur(suivant);
1118
+ },
1119
+ ecouter: (ecouteur) => {
1120
+ ecouteurs.add(ecouteur);
1121
+ return () => {
1122
+ ecouteurs.delete(ecouteur);
1123
+ };
1124
+ }
1125
+ };
1126
+ }
1127
+ function socketIoSource(socket) {
1128
+ const etat = etatObservable(socket.connected ? "open" : "connecting");
1129
+ const ouvert = () => etat.poser("open");
1130
+ const ferme = () => etat.poser("closed");
1131
+ const reconnexion = () => etat.poser("connecting");
1132
+ socket.on("connect", ouvert);
1133
+ socket.on("disconnect", ferme);
1134
+ socket.on("connect_error", reconnexion);
1135
+ return {
1136
+ subscribe(topic, handler) {
1137
+ const listener = (event) => handler(event);
1138
+ socket.on(topic, listener);
1139
+ return () => {
1140
+ socket.off(topic, listener);
1141
+ };
1142
+ },
1143
+ status: etat.lire,
1144
+ onStatusChange: etat.ecouter
1145
+ };
1146
+ }
1147
+ function eventSourceSource(stream) {
1148
+ const etat = etatObservable(stream.readyState === 1 ? "open" : stream.readyState === 2 ? "closed" : "connecting");
1149
+ stream.onopen = () => etat.poser("open");
1150
+ stream.onerror = () => etat.poser(stream.readyState === 2 ? "closed" : "connecting");
1151
+ return {
1152
+ subscribe(topic, handler) {
1153
+ const listener = (event) => {
1154
+ let contenu = event.data;
1155
+ try {
1156
+ contenu = JSON.parse(event.data);
1157
+ } catch {
1158
+ }
1159
+ handler(contenu);
1160
+ };
1161
+ stream.addEventListener(topic, listener);
1162
+ return () => stream.removeEventListener(topic, listener);
1163
+ },
1164
+ status: etat.lire,
1165
+ onStatusChange: etat.ecouter
1166
+ };
1167
+ }
1168
+
1169
+ export { ACCESS_TOKEN_KEY, ApiClient, BaseService, ENGLISH_STATUS_MESSAGES, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, HttpError, REFRESH_TOKEN_KEY, ResponseHandler, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRealtimeBinding, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, eventSourceSource, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters, socketIoSource };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sia-ui/api",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Typed HTTP client with CRUD services, plugins and injectable transports.",
5
5
  "keywords": [
6
6
  "sia-ui",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@sia-ui/utils": "0.7.1"
45
+ "@sia-ui/utils": "0.8.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "tsup": "^8.5.1",