feat: add REST + Socket.IO fallback for Python server

Add RestSocketAdapter that uses:
- REST API for actions (login, logout, terminals)
- Socket.IO for real-time events (IpbxEvent)

ConnectionManager now tries SignalR first (.NET server),
then falls back to REST+SocketIO (Python server).

This enables the client to work with both servers during migration.
This commit is contained in:
Pierre Marx
2025-11-24 16:05:30 -05:00
parent 60bad93f1e
commit 2258013394
3 changed files with 329 additions and 27 deletions

View File

@@ -1,60 +1,66 @@
/**
* Gestionnaire de connexion avec fallback SignalR → WebSocket
* Essaie d'abord SignalR, puis bascule sur WebSocket si nécessaire
* Gestionnaire de connexion avec fallback SignalR → REST + Socket.IO
*
* 1. Essaie d'abord SignalR (serveur .NET legacy)
* 2. Si SignalR échoue, bascule sur REST + Socket.IO (serveur Python)
*/
const signalR = require('@microsoft/signalr');
const WebSocketAdapter = require('./websocket-adapter');
const RestSocketAdapter = require('./rest-socket-adapter');
class ConnectionManager {
constructor(serverUrl, options = {}) {
this.serverUrl = serverUrl;
this.options = options;
this.connection = null;
this.isUsingWebSocketFallback = false;
this.connectionType = 'none';
this.connectionType = 'none'; // 'none', 'SignalR', 'REST+SocketIO'
}
/**
* Établit la connexion en essayant SignalR puis WebSocket
* Établit la connexion en essayant SignalR puis REST + Socket.IO
*/
async connect() {
console.log('🔌 Tentative de connexion au serveur...');
// 1. Essayer SignalR d'abord
// 1. Essayer SignalR d'abord (serveur .NET)
try {
console.log('📡 Essai avec SignalR...');
this.connection = await this.createSignalRConnection();
this.connection = this._createSignalRConnection();
await this.connection.start();
this.connectionType = 'SignalR';
this.isUsingWebSocketFallback = false;
console.log('✅ Connexion SignalR établie');
console.log('✅ Connexion SignalR établie (serveur .NET)');
return this.connection;
} catch (signalRError) {
console.warn('⚠️ SignalR indisponible:', signalRError.message);
console.log('🔄 Basculement vers WebSocket...');
console.log('🔄 Basculement vers REST + Socket.IO...');
}
// 2. Fallback vers WebSocket
// 2. Fallback vers REST + Socket.IO (serveur Python)
try {
this.connection = new WebSocketAdapter(this.serverUrl, this.options);
this.connection = new RestSocketAdapter(this.serverUrl, this.options);
await this.connection.start();
this.connectionType = 'WebSocket';
this.isUsingWebSocketFallback = true;
console.log('✅ Connexion WebSocket établie (mode fallback)');
this.connectionType = 'REST+SocketIO';
console.log('✅ Connexion REST + Socket.IO établie (serveur Python)');
return this.connection;
} catch (wsError) {
console.error('❌ Impossible de se connecter (SignalR et WebSocket ont échoué)');
throw wsError;
} catch (restError) {
console.error('❌ REST + Socket.IO indisponible:', restError.message);
throw new Error('Impossible de se connecter (SignalR et REST+SocketIO ont échoué)');
}
}
/**
* Crée une connexion SignalR
*/
createSignalRConnection() {
const url = `http://${this.serverUrl}/signalR`;
console.log(`Creating SignalR connection to: ${url}`);
_createSignalRConnection() {
let url = this.serverUrl;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `http://${url}`;
}
if (!url.includes('/signalR')) {
url = `${url}/signalR`;
}
console.log(`📡 URL SignalR: ${url}`);
return new signalR.HubConnectionBuilder()
.withUrl(url)
@@ -87,7 +93,8 @@ class ConnectionManager {
getConnectionInfo() {
return {
type: this.connectionType,
isWebSocketFallback: this.isUsingWebSocketFallback,
isSignalR: this.connectionType === 'SignalR',
isRestSocketIO: this.connectionType === 'REST+SocketIO',
isConnected: this.connection && this.connection.state === 'Connected',
serverUrl: this.serverUrl
};
@@ -112,6 +119,15 @@ class ConnectionManager {
}
this.connection.on(eventName, handler);
}
/**
* Méthode helper pour retirer un handler d'événement
*/
off(eventName) {
if (this.connection && this.connection.off) {
this.connection.off(eventName);
}
}
}
module.exports = ConnectionManager;

View File

@@ -317,7 +317,8 @@ async function startSignalRConnection() {
console.log(`Connexion établie via ${connectionInfo.type}`);
logSignalR(`✅ Connexion établie avec succès (${connectionInfo.type})`, {
connectionType: connectionInfo.type,
isWebSocketFallback: connectionInfo.isWebSocketFallback,
isSignalR: connectionInfo.isSignalR,
isRestSocketIO: connectionInfo.isRestSocketIO,
status: 'connected',
serverUrl: connectionInfo.serverUrl
});

285
rest-socket-adapter.js Normal file
View File

@@ -0,0 +1,285 @@
/**
* Adaptateur REST + Socket.IO pour le serveur Python
*
* Utilise REST pour les actions (login, logout, terminaux)
* et Socket.IO pour les événements temps réel (IpbxEvent)
*
* Émule l'API SignalR pour compatibilité avec le code existant
*/
const io = require('socket.io-client');
class RestSocketAdapter {
constructor(serverUrl, options = {}) {
// Nettoyer l'URL
this.serverUrl = serverUrl;
if (this.serverUrl.includes('/signalR')) {
this.serverUrl = this.serverUrl.replace('/signalR', '');
}
if (!this.serverUrl.startsWith('http://') && !this.serverUrl.startsWith('https://')) {
this.serverUrl = `http://${this.serverUrl}`;
}
this.socket = null;
this.connected = false;
this.handlers = new Map();
this.options = options;
// Token JWT pour les requêtes authentifiées
this.authToken = null;
this.currentAgent = null;
}
/**
* Émule connection.start() de SignalR
* Établit la connexion Socket.IO pour les événements
*/
async start() {
return new Promise((resolve, reject) => {
console.log(`[RestSocketAdapter] Connexion à ${this.serverUrl}...`);
// Vérifier d'abord que le serveur Python répond
fetch(`${this.serverUrl}/health`)
.then(response => {
if (!response.ok) {
throw new Error(`Health check failed: ${response.status}`);
}
return response.json();
})
.then(health => {
console.log(`[RestSocketAdapter] Serveur Python détecté:`, health);
// Connecter Socket.IO pour les événements temps réel
this.socket = io(this.serverUrl, {
path: '/socket.io/',
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 2000,
...this.options
});
this.socket.on('connect', () => {
console.log('[RestSocketAdapter] Socket.IO connecté');
this.connected = true;
resolve();
});
this.socket.on('connect_error', (error) => {
console.error('[RestSocketAdapter] Erreur Socket.IO:', error.message);
// On reste connecté même si Socket.IO échoue (REST fonctionne)
if (!this.connected) {
this.connected = true;
console.log('[RestSocketAdapter] Mode REST seul (Socket.IO indisponible)');
resolve();
}
});
this.socket.on('disconnect', (reason) => {
console.log('[RestSocketAdapter] Socket.IO déconnecté:', reason);
});
// Configurer les handlers d'événements existants
this.handlers.forEach((handler, eventName) => {
this.socket.on(eventName, (...args) => {
console.log(`[RestSocketAdapter] Événement reçu: ${eventName}`, args);
handler(eventName, args);
});
});
// Timeout de connexion
setTimeout(() => {
if (!this.connected) {
reject(new Error('Timeout de connexion au serveur Python'));
}
}, 10000);
})
.catch(error => {
console.error('[RestSocketAdapter] Serveur Python non disponible:', error.message);
reject(error);
});
});
}
/**
* Émule connection.stop() de SignalR
*/
async stop() {
if (this.socket) {
this.socket.disconnect();
}
this.connected = false;
this.authToken = null;
this.currentAgent = null;
console.log('[RestSocketAdapter] Déconnecté');
}
/**
* Émule connection.invoke() de SignalR
* Redirige vers les endpoints REST appropriés
*/
async invoke(methodName, ...args) {
if (!this.connected) {
throw new Error('Non connecté au serveur');
}
console.log(`[RestSocketAdapter] invoke: ${methodName}`, args);
switch (methodName) {
case 'GetTerminalListByServiceProvider':
return this._getTerminalList(args[0]);
case 'AgentLogin':
return this._agentLogin(args[0], args[1], args[2]);
case 'AgentLogoff':
return this._agentLogoff(args[0]);
default:
console.warn(`[RestSocketAdapter] Méthode non implémentée: ${methodName}`);
throw new Error(`Méthode non supportée: ${methodName}`);
}
}
/**
* GET /api/v1/terminals/{serviceProvider}
*/
async _getTerminalList(serviceProvider) {
const response = await fetch(
`${this.serverUrl}/api/v1/terminals/${encodeURIComponent(serviceProvider)}`
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || 'Erreur récupération terminaux');
}
const data = await response.json();
// Retourner juste la liste des numéros de terminaux (format SignalR)
return data.terminals.map(t => t.number || t);
}
/**
* POST /api/v1/auth/login
*/
async _agentLogin(email, password, terminal) {
const response = await fetch(`${this.serverUrl}/api/v1/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
access_code: email,
password: password,
terminal: terminal,
force_disconnect: false
})
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || 'Erreur de connexion');
}
const data = await response.json();
// Stocker le token si présent
if (data.token) {
this.authToken = data.token;
}
this.currentAgent = data;
// Enregistrer le terminal sur Socket.IO pour recevoir les événements
if (this.socket && this.socket.connected) {
this.socket.emit('register', {
terminal: terminal,
agentId: data.accessCode || data.access_code
});
}
// Retourner au format attendu par le client (compatible SignalR)
return {
accessCode: data.accessCode || data.access_code,
firstName: data.firstName || data.first_name,
lastName: data.lastName || data.last_name,
connList: data.connList || data.conn_list || []
};
}
/**
* POST /api/v1/auth/logout
*/
async _agentLogoff(accessCode) {
// Désenregistrer du Socket.IO
if (this.socket && this.socket.connected) {
this.socket.emit('unregister', { terminal: this.currentAgent?.terminal });
}
const headers = {
'Content-Type': 'application/json',
};
// Ajouter le token si disponible
if (this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
const response = await fetch(`${this.serverUrl}/api/v1/auth/logout`, {
method: 'POST',
headers: headers,
body: JSON.stringify({ access_code: accessCode })
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || 'Erreur de déconnexion');
}
this.authToken = null;
this.currentAgent = null;
return { success: true };
}
/**
* Émule connection.on() de SignalR
*/
on(eventName, handler) {
console.log(`[RestSocketAdapter] Enregistrement handler: ${eventName}`);
this.handlers.set(eventName, handler);
// Si Socket.IO est déjà connecté, ajouter le handler
if (this.socket) {
this.socket.on(eventName, (...args) => {
console.log(`[RestSocketAdapter] Événement reçu: ${eventName}`, args);
handler(eventName, args);
});
}
}
/**
* Émule connection.off() de SignalR
*/
off(eventName) {
this.handlers.delete(eventName);
if (this.socket) {
this.socket.off(eventName);
}
}
/**
* État de la connexion (émule SignalR HubConnectionState)
*/
get state() {
return this.connected ? 'Connected' : 'Disconnected';
}
/**
* Identifie ce type d'adaptateur
*/
get isRestSocketAdapter() {
return true;
}
}
module.exports = RestSocketAdapter;