UNPKG

precise-time-ntp

Version:

⏰ Simple NTP time sync for Node.js - Auto-drift, WebSocket & HTML clocks

628 lines (544 loc) 18 kB
const ntpClient = require("ntp-client"); const WebSocket = require("ws"); const EventEmitter = require("events"); /** * TimeSync - Class for precise time synchronization * Simple and intuitive API for synchronizing time with NTP servers */ class TimeSync extends EventEmitter { constructor(options = {}) { super(); // Synchronization state this.isSync = false; this.lastSyncTime = null; this.systemOffset = 0; this.syncDate = null; // Smooth correction this.targetOffset = 0; // Target offset to reach this.currentOffset = 0; // Current offset (gradually corrected) this.correctionInProgress = false; this.correctionStartTime = null; // For correction timeout // Default configuration this.config = { servers: [ "pool.ntp.org", "time.google.com", "time.cloudflare.com", ], timeout: 5000, retries: 3, autoSync: false, autoSyncInterval: 300000, // 5 minutes // New options for smooth correction smoothCorrection: true, // Enable smooth correction maxCorrectionJump: 1000, // Max brutal correction (1s) correctionRate: 0.1, // Smooth correction rate (10%/sync) maxOffsetThreshold: 5000, // Threshold to force brutal correction (5s) coherenceValidation: true, // Server coherence validation ...options }; // WebSocket for real-time (optional) this.wsServer = null; this.wsClients = new Set(); // Auto-sync this.autoSyncTimer = null; this.setupEventHandlers(); } setupEventHandlers() { this.on('sync', (data) => { console.log(`✅ Synchronized with ${data.server} (offset: ${data.offset}ms)`); }); this.on('error', (error) => { console.log(`❌ Synchronization error: ${error.message}`); }); // Advanced events this.on('coherenceWarning', (data) => { console.log(`⚠️ Server coherence issue: variance ${data.variance}ms`); }); this.on('driftWarning', (data) => { console.log(`📈 Long elapsed time: ${(data.elapsed / 60000).toFixed(1)} minutes`); }); } /** * Synchronize time with NTP server * @param {Object} options - Synchronization options * @returns {Promise<Object>} Synchronization info */ async sync(options = {}) { const config = { ...this.config, ...options }; const serverResults = []; // Test multiple servers for coherence validation const serversToTest = config.coherenceValidation !== false ? config.servers.slice(0, Math.min(3, config.servers.length)) : config.servers; for (const server of serversToTest) { try { const ntpTime = await this.getNtpTime(server, config.timeout); const systemTime = Date.now(); const newOffset = ntpTime.getTime() - systemTime; serverResults.push({ server, offset: newOffset, ntpTime, systemTime }); } catch (error) { this.emit('error', { server, error }); continue; // Try next server } } if (serverResults.length === 0) { throw new Error('Unable to synchronize with any NTP server'); } // Coherence validation between servers let selectedResult = serverResults[0]; if (serverResults.length > 1) { const offsets = serverResults.map(r => r.offset); const variance = Math.max(...offsets) - Math.min(...offsets); if (variance > 100) { // Variance > 100ms is suspicious this.emit('coherenceWarning', { variance, servers: serverResults.map(r => ({ server: r.server, offset: r.offset })) }); // Use median for better robustness offsets.sort((a, b) => a - b); const medianOffset = offsets[Math.floor(offsets.length / 2)]; selectedResult = serverResults.find(r => r.offset === medianOffset) || selectedResult; } } const newOffset = selectedResult.offset; // Smooth correction management const isFirstSync = !this.isSync; const offsetDiff = Math.abs(newOffset - this.currentOffset); if (isFirstSync || !config.smoothCorrection || offsetDiff <= config.maxCorrectionJump || offsetDiff >= config.maxOffsetThreshold) { // Brutal correction this.systemOffset = newOffset; this.currentOffset = newOffset; this.targetOffset = newOffset; this.correctionInProgress = false; this.correctionStartTime = null; // Reset correction timer } else { // Smooth correction this.targetOffset = newOffset; this.systemOffset = newOffset; // Keep real offset for stats this.correctionInProgress = true; this.correctionStartTime = null; // Will be set in applyGradualCorrection this.applyGradualCorrection(config.correctionRate); } this.isSync = true; this.lastSyncTime = performance.now(); this.syncDate = new Date(); const result = { server: selectedResult.server, offset: this.systemOffset, correctedOffset: this.currentOffset, time: selectedResult.ntpTime, systemTime: new Date(selectedResult.systemTime), gradualCorrection: this.correctionInProgress, offsetDiff: isFirstSync ? 0 : offsetDiff, serverResults: serverResults.length > 1 ? serverResults : undefined, coherenceVariance: serverResults.length > 1 ? Math.max(...serverResults.map(r => r.offset)) - Math.min(...serverResults.map(r => r.offset)) : 0 }; this.emit('sync', result); // Start auto-sync if requested if (config.autoSync && !this.autoSyncTimer) { this.startAutoSync(config.autoSyncInterval); } return result; } /** * Gets NTP time from a server * @private */ getNtpTime(server, timeout = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Timeout after ${timeout}ms`)); }, timeout); ntpClient.getNetworkTime(server, 123, (err, date) => { clearTimeout(timer); if (err) { reject(new Error(`NTP error: ${err.message}`)); } else { resolve(date); } }); }); } /** * Returns current synchronized time * @returns {Date} Precise time */ now() { if (!this.isSync) { throw new Error('Clock not synchronized. Call sync() first.'); } const currentPerf = performance.now(); const elapsed = currentPerf - this.lastSyncTime; // Detect significant drift for automatic recalculation if (elapsed > 3600000) { // More than 1 hour since last sync console.log('⚠️ Long elapsed time detected, consider re-syncing'); this.emit('driftWarning', { elapsed }); } // Use gradually corrected offset if available const activeOffset = this.correctionInProgress ? this.currentOffset : this.systemOffset; // More precise calculation avoiding error accumulation const baseTime = this.syncDate.getTime(); const adjustedTime = baseTime + elapsed + activeOffset; return new Date(adjustedTime); } /** * Returns time in ISO format * @returns {string} ISO timestamp */ timestamp() { return this.now().toISOString(); } /** * Returns the offset from system time * @returns {number} Offset in milliseconds */ offset() { if (!this.isSync) return 0; // Return gradually corrected offset if available return this.correctionInProgress ? this.currentOffset : this.systemOffset; } /** * Checks if clock is synchronized * @returns {boolean} */ isSynchronized() { return this.isSync; } /** * Returns synchronization statistics * @returns {Object} */ stats() { return { synchronized: this.isSync, lastSync: this.syncDate, offset: this.systemOffset, correctedOffset: this.currentOffset, targetOffset: this.targetOffset, correctionInProgress: this.correctionInProgress, uptime: this.isSync ? performance.now() - this.lastSyncTime : 0, config: { smoothCorrection: this.config.smoothCorrection, maxCorrectionJump: this.config.maxCorrectionJump, correctionRate: this.config.correctionRate, maxOffsetThreshold: this.config.maxOffsetThreshold } }; } /** * Starts automatic synchronization * @param {number} interval - Interval in milliseconds */ startAutoSync(interval = 300000) { if (this.autoSyncTimer) { clearInterval(this.autoSyncTimer); } this.autoSyncTimer = setInterval(() => { this.sync().catch(err => { this.emit('error', err); }); }, interval); console.log(`🔄 Auto-sync enabled (${interval / 1000}s)`); } /** * Stops automatic synchronization */ stopAutoSync() { if (this.autoSyncTimer) { clearInterval(this.autoSyncTimer); this.autoSyncTimer = null; console.log('🛑 Auto-sync disabled'); } } /** * Starts a WebSocket server to broadcast time in real-time * @param {number} port - WebSocket server port * @returns {number} Port used */ startWebSocketServer(port = 8080) { if (this.wsServer) { throw new Error('WebSocket server already started'); } this.wsServer = new WebSocket.Server({ port }); this.wsServer.on('connection', (ws) => { this.wsClients.add(ws); console.log(`🔌 WebSocket client connected (${this.wsClients.size} total)`); // Send time immediately if (this.isSync) { ws.send(JSON.stringify({ type: 'time', data: { timestamp: this.timestamp(), offset: this.offset(), synchronized: true } })); } ws.on('close', () => { this.wsClients.delete(ws); console.log(`🔌 WebSocket client disconnected (${this.wsClients.size} remaining)`); }); ws.on('message', (message) => { try { const data = JSON.parse(message); this.handleWebSocketMessage(ws, data); } catch (error) { ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON format' })); } }); }); // Broadcast time every second this.wsTimer = setInterval(() => { if (this.isSync && this.wsClients.size > 0) { this.broadcastTime(); } }, 1000); console.log(`🌐 WebSocket server started on port ${port}`); return port; } /** * Stops the WebSocket server */ stopWebSocketServer() { if (this.wsTimer) { clearInterval(this.wsTimer); this.wsTimer = null; } if (this.wsServer) { this.wsServer.close(); this.wsClients.clear(); this.wsServer = null; console.log('🌐 WebSocket server stopped'); } } /** * Handles WebSocket messages * @private */ handleWebSocketMessage(ws, data) { switch (data.type) { case 'getTime': if (this.isSync) { ws.send(JSON.stringify({ type: 'time', data: { timestamp: this.timestamp(), offset: this.offset(), synchronized: true } })); } else { ws.send(JSON.stringify({ type: 'error', message: 'Clock not synchronized' })); } break; case 'sync': this.sync().then(() => { ws.send(JSON.stringify({ type: 'syncComplete', message: 'Synchronization complete' })); }).catch(error => { ws.send(JSON.stringify({ type: 'error', message: error.message })); }); break; default: ws.send(JSON.stringify({ type: 'error', message: 'Unknown command. Use: getTime, sync' })); } } /** * Broadcasts time to all WebSocket clients * @private */ broadcastTime() { const message = JSON.stringify({ type: 'time', data: { timestamp: this.timestamp(), offset: this.offset(), synchronized: this.isSync } }); this.wsClients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(message); } }); } /** * Formats a date/time * @param {Date|string|number} date - Date to format * @param {string} format - Output format * @returns {string} */ format(date = null, format = 'iso') { const time = date ? new Date(date) : this.now(); switch (format) { case 'iso': return time.toISOString(); case 'locale': return time.toLocaleString('en-US'); case 'timestamp': return time.getTime().toString(); case 'utc': return time.toUTCString(); case 'date': return time.toLocaleDateString('en-US'); case 'time': return time.toLocaleTimeString('en-US'); default: return time.toString(); } } /** * Calculates the difference between two dates * @param {Date|string|number} date1 * @param {Date|string|number} date2 * @returns {number} Difference in milliseconds */ diff(date1, date2 = null) { const d1 = new Date(date1); const d2 = date2 ? new Date(date2) : this.now(); return Math.abs(d2.getTime() - d1.getTime()); } /** * Displays a message with precise time * @param {string} message */ log(message) { const time = this.isSync ? this.timestamp() : new Date().toISOString(); console.log(`[${time}] ${message}`); } /** * Applies gradual offset correction * @private */ applyGradualCorrection(rate = 0.1) { if (!this.correctionInProgress) return; const diff = this.targetOffset - this.currentOffset; // Convergence threshold to avoid infinite oscillations if (Math.abs(diff) < 0.5) { // Convergence within 0.5ms this.currentOffset = this.targetOffset; this.correctionInProgress = false; this.emit('correctionComplete', { finalOffset: this.currentOffset, targetReached: true, converged: true }); return; } // Timeout verification to avoid infinite corrections if (!this.correctionStartTime) { this.correctionStartTime = performance.now(); } const elapsed = performance.now() - this.correctionStartTime; if (elapsed > 30000) { // 30 second timeout console.log('⚠️ Correction timeout, applying final offset'); this.currentOffset = this.targetOffset; this.correctionInProgress = false; this.correctionStartTime = null; this.emit('correctionComplete', { finalOffset: this.currentOffset, targetReached: true, timeout: true }); return; } const correction = diff * rate; this.currentOffset += correction; // Adaptive interval based on correction size const nextInterval = Math.max(50, Math.min(200, Math.abs(diff) * 0.1)); // Schedule next correction setTimeout(() => { this.applyGradualCorrection(rate); }, nextInterval); } /** * Enables or disables gradual correction * @param {boolean} enabled - Enable gradual correction * @param {Object} options - Correction options */ setSmoothCorrection(enabled, options = {}) { this.config.smoothCorrection = enabled; if (options.maxCorrectionJump !== undefined) { this.config.maxCorrectionJump = options.maxCorrectionJump; } if (options.correctionRate !== undefined) { this.config.correctionRate = options.correctionRate; } if (options.maxOffsetThreshold !== undefined) { this.config.maxOffsetThreshold = options.maxOffsetThreshold; } console.log(`🔧 Smooth correction: ${enabled ? 'enabled' : 'disabled'}`); if (enabled) { console.log(` - Max jump: ${this.config.maxCorrectionJump}ms`); console.log(` - Rate: ${this.config.correctionRate * 100}%`); console.log(` - Brutal threshold: ${this.config.maxOffsetThreshold}ms`); } } /** * Forces brutal correction (ignores gradual correction) */ forceCorrection() { if (this.correctionInProgress) { this.currentOffset = this.targetOffset; this.correctionInProgress = false; this.emit('correctionComplete', { finalOffset: this.currentOffset, forced: true }); console.log('⚡ Forced correction applied'); } } } // Global instance for simple usage const timeSync = new TimeSync(); // Simple API - direct functions const api = { // Main methods sync: (options) => timeSync.sync(options), now: () => timeSync.now(), timestamp: () => timeSync.timestamp(), offset: () => timeSync.offset(), stats: () => timeSync.stats(), isSynchronized: () => timeSync.isSynchronized(), // Auto-sync startAutoSync: (interval) => timeSync.startAutoSync(interval), stopAutoSync: () => timeSync.stopAutoSync(), // Gradual correction setSmoothCorrection: (enabled, options) => timeSync.setSmoothCorrection(enabled, options), forceCorrection: () => timeSync.forceCorrection(), // WebSocket startWebSocketServer: (port) => timeSync.startWebSocketServer(port), stopWebSocketServer: () => timeSync.stopWebSocketServer(), // Utilities format: (date, format) => timeSync.format(date, format), diff: (date1, date2) => timeSync.diff(date1, date2), log: (message) => timeSync.log(message), // Events on: (event, callback) => timeSync.on(event, callback), off: (event, callback) => timeSync.off(event, callback), // Class for advanced usage TimeSync }; module.exports = api;