@siran/chat-client
v0.1.8
Published
Package client du système de chat.
Readme
client
Package client du système de chat.
Le ChatClient est le runtime qui connecte le ChatEngine aux interfaces utilisateur via un transport temps réel.
Responsabilités
Le ChatClient est responsable de :
- L'abonnement aux événements temps réel
- La réception des événements depuis le transport
- La distribution des événements aux callbacks appropriés
- La mise en cache des conversations et messages
Important : Le ChatClient ne contient aucune règle métier. Toute la logique métier est dans le ChatEngine.
Installation
bun installUtilisation
Exemple basique avec Supabase
Pour utiliser le ChatClient avec Supabase, vous devez installer les packages d'implémentation Supabase :
bun install @package/chat-supabase-repo @package/chat-supabase-eventsimport { createClient } from "@supabase/supabase-js";
import { createChatClient } from "@siran/chat-client";
import { createChatEngine } from "@siran/chat-core";
import {
createSupabaseConversationRepository,
createSupabaseChatMessageRepository,
} from "@package/chat-supabase-repo";
import {
createSupabaseRealTimeEventEmitter,
createSupabaseRealTimeEventSubscriber,
} from "@package/chat-supabase-events";
// Initialiser le client Supabase
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Créer les repositories Supabase
const conversationRepository = createSupabaseConversationRepository(supabase);
const messageRepository = createSupabaseChatMessageRepository(supabase);
// Créer l'event emitter Supabase (pour le serveur/backend)
const eventEmitter = createSupabaseRealTimeEventEmitter(supabase);
// Créer le ChatEngine avec les repositories Supabase
const engine = createChatEngine({
conversationRepository,
messageRepository,
eventEmitter,
});
// Créer le subscriber Supabase (pour le client/frontend)
const eventSubscriber = createSupabaseRealTimeEventSubscriber(supabase);
// Créer le ChatClient avec le ChatEngine et le subscriber Supabase
const client = createChatClient({ engine, eventSubscriber });
// Définir les callbacks pour les événements avec la nouvelle API
// Les handlers reçoivent maintenant l'événement complet pour permettre l'utilisation de middlewares
client.on("message.received", (event) => {
console.log("Nouveau message reçu:", event.payload.message);
console.log("Conversation:", event.conversationId);
console.log("Timestamp:", event.timestamp);
});
client.on("message.updated", (event) => {
console.log("Message mis à jour:", event.payload.message);
});
client.on("typing", (event) => {
console.log(`Utilisateur ${event.payload.userId} ${event.payload.isTyping ? "est en train de taper" : "a arrêté de taper"}`);
});
client.on("presence.changed", (event) => {
console.log(`Utilisateur ${event.payload.userId} est maintenant ${event.payload.status}`);
});
// S'abonner à une conversation
const unsubscribe = client.subscribeToConversation("conv-123");
// Utiliser les méthodes déléguées du ChatEngine
const result = await client.sendMessage({
conversationId: "conv-123",
senderId: "user-456",
content: "Hello!",
});
if (result.ok) {
console.log("Message envoyé:", result.value);
}
// Plus tard, se désabonner
unsubscribe();Exemple basique (sans Supabase)
Si vous utilisez une autre implémentation que Supabase :
import { createChatClient } from "@siran/chat-client";
import { createChatEngine } from "@siran/chat-core";
import type { RealTimeEventSubscriber } from "@siran/chat-core";
// Créer le ChatEngine d'abord
const engine = createChatEngine({
conversationRepository: myConversationRepository,
messageRepository: myMessageRepository,
eventEmitter: myEventEmitter,
});
// Créer le subscriber (implémentation dans la couche infrastructure)
const eventSubscriber: RealTimeEventSubscriber = {
subscribeToConversation: (conversationId, callback) => {
// Implémentation de l'abonnement (WebSocket, SSE, etc.)
return () => {
// Fonction de désabonnement
};
},
subscribeToUser: (userId, callback) => {
// Implémentation de l'abonnement utilisateur
return () => {
// Fonction de désabonnement
};
},
};
// Créer le ChatClient avec le ChatEngine
const client = createChatClient({ engine, eventSubscriber });Use Case : Intégration React avec Store Observable et Supabase
Voici un exemple complet d'intégration dans une application React utilisant le store observable :
// hooks/useChatClient.ts
import { useSyncExternalStore } from "react";
import { createClient } from "@supabase/supabase-js";
import { createChatClient } from "@siran/chat-client";
import { createChatEngine } from "@siran/chat-core";
import type { ChatStoreSnapshot } from "@siran/chat-client";
import {
createSupabaseConversationRepository,
createSupabaseChatMessageRepository,
} from "@package/chat-supabase-repo";
import {
createSupabaseRealTimeEventEmitter,
createSupabaseRealTimeEventSubscriber,
} from "@package/chat-supabase-events";
// Instance singleton du client (ou via Context)
let chatClient: ReturnType<typeof createChatClient> | null = null;
export function initializeChatClient(supabaseUrl: string, supabaseAnonKey: string) {
// Initialiser Supabase
const supabase = createClient(supabaseUrl, supabaseAnonKey);
// Créer les repositories Supabase
const conversationRepository = createSupabaseConversationRepository(supabase);
const messageRepository = createSupabaseChatMessageRepository(supabase);
const eventEmitter = createSupabaseRealTimeEventEmitter(supabase);
const eventSubscriber = createSupabaseRealTimeEventSubscriber(supabase);
// Créer le ChatEngine avec les repositories Supabase
const engine = createChatEngine({
conversationRepository,
messageRepository,
eventEmitter,
});
// Créer le ChatClient
chatClient = createChatClient({ engine, eventSubscriber });
return chatClient;
}
export function useChatClient() {
if (!chatClient) {
throw new Error("ChatClient not initialized");
}
return chatClient;
}
// Hook pour s'abonner au store
export function useChatStore() {
const client = useChatClient();
return useSyncExternalStore(
(onStoreChange) => {
// S'abonner aux changements du store
return client.store.subscribe(onStoreChange);
},
() => {
// Obtenir le snapshot actuel
return client.store.getSnapshot();
}
);
}
// Hook pour accéder aux messages d'une conversation
export function useChatMessages(conversationId: string) {
const snapshot = useChatStore();
return snapshot.messages[conversationId] || [];
}
// Hook pour accéder aux conversations
export function useChatConversations() {
const snapshot = useChatStore();
return snapshot.conversations;
}
// Hook pour accéder aux utilisateurs en train de taper
export function useChatTyping(conversationId: string) {
const snapshot = useChatStore();
return snapshot.typing[conversationId] || [];
}
// Hook pour accéder à la présence d'un utilisateur
export function useChatPresence(userId: string) {
const snapshot = useChatStore();
return snapshot.presence[userId];
}// components/ChatConversation.tsx
import { useEffect, useState } from "react";
import { useChatClient, useChatMessages, useChatTyping } from "../hooks/useChatClient";
interface ChatConversationProps {
conversationId: string;
currentUserId: string;
}
export function ChatConversation({ conversationId, currentUserId }: ChatConversationProps) {
const client = useChatClient();
const messages = useChatMessages(conversationId);
const typingUsers = useChatTyping(conversationId);
const [inputValue, setInputValue] = useState("");
// S'abonner à la conversation au montage du composant
useEffect(() => {
const unsubscribe = client.subscribeToConversation(conversationId);
// Écouter les nouveaux messages
client.on("message.received", (event) => {
if (event.conversationId === conversationId) {
// Le store sera automatiquement mis à jour via le système observable
console.log("Nouveau message:", event.payload.message);
}
});
// Écouter les événements de frappe
client.on("typing", (event) => {
if (event.conversationId === conversationId) {
console.log(`User ${event.payload.userId} is ${event.payload.isTyping ? "typing" : "not typing"}`);
}
});
return () => {
unsubscribe();
// Note: Les handlers `on()` ne retournent pas de fonction de désabonnement
// Pour une vraie implémentation, vous devriez stocker les handlers et les retirer
// ou utiliser un système de cleanup plus sophistiqué
};
}, [conversationId, client]);
const handleSendMessage = async () => {
if (!inputValue.trim()) return;
const result = await client.sendMessage({
conversationId,
senderId: currentUserId,
content: inputValue,
});
if (result.ok) {
setInputValue("");
} else {
console.error("Erreur lors de l'envoi:", result.error);
}
};
return (
<div className="chat-conversation">
<div className="messages">
{messages.map((message) => (
<div key={message.id} className="message">
<div className="message-content">{message.content}</div>
<div className="message-meta">
{new Date(message.sentAt).toLocaleTimeString()}
</div>
</div>
))}
</div>
{typingUsers.length > 0 && (
<div className="typing-indicator">
{typingUsers.map((userId) => (
<span key={userId}>User {userId} is typing...</span>
))}
</div>
)}
<div className="input-area">
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSendMessage()}
placeholder="Tapez votre message..."
/>
<button onClick={handleSendMessage}>Envoyer</button>
</div>
</div>
);
}// components/ChatList.tsx
import { useEffect } from "react";
import { useChatClient, useChatConversations } from "../hooks/useChatClient";
export function ChatList({ currentUserId }: { currentUserId: string }) {
const client = useChatClient();
const conversations = useChatConversations();
// Charger les conversations au montage
useEffect(() => {
client.getConversations({ userId: currentUserId }).then((result) => {
if (result.ok) {
// Les conversations seront automatiquement mises en cache
// et le store sera mis à jour via le système observable
console.log("Conversations chargées:", result.value);
}
});
}, [currentUserId, client]);
// Écouter les nouvelles conversations
useEffect(() => {
client.on("conversation.created", (event) => {
console.log("Nouvelle conversation créée:", event.payload.conversation);
// Le store sera automatiquement mis à jour
});
return () => {
// Note: Pour une vraie implémentation, vous devriez stocker les handlers
// et les retirer lors du cleanup
};
}, [client]);
return (
<div className="chat-list">
<h2>Conversations</h2>
{conversations.map((conversation) => (
<div key={conversation.id} className="conversation-item">
<h3>{conversation.name || `Conversation ${conversation.id}`}</h3>
<p>Participants: {conversation.participantIds.length}</p>
</div>
))}
</div>
);
}// components/UserPresence.tsx
import { useChatPresence } from "../hooks/useChatClient";
interface UserPresenceProps {
userId: string;
}
export function UserPresence({ userId }: UserPresenceProps) {
const presence = useChatPresence(userId);
if (!presence) {
return <span className="presence unknown">Inconnu</span>;
}
return (
<span className={`presence ${presence.status}`}>
{presence.status === "online" && "🟢 En ligne"}
{presence.status === "offline" && "⚫ Hors ligne"}
{presence.status === "away" && "🟡 Absent"}
{presence.status === "busy" && "🔴 Occupé"}
{presence.lastSeenAt && (
<span className="last-seen">
Vu il y a {Math.floor((Date.now() - presence.lastSeenAt.getTime()) / 60000)} min
</span>
)}
</span>
);
}API
Méthodes déléguées vers le ChatEngine
Toutes les opérations métier sont disponibles directement sur le ChatClient :
createConversation(input): Crée une nouvelle conversationsendMessage(input): Envoie un message dans une conversationeditMessage(input): Édite un message existantdeleteMessage(input): Supprime un message (soft delete)markMessageAsRead(input): Marque un message comme lugetConversations(input): Récupère les conversations d'un utilisateurgetMessages(input): Récupère les messages d'une conversation
Méthodes d'abonnement
subscribeToConversation(conversationId): S'abonne aux événements d'une conversation spécifique. Retourne une fonction pour se désabonner.subscribeToUser(userId): S'abonne aux événements d'un utilisateur spécifique. Retourne une fonction pour se désabonner.
Callbacks d'événements
La nouvelle API utilise une méthode générique on(type, handler) avec typage fort.
Les handlers reçoivent maintenant l'événement complet pour permettre l'utilisation de middlewares :
client.on("message.received", (event) => {
// event.type === "message.received"
// event.conversationId === string
// event.payload.message === ChatMessage
// event.timestamp === Date
console.log("Message:", event.payload.message);
});
client.on("message.updated", (event) => { ... });
client.on("message.deleted", (event) => { ... });
client.on("typing", (event) => { ... });
client.on("presence.changed", (event) => { ... });
client.on("conversation.created", (event) => { ... });
client.on("conversation.updated", (event) => { ... });Middlewares
Le système de middlewares permet d'intercepter et de traiter les événements avant qu'ils n'atteignent les handlers. Cela permet d'ajouter des fonctionnalités comme le logging, la persistance, le replay, ou des plugins.
// Middleware de logging
client.use((event, next) => {
console.log(`[${event.type}]`, event);
next(); // Appeler next() pour passer au middleware suivant ou au handler
});
// Middleware de persistance
client.use(async (event, next) => {
await saveEventToDatabase(event);
next();
});
// Middleware de replay
const eventQueue: ChatClientEvent[] = [];
client.use((event, next) => {
eventQueue.push(event);
next();
});
// Middleware de filtrage (bloque certains événements)
client.use((event, next) => {
if (event.type !== "typing") {
next(); // Ne passer que les événements non-typing
}
// Si on n'appelle pas next(), l'événement est bloqué
});Ordre d'exécution :
- Les middlewares sont exécutés dans l'ordre d'enregistrement
- Chaque middleware doit appeler
next()pour continuer - Si un middleware ne appelle pas
next(), l'événement est bloqué - Après tous les middlewares, les handlers sont appelés
Voir middlewares.example.ts pour plus d'exemples de middlewares.
Store Observable
Le store permet d'accéder aux données mises en cache et de s'abonner aux changements :
Méthodes du store
store.messages(conversationId): Récupère les messages d'une conversationstore.conversations(): Récupère toutes les conversationsstore.typing(conversationId): Récupère les utilisateurs en train de taper dans une conversationstore.presence(userId): Récupère la présence d'un utilisateur
Système observable
store.subscribe(listener): S'abonne aux changements du store. Le listener est appelé immédiatement avec le snapshot actuel, puis à chaque changement. Retourne une fonction pour se désabonner.store.getSnapshot(): Récupère un snapshot immuable de l'état actuel du store
// Exemple d'utilisation du store observable
const unsubscribe = client.store.subscribe((snapshot) => {
console.log("Store mis à jour:", snapshot);
// snapshot contient :
// - conversations: Conversation[]
// - messages: Record<string, ChatMessage[]>
// - typing: Record<string, string[]>
// - presence: Record<string, { status, lastSeenAt? }>
});
// Obtenir un snapshot à tout moment
const snapshot = client.store.getSnapshot();
const messages = snapshot.messages["conv-123"];
// Se désabonner
unsubscribe();Méthodes utilitaires
isSubscribedToConversation(conversationId): Vérifie si une conversation est abonnéeisSubscribedToUser(userId): Vérifie si un utilisateur est abonnégetSubscribedConversations(): Liste des conversations abonnéesgetSubscribedUsers(): Liste des utilisateurs abonnésunsubscribeAll(): Se désabonne de toutes les conversations et utilisateurs
Avantages du Store Observable
Le système de store observable offre plusieurs avantages :
- Réactivité automatique : Les composants React se mettent à jour automatiquement lorsque les données changent
- Performance : Seuls les composants abonnés sont re-rendus lors des changements
- Snapshot immuable :
getSnapshot()retourne toujours une copie des données, garantissant la cohérence - Compatibilité React : Compatible avec
useSyncExternalStorepour une intégration native - Type-safe : TypeScript vérifie automatiquement les types du snapshot et des handlers
Architecture
Le ChatClient fait partie de la couche infrastructure du système de chat :
ChatEngine (core) → Logique métier (use cases, validation)
↓
ChatClient (client) → Abonnements, cache, distribution d'événements, store observable
↓
UI Layer → Interfaces utilisateur (React, React Native, etc.)Le ChatClient utilise le RealTimeEventSubscriber (port) pour s'abonner aux événements. L'implémentation concrète du subscriber doit être fournie par la couche infrastructure (ex. WebSocket, Server-Sent Events, Supabase Realtime).
Intégration avec les packages Supabase
Le ChatClient est conçu pour fonctionner avec les packages d'implémentation Supabase :
@package/chat-supabase-repo: Fournit les repositories Supabase pour les conversations et messagescreateSupabaseConversationRepository(supabaseClient): Repository pour les conversationscreateSupabaseChatMessageRepository(supabaseClient): Repository pour les messages
@package/chat-supabase-events: Fournit les adaptateurs Supabase pour les événements temps réelcreateSupabaseRealTimeEventEmitter(supabaseClient): Pour émettre des événements (backend)createSupabaseRealTimeEventSubscriber(supabaseClient): Pour s'abonner aux événements (frontend)
Ces packages implémentent les ports définis dans @siran/chat-core et peuvent être utilisés directement avec le ChatEngine et le ChatClient.
Pour plus d'informations sur l'utilisation de ces packages, consultez :
Flux de données
1. Événement temps réel reçu (via Supabase Realtime) → ChatClient.handleEvent()
2. Cache mis à jour → ChatCache
3. Listeners du store notifiés → store.subscribe()
4. Composants React re-rendus → useSyncExternalStore
5. UI mise à jour automatiquement