meteor/meteor

View on GitHub
packages/socket-stream-client/browser.js

Summary

Maintainability
A
2 hrs
Test Coverage
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);
  }
}