import { toSockjsUrl, toWebsocketUrl, } from "./urls.js"; import { StreamClientCommon } from "./common.js"; // Statically importing SockJS here will prevent native WebSocket usage // below (in favor of SockJS), but will ensure maximum compatibility for // clients stuck in unusual networking environments. import SockJS from "./sockjs-1.6.1-min-.js"; export class ClientStream extends StreamClientCommon { // @param url {String} URL to Meteor app // "http://subdomain.meteor.com/" or "/" or // "ddp+sockjs://foo-**.meteor.com/sockjs" constructor(url, options) { super(options); this._initCommon(this.options); //// Constants // how long between hearing heartbeat from the server until we declare // the connection dead. heartbeats come every 45s (stream_server.js) // // NOTE: this is a older timeout mechanism. We now send heartbeats at // the DDP level (https://github.com/meteor/meteor/pull/1865), and // expect those timeouts to kill a non-responsive connection before // this timeout fires. This is kept around for compatibility (when // talking to a server that doesn't support DDP heartbeats) and can be // removed later. this.HEARTBEAT_TIMEOUT = 100 * 1000; this.rawUrl = url; this.socket = null; this.lastError = null; this.heartbeatTimer = null; // Listen to global 'online' event if we are running in a browser. window.addEventListener( 'online', this._online.bind(this), false /* useCapture */ ); //// Kickoff! this._launchConnection(); } // data is a utf8 string. Data sent while not connected is dropped on // the floor, and it is up the user of this API to retransmit lost // messages on 'reset' send(data) { if (this.currentStatus.connected) { this.socket.send(data); } } // Changes where this connection points _changeUrl(url) { this.rawUrl = url; } _connected() { if (this.connectionTimer) { clearTimeout(this.connectionTimer); this.connectionTimer = null; } if (this.currentStatus.connected) { // already connected. do nothing. this probably shouldn't happen. return; } // update status this.currentStatus.status = 'connected'; this.currentStatus.connected = true; this.currentStatus.retryCount = 0; this.statusChanged(); // fire resets. This must come after status change so that clients // can call send from within a reset callback. this.forEachCallback('reset', callback => { callback(); }); } _cleanup(maybeError) { this._clearConnectionAndHeartbeatTimers(); if (this.socket) { this.socket.onmessage = this.socket.onclose = this.socket.onerror = this.socket.onheartbeat = () => {}; this.socket.close(); this.socket = null; } this.forEachCallback('disconnect', callback => { callback(maybeError); }); } _clearConnectionAndHeartbeatTimers() { if (this.connectionTimer) { clearTimeout(this.connectionTimer); this.connectionTimer = null; } if (this.heartbeatTimer) { clearTimeout(this.heartbeatTimer); this.heartbeatTimer = null; } } _heartbeat_timeout() { console.log('Connection timeout. No sockjs heartbeat received.'); this._lostConnection(new this.ConnectionError("Heartbeat timed out")); } _heartbeat_received() { // If we've already permanently shut down this stream, the timeout is // already cleared, and we don't need to set it again. if (this._forcedToDisconnect) return; if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer); this.heartbeatTimer = setTimeout( this._heartbeat_timeout.bind(this), this.HEARTBEAT_TIMEOUT ); } _sockjsProtocolsWhitelist() { // only allow polling protocols. no streaming. streaming // makes safari spin. var protocolsWhitelist = [ 'xdr-polling', 'xhr-polling', 'iframe-xhr-polling', 'jsonp-polling' ]; // iOS 4 and 5 and below crash when using websockets over certain // proxies. this seems to be resolved with iOS 6. eg // https://github.com/LearnBoost/socket.io/issues/193#issuecomment-7308865. // // iOS <4 doesn't support websockets at all so sockjs will just // immediately fall back to http var noWebsockets = navigator && /iPhone|iPad|iPod/.test(navigator.userAgent) && /OS 4_|OS 5_/.test(navigator.userAgent); if (!noWebsockets) protocolsWhitelist = ['websocket'].concat(protocolsWhitelist); return protocolsWhitelist; } _launchConnection() { this._cleanup(); // cleanup the old socket, if there was one. var options = { transports: this._sockjsProtocolsWhitelist(), ...this.options._sockjsOptions }; const hasSockJS = typeof SockJS === "function"; const disableSockJS = __meteor_runtime_config__.DISABLE_SOCKJS; this.socket = hasSockJS && !disableSockJS // Convert raw URL to SockJS URL each time we open a connection, so // that we can connect to random hostnames and get around browser // per-host connection limits. ? new SockJS(toSockjsUrl(this.rawUrl), undefined, options) : new WebSocket(toWebsocketUrl(this.rawUrl)); this.socket.onopen = data => { this.lastError = null; this._connected(); }; this.socket.onmessage = data => { this.lastError = null; this._heartbeat_received(); if (this.currentStatus.connected) { this.forEachCallback('message', callback => { callback(data.data); }); } }; this.socket.onclose = () => { this._lostConnection(); }; this.socket.onerror = error => { const { lastError } = this; this.lastError = error; if (lastError) return; console.error( 'stream error', error, new Date().toDateString() ); }; this.socket.onheartbeat = () => { this.lastError = null; this._heartbeat_received(); }; if (this.connectionTimer) clearTimeout(this.connectionTimer); this.connectionTimer = setTimeout(() => { this._lostConnection( new this.ConnectionError("DDP connection timed out") ); }, this.CONNECT_TIMEOUT); } }