@supernick135/face-scanner-client
Version:
Node.js client library for ZKTeco face scanning devices integration with comprehensive API support
489 lines (412 loc) • 13.6 kB
JavaScript
/**
* WebSocket Client for Face Scanner API
* Real-time connection to Face Scanner WebSocket server
*/
const WebSocket = require('ws');
const EventEmitter = require('events');
class WebSocketClient extends EventEmitter {
constructor(options = {}) {
super();
this.config = {
url: options.wsUrl || options.serverUrl || 'ws://localhost:3002',
reconnect: options.reconnect !== false,
reconnectDelay: options.reconnectDelay || 5000,
maxReconnectAttempts: options.maxReconnectAttempts || 10,
heartbeatInterval: options.heartbeatInterval || 30000,
// Authentication configuration
authMethod: options.authMethod || 'auto', // 'jwt', 'apikey', 'auto'
token: options.token || options.jwt, // JWT token (legacy)
apiKey: options.apiKey, // API key (new)
clientType: options.clientType, // student, device, service, monitor
schoolId: options.schoolId,
deviceId: options.deviceId,
studentId: options.studentId,
...options
};
this.ws = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.heartbeatTimer = null;
this.subscriptions = new Set();
}
async connect() {
return new Promise((resolve, reject) => {
try {
// Build WebSocket URL with authentication
const wsUrl = this.buildWebSocketUrl();
this.ws = new WebSocket(wsUrl);
this.ws.on('open', async () => {
this.isConnected = true;
this.reconnectAttempts = 0;
// Try to authenticate if credentials are provided
if (this.config.apiKey || this.config.token) {
try {
await this.authenticateAfterConnection();
this.emit('authenticated');
} catch (authError) {
this.emit('authError', authError);
reject(authError);
return;
}
}
// Only start heartbeat if interval is set and > 0
if (this.config.heartbeatInterval && this.config.heartbeatInterval > 0) {
this.startHeartbeat();
}
this.emit('connected');
resolve();
});
this.ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(message);
} catch (error) {
this.emit('error', error);
}
});
// Handle native WebSocket pong
this.ws.on('pong', () => {
this.emit('pong');
});
this.ws.on('close', (code, reason) => {
this.isConnected = false;
this.stopHeartbeat();
this.emit('disconnected', { code, reason: reason.toString() });
if (this.config.reconnect && !this.isShuttingDown) {
this.handleReconnect();
}
});
this.ws.on('error', (error) => {
this.emit('error', error);
if (!this.isConnected) {
reject(error);
}
});
// Connection timeout
setTimeout(() => {
if (!this.isConnected) {
reject(new Error('WebSocket connection timeout'));
}
}, 10000);
} catch (error) {
reject(error);
}
});
}
disconnect() {
this.isShuttingDown = true;
this.stopHeartbeat();
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
}
}
handleMessage(message) {
this.emit('message', message);
// Handle specific message types
switch (message.type) {
case 'auth_success':
this.emit('auth_success', message.payload);
break;
case 'auth_error':
this.emit('auth_error', message.payload);
break;
case 'face_detected':
this.emit('faceDetected', message.payload || message.data);
break;
case 'scan_complete':
this.emit('scanComplete', message.payload || message.data);
break;
case 'device_status':
this.emit('deviceStatus', message.payload || message.data);
break;
case 'recovery_progress':
this.emit('recoveryProgress', message.payload || message.data);
break;
case 'queue_stats':
this.emit('queueStats', message.payload || message.data);
break;
case 'system_alert':
this.emit('systemAlert', message.payload || message.data);
break;
case 'room_update':
this.emit('roomUpdate', message.payload);
break;
case 'client_connected':
this.emit('clientConnected', message.payload);
break;
case 'client_disconnected':
this.emit('clientDisconnected', message.payload);
break;
case 'error':
this.emit('serverError', message.payload);
break;
case 'pong':
this.emit('pong', message.payload || message.data);
break;
default:
this.emit('unknownMessage', message);
}
}
send(message) {
if (!this.isConnected || !this.ws) {
throw new Error('WebSocket not connected');
}
const data = typeof message === 'string' ? message : JSON.stringify(message);
this.ws.send(data);
}
// Subscribe to specific events
subscribe(eventType, deviceId = null) {
const subscription = { type: eventType, deviceId };
this.subscriptions.add(JSON.stringify(subscription));
this.send({
type: 'subscribe',
payload: {
eventType,
deviceId
},
timestamp: Date.now()
});
}
// Unsubscribe from events
unsubscribe(eventType, deviceId = null) {
const subscription = { type: eventType, deviceId };
this.subscriptions.delete(JSON.stringify(subscription));
this.send({
type: 'unsubscribe',
payload: {
eventType,
deviceId
},
timestamp: Date.now()
});
}
// Send WebSocket native ping
ping() {
if (this.ws && this.isConnected) {
this.ws.ping();
}
}
// Send application-level ping message
sendPingMessage() {
this.send({
type: 'ping',
payload: {
status: 'active'
},
timestamp: Date.now()
});
}
// Start heartbeat
startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
if (this.isConnected) {
// Use WebSocket native ping for connection health check
this.ping();
}
}, this.config.heartbeatInterval);
}
// Stop heartbeat
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// Build WebSocket URL with authentication parameters
buildWebSocketUrl() {
let baseUrl = this.config.url;
// Ensure URL starts with ws:// or wss://
if (!baseUrl.startsWith('ws://') && !baseUrl.startsWith('wss://')) {
baseUrl = 'ws://' + baseUrl;
}
const url = new URL(baseUrl);
// Add authentication based on method
if (this.config.authMethod === 'apikey' || (this.config.authMethod === 'auto' && this.config.apiKey)) {
if (this.config.apiKey) {
url.searchParams.set('apikey', this.config.apiKey);
}
// Add client identification parameters
if (this.config.clientType) {
url.searchParams.set('clientType', this.config.clientType);
}
if (this.config.schoolId) {
url.searchParams.set('schoolId', this.config.schoolId);
}
if (this.config.deviceId) {
url.searchParams.set('deviceId', this.config.deviceId);
}
if (this.config.studentId) {
url.searchParams.set('studentId', this.config.studentId);
}
} else if (this.config.authMethod === 'jwt' || (this.config.authMethod === 'auto' && this.config.token)) {
if (this.config.token) {
url.searchParams.set('token', this.config.token);
}
}
return url.toString();
}
// Authenticate after connection using message-based authentication
async authenticateAfterConnection() {
return new Promise((resolve, reject) => {
// Prepare authentication payload
const authPayload = {
clientType: this.config.clientType
};
// Add authentication credentials
if (this.config.apiKey) {
authPayload.apiKey = this.config.apiKey;
} else if (this.config.token) {
authPayload.token = this.config.token;
}
// Add client identification
if (this.config.schoolId) authPayload.schoolId = this.config.schoolId;
if (this.config.deviceId) authPayload.deviceId = this.config.deviceId;
if (this.config.studentId) authPayload.studentId = this.config.studentId;
// Set up one-time listeners for auth response
const authSuccessHandler = (data) => {
this.removeListener('auth_error', authErrorHandler);
this.removeListener('error', errorHandler);
resolve(data);
};
const authErrorHandler = (data) => {
this.removeListener('auth_success', authSuccessHandler);
this.removeListener('error', errorHandler);
reject(new Error(data.message || 'Authentication failed'));
};
const errorHandler = (error) => {
this.removeListener('auth_success', authSuccessHandler);
this.removeListener('auth_error', authErrorHandler);
reject(error);
};
this.once('auth_success', authSuccessHandler);
this.once('auth_error', authErrorHandler);
this.once('error', errorHandler);
// Send authentication message
this.send({
type: 'auth',
payload: authPayload,
timestamp: Date.now()
});
});
}
// Handle reconnection
async handleReconnect() {
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
this.emit('reconnectFailed');
return;
}
this.reconnectAttempts++;
this.emit('reconnecting', this.reconnectAttempts);
setTimeout(async () => {
try {
await this.connect();
// Re-subscribe to previous subscriptions
for (const subscriptionStr of this.subscriptions) {
const subscription = JSON.parse(subscriptionStr);
this.subscribe(subscription.type, subscription.deviceId);
}
} catch (error) {
this.emit('error', error);
}
}, this.config.reconnectDelay * this.reconnectAttempts);
}
// Get connection status
getConnectionStatus() {
return {
connected: this.isConnected,
reconnectAttempts: this.reconnectAttempts,
subscriptions: Array.from(this.subscriptions).map(s => JSON.parse(s)),
authMethod: this.config.authMethod,
clientType: this.config.clientType,
schoolId: this.config.schoolId,
deviceId: this.config.deviceId
};
}
// Convenience methods for different client types
// Join room (for non-student clients)
joinRoom(roomName) {
this.send({
type: 'join_room',
payload: { room: roomName },
timestamp: Date.now()
});
}
// Leave room
leaveRoom(roomName) {
this.send({
type: 'leave_room',
payload: { room: roomName },
timestamp: Date.now()
});
}
// Send face scan event (for device clients)
sendFaceScanEvent(eventType, eventData) {
const payload = {
schoolId: this.config.schoolId,
deviceId: this.config.deviceId,
timestamp: Date.now(),
...eventData
};
this.send({
type: eventType,
payload,
timestamp: Date.now()
});
}
// Request server stats (admin only)
requestStats() {
this.send({
type: 'get_stats',
payload: {},
timestamp: Date.now()
});
}
// Kick client (admin only)
kickClient(targetClientId, reason) {
this.send({
type: 'kick_client',
payload: {
clientId: targetClientId,
reason: reason
},
timestamp: Date.now()
});
}
// Helper method to create client instances for different types
static createDeviceClient(options) {
return new WebSocketClient({
clientType: 'device',
authMethod: 'apikey',
...options
});
}
static createStudentClient(options) {
return new WebSocketClient({
clientType: 'student',
authMethod: 'apikey',
...options
});
}
static createMonitorClient(options) {
return new WebSocketClient({
clientType: 'monitor',
authMethod: 'apikey',
...options
});
}
static createServiceClient(options) {
return new WebSocketClient({
clientType: 'service',
authMethod: 'apikey',
...options
});
}
static createAdminClient(options) {
return new WebSocketClient({
clientType: 'admin',
authMethod: 'jwt',
...options
});
}
}
module.exports = WebSocketClient;