// tiktok-push.js // 抖音小程序 WebSocket 推送模块(完整修复版) // 支持频道订阅、心跳保活、自动重连、私有频道认证 // ==================== 辅助函数 ==================== function getGlobalObject() { if (typeof global !== 'undefined') return global; if (typeof window !== 'undefined') return window; return this; } // ==================== CallbackRegistry ==================== function CallbackRegistry() { this._callbacks = {}; } function prefix(name) { return '_' + name; } CallbackRegistry.prototype.get = function (name) { var key = prefix(name); return this._callbacks[key] || []; }; CallbackRegistry.prototype.add = function (name, callback, context) { var key = prefix(name); if (!this._callbacks[key]) { this._callbacks[key] = []; } this._callbacks[key].push({ fn: callback, context: context }); }; CallbackRegistry.prototype.remove = function (name, callback, context) { if (!name && !callback && !context) { this._callbacks = {}; return; } var names = name ? [prefix(name)] : Object.keys(this._callbacks); for (var i = 0; i < names.length; i++) { var key = names[i]; if (!this._callbacks[key]) continue; if (callback || context) { var filtered = []; for (var j = 0; j < this._callbacks[key].length; j++) { var cb = this._callbacks[key][j]; if ((callback && callback !== cb.fn) || (context && context !== cb.context)) { filtered.push(cb); } } if (filtered.length === 0) { delete this._callbacks[key]; } else { this._callbacks[key] = filtered; } } else { delete this._callbacks[key]; } } }; // ==================== Dispatcher ==================== function Dispatcher(failThrough) { this.callbacks = new CallbackRegistry(); this.global_callbacks = []; this.failThrough = failThrough; } Dispatcher.prototype.on = function (eventName, callback, context) { this.callbacks.add(eventName, callback, context); return this; }; Dispatcher.prototype.on_global = function (callback) { if (typeof callback === 'function') { this.global_callbacks.push(callback); } return this; }; Dispatcher.prototype.off = function (eventName, callback, context) { this.callbacks.remove(eventName, callback, context); return this; }; Dispatcher.prototype.emit = function (eventName, data) { // 全局回调 if (Array.isArray(this.global_callbacks)) { for (var i = 0; i < this.global_callbacks.length; i++) { var gc = this.global_callbacks[i]; if (typeof gc === 'function') { try { gc(eventName, data); } catch (e) { console.error('global callback error:', e); } } } } // 获取事件回调列表 var callbacks = null; try { callbacks = this.callbacks && this.callbacks.get ? this.callbacks.get(eventName) : null; } catch (e) { console.warn('get callbacks error:', e); } if (callbacks && Array.isArray(callbacks) && callbacks.length > 0) { for (var j = 0; j < callbacks.length; j++) { var item = callbacks[j]; if (item && typeof item.fn === 'function') { var ctx = item.context || getGlobalObject(); try { item.fn.call(ctx, data); } catch (e) { console.error('callback error for event "' + eventName + '":', e); } } } } else if (this.failThrough && typeof this.failThrough === 'function') { this.failThrough(eventName, data); } return this; }; // ==================== Channel ==================== function Channel(connection, channel_name) { this.subscribed = false; this.dispatcher = new Dispatcher(); this.connection = connection; this.channelName = channel_name; this.subscribeCb = null; this.queue = []; // 绑定 dispatcher 方法,确保 this 指向 dispatcher this.on = this.dispatcher.on.bind(this.dispatcher); this.off = this.dispatcher.off.bind(this.dispatcher); this.emit = this.dispatcher.emit.bind(this.dispatcher); this.on_global = this.dispatcher.on_global.bind(this.dispatcher); } Channel.prototype.processSubscribe = function () { if (this.connection.state !== 'connected') { return; } if (this.subscribeCb) { this.subscribeCb(); } }; Channel.prototype.processQueue = function () { if (this.connection.state !== 'connected' || !this.subscribed) { return; } for (var i = 0; i < this.queue.length; i++) { try { this.queue[i](); } catch (e) { console.error('processQueue error:', e); } } this.queue = []; }; Channel.prototype.trigger = function (event, data) { if (event.indexOf('client-') !== 0) { throw new Error("Event '" + event + "' should start with 'client-'"); } var self = this; this.queue.push(function () { self.connection.send(JSON.stringify({ event: event, data: data, channel: self.channelName })); }); this.processQueue(); }; // ==================== Connection ==================== function Connection(options) { this.dispatcher = new Dispatcher(); // 绑定 dispatcher 方法 this.on = this.dispatcher.on.bind(this.dispatcher); this.off = this.dispatcher.off.bind(this.dispatcher); this.emit = this.dispatcher.emit.bind(this.dispatcher); this.on_global = this.dispatcher.on_global.bind(this.dispatcher); this.options = options; this.state = 'initialized'; this.doNotConnect = 0; this.reconnectInterval = 1000; this.socketTask = null; this.reconnectTimer = 0; this.socket_id = null; this.connect(); } Connection.prototype.updateNetworkState = function (state) { var old_state = this.state; this.state = state; if (old_state !== state) { this.emit('state_change', { previous: old_state, current: state }); } }; Connection.prototype.connect = function () { var self = this; this.doNotConnect = 0; if (this.state === 'connected') { console.log('networkState is "' + this.state + '" , no need to connect'); return; } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = 0; } this.closeAndClean(); var url = this.options.url; var app_key = this.options.app_key; var fullUrl = url + '/app/' + app_key; this.socketTask = tt.connectSocket({ url: fullUrl, success: function (res) { console.log('WebSocket connect success, socketTaskId:', res.socketTaskId); }, fail: function (err) { console.error('WebSocket connect fail:', err); self.updateNetworkState('disconnected'); if (!self.doNotConnect) { self.waitReconnect(); } if (self.options.onError) { self.options.onError(err); } } }); this.socketTask.onOpen(function (res) { self.reconnectInterval = 1000; if (self.doNotConnect) { self.updateNetworkState('disconnected'); self.socketTask.close(); return; } self.updateNetworkState('connected'); if (self.options.onOpen) { self.options.onOpen(res); } }); this.socketTask.onMessage(function (res) { if (self.options.onMessage) { self.options.onMessage(res); } }); this.socketTask.onClose(function (res) { console.log('WebSocket closed, code:', res); self.updateNetworkState('disconnected'); if (!self.doNotConnect) { self.waitReconnect(); } if (self.options.onClose) { self.options.onClose(res); } }); this.socketTask.onError(function (res) { console.log('WebSocket error, code:', res); self.updateNetworkState('disconnected'); if (!self.doNotConnect) { self.waitReconnect(); } if (self.options.onError) { self.options.onError(res); } }); this.updateNetworkState('connecting'); }; Connection.prototype.closeAndClean = function () { if (this.socketTask) { try { this.socketTask.close(); } catch (e) { console.warn('closeAndClean error:', e); } this.socketTask = null; } this.updateNetworkState('disconnected'); }; Connection.prototype.waitReconnect = function () { if (this.state === 'connected' || this.state === 'connecting') { return; } if (!this.doNotConnect) { this.updateNetworkState('connecting'); var self = this; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } this.reconnectTimer = setTimeout(function () { self.connect(); }, this.reconnectInterval); if (this.reconnectInterval < 1000) { this.reconnectInterval = 1000; } else { this.reconnectInterval = Math.min(this.reconnectInterval * 2, 2000); } } }; Connection.prototype.send = function (data) { if (this.state !== 'connected') { console.warn('state is "' + this.state + '", cannot send:', data); return; } this.socketTask.send({ data: data, fail: function (err) { console.error('send fail:', err); } }); }; Connection.prototype.close = function () { this.doNotConnect = 1; this.updateNetworkState('disconnected'); if (this.socketTask) { this.socketTask.close(); } }; // ==================== Push 客户端 ==================== function Push(options) { this.doNotConnect = 0; options = options || {}; options.heartbeat = options.heartbeat || 25000; options.pingTimeout = options.pingTimeout || 10000; this.config = options; this.uid = 0; this.channels = {}; this.connection = null; this.pingTimeoutTimer = 0; Push.instances.push(this); this.createConnection(); } Push.instances = []; Push.prototype.checkoutPing = function () { var self = this; setTimeout(function () { if (self.connection.state === 'connected') { self.connection.send('hi'); if (self.pingTimeoutTimer) { clearTimeout(self.pingTimeoutTimer); self.pingTimeoutTimer = 0; } self.pingTimeoutTimer = setTimeout(function () { self.connection.closeAndClean(); if (!self.connection.doNotConnect) { self.connection.waitReconnect(); } }, self.config.pingTimeout); } }, this.config.heartbeat); }; Push.prototype.channel = function (name) { return this.channels[name]; }; Push.prototype.allChannels = function () { return this.channels; }; Push.prototype.createConnection = function () { if (this.connection) { throw new Error('Connection already exists'); } var self = this; var url = this.config.url; var app_key = this.config.app_key; function updateSubscribed() { for (var i in self.channels) { if (self.channels[i]) { self.channels[i].subscribed = false; } } } this.connection = new Connection({ url: url, app_key: app_key, onOpen: function () { self.connection.state = 'connected'; self.checkoutPing(); }, onMessage: function (res) { if (self.pingTimeoutTimer) { clearTimeout(self.pingTimeoutTimer); self.pingTimeoutTimer = 0; } var params; try { params = JSON.parse(res.data); } catch (e) { console.warn('parse message error:', e, res.data); return; } var event = params.event; var channel_name = params.channel; if (event === 'pusher:pong') { self.checkoutPing(); return; } if (event === 'pusher:error') { throw new Error(params.data && params.data.message); } var data = params.data; var channel; if (event === 'pusher_internal:subscription_succeeded') { channel = self.channels[channel_name]; if (channel) { channel.subscribed = true; channel.processQueue(); channel.emit('pusher:subscription_succeeded'); } return; } if (event === 'pusher:connection_established') { self.connection.socket_id = data.socket_id; self.connection.state = 'connected'; self.subscribeAll(); return; } if (event && event.indexOf('pusher_internal') !== -1) { console.log('Event "' + event + '" not implemented'); return; } channel = self.channels[channel_name]; if (channel) { if (typeof data === 'string') { try { data = JSON.parse(data); } catch (e) {} } channel.emit(event, data); } }, onClose: function () { updateSubscribed(); }, onError: function () { updateSubscribed(); } }); }; Push.prototype.disconnect = function () { this.connection.doNotConnect = 1; this.connection.close(); }; Push.prototype.subscribeAll = function () { if (this.connection.state !== 'connected') { return; } for (var channel_name in this.channels) { if (this.channels[channel_name]) { this.channels[channel_name].processSubscribe(); } } }; Push.prototype.unsubscribe = function (channel_name) { if (this.channels[channel_name]) { delete this.channels[channel_name]; if (this.connection.state === 'connected') { this.connection.send(JSON.stringify({ event: 'pusher:unsubscribe', data: { channel: channel_name } })); } } }; Push.prototype.unsubscribeAll = function () { for (var channel_name in this.channels) { this.unsubscribe(channel_name); } this.channels = {}; }; Push.prototype.subscribe = function (channel_name) { if (this.channels[channel_name]) { return this.channels[channel_name]; } if (channel_name.indexOf('private-') === 0) { return createPrivateChannel(channel_name, this); } if (channel_name.indexOf('presence-') === 0) { return createPresenceChannel(channel_name, this); } return createChannel(channel_name, this); }; // ==================== 频道创建辅助函数 ==================== function createChannel(channel_name, push) { var channel = new Channel(push.connection, channel_name); push.channels[channel_name] = channel; channel.subscribeCb = function () { push.connection.send(JSON.stringify({ event: 'pusher:subscribe', data: { channel: channel_name } })); }; channel.processSubscribe(); return channel; } function createPrivateChannel(channel_name, push) { var channel = new Channel(push.connection, channel_name); push.channels[channel_name] = channel; channel.subscribeCb = function () { tt.request({ url: push.config.auth, method: 'POST', data: { channel_name: channel_name, socket_id: push.connection.socket_id }, success: function (res) { var data; try { data = res.data; } catch (e) { console.error('parse auth response error:', e); return; } data.channel = channel_name; push.connection.send(JSON.stringify({ event: 'pusher:subscribe', data: data })); }, fail: function (err) { console.error('auth request fail:', err); throw new Error(err.errMsg); } }); }; channel.processSubscribe(); return channel; } function createPresenceChannel(channel_name, push) { return createPrivateChannel(channel_name, push); } // ==================== 导出 ==================== module.exports = { Push: Push };