2
\$\begingroup\$

I've been diving a bit into JavaScript for the first time in forever, generally trying things out, and getting acquainted.

I gave my toy project the following requirements:

  • single-page react app
  • uses firebase for authentication
  • matching server-side session with a persistent websocket connection.
  • the connection isn't "established" until the server has validated the firebase authentication.

Since I needed to do a query-response handshake over the websocket, I decided that I might as well generalize that, since I need to do it a lot more further down the line.

client-side session.js:

import * as firebase from 'firebase';
import { EventTarget } from 'event-target-shim';

const SESSION_AUTH_MSG = '_session_auth';

/**
* Class representing a firebase-authenticated session with a server.
* The server is expected to be using the matching ServerSession class.
*/
class ClientSession extends EventTarget {

  /** Creates a session.
  * @param {Object} cfg - The session connection info
  * @param cfg.firebaseConfig - A firebase configuration, as expected by firebase.initializeApp()
  * @param cfg.endpoint - The url to use when connecting the websocket.
  */
  constructor(cfg) {
    super();
    this._config = cfg;
    this._webSocket = null;

    this._firebaseApp = firebase.initializeApp(cfg.firebaseConfig);
    this.firebase_auth = firebase.auth(this._firebaseApp);    

    this._next_reply_id = 0;
    this._reply_handlers = {};

    this._setupFirebaseAuthListener();
  }

  /**
  * Sends a message to the server.
  * @param {string} type - the message type, used for dispatching on the server.
  * @param {Object} data - the message's payload.
  */
  send(type, data) {
    const msg = {
      type: type,
      data: data,
    };

    this._webSocket.send(JSON.stringify(msg));
  }

  /**
  * Sends a message to the server, and expect an associated reply.
  * @param {string} type - the message type, used for dispatching on the server.
  * @param {Object} data - the message's payload.
  * @returns {Promise} resolves to the data returned by the server.
  */
  sendQuery(type, data) {
    const msg = {
      type: type,
      reply_key: this._generateReplyKey(),
      data: data,
    };

    var self = this;
    return new Promise((resolve, reject) => {
      self._reply_handlers[msg.reply_key] = {
        resolve: resolve,
        reject: reject
      };

      self._webSocket.send(JSON.stringify(msg));
    });
  }

  _handleMessage(msg) {
    if('reply_key' in msg) {
      var handler = this._reply_handlers[msg.reply_key];
      if('error' in msg) {
        handler.reject(msg.error);
      }
      else {
        handler.resolve(msg.data);
      }

      delete this._reply_handlers[msg.reply_key];
    }
    else {
      this.dispatchEvent(new CustomEvent(msg.type, {detail: msg.data}));
    }
  }

  _generateReplyKey() {
    return String(this._next_reply_id++);
  }

  _setupFirebaseAuthListener() {
    this.firebase_auth.onAuthStateChanged(user => {
      if(user) {
        // We do not qualify as "connected" until the serverside Session
        // has been established.
        this._setupWebSocket();
      }
      else {
        if(this._webSocket) {
          // Notification will be handled by the websocket closing.
          this._webSocket.close();
          this._webSocket = null;
        }
      }
    });
  }

  _setupWebSocket() {
    var ws = new WebSocket(this._config.endpoint);
    this._webSocket = ws;

    ws.onopen = e => {
      // Send the login handshake.
      firebase.auth(this._firebaseApp).currentUser.getIdToken()
        .then(tok => {
          return this.sendQuery(SESSION_AUTH_MSG, {token: tok});
        })
        .then(rep => {
          this.dispatchEvent(new Event('session_authenticated'));
        })
        .catch(e => {
          this.dispatchEvent(new CustomEvent('error', {detail: e}));
        });
    };

    ws.onclose = e => {
      this.dispatchEvent(new Event('session_disconnected'));

      // If we are still logged in to firebase, then odds are this
      // was just the connection timing out.
      if(firebase.auth(this._firebaseApp).currentUser) {
        // TODO: expanding backoff perhaps?
        setTimeout(this._setupWebSocket.bind(this), 1000);
      }
    };

    ws.onError = e => {
      // TODO: retry connection?
    };

    ws.onmessage = e => {
      try {
        this._handleMessage(JSON.parse(e.data));
      }
      catch(err) {
        console.error(`bad message received: ${e.data}, ${err.message}`);
      }
    };
  }
}

export default ClientSession;

server-side session.js:

const EventEmitter = require('events');

const SESSION_AUTH_MSG = '_session_auth';

/**
* Class representing a firebase-authenticated session with a client.
* The client is expected to be using the matching ClientSession class.
*/
class ServerSession extends EventEmitter {

  /** Creates a session.
  * @param fb_admin - firebase administration, as returned by admin.initializeApp()
  * @param ws - A firebase configuration, as expected by firebase.initializeApp()
  */
  constructor(fb_admin, ws) {
    super();

    this._ws = ws;

    this.on(SESSION_AUTH_MSG, (msg) => {
      fb_admin.auth().verifyIdToken(msg.data.token)
        .then( (token) => {
          var uid = token.uid;
          this.firebaseUid = uid;
          this.reply(msg, {});
        }).catch((error) => {
          // Handle error
          this.replyFailure(msg, error.message);
        });
    });

    ws.on('message', (msg) => {
      try {
        var msg_obj = JSON.parse(msg);

        if( this._isAuthenticated() || msg_obj.type === SESSION_AUTH_MSG) {
          this.emit(msg_obj.type, msg_obj);
        }
      }
      catch(e) {
        console.error(`bad message received: ${e}`);
      }
    });
  }

  /**
  * Sends a reply to a message
  * @param orig - The original message you are replying to
  * @param data - the payload of the reply
  */
  reply(orig, data) {
    const msg = {
      reply_key: orig.reply_key,
      data: data
    };
    this._send(msg);
  }

  /**
  * Notifies that a reply for a message could not be generated
  * @param orig - The original message you are replying to
  * @param err_msgr - The error message.
  */
  replyFailure(orig, err_msg) {
    const msg = {
      reply_key: orig.reply_key,
      error: err_msg
    };
    this._send(msg);
  }

  /**
  * Sends a message to the client
  * @param {Object} msg - The message to send.
  */
  send(type, msg) {
    const real_msg = {
      type: type,
      data: msg
    };
    this._send(real_msg);
  }

  _isAuthenticated() {
    return 'firebaseUid' in this;
  }

  _send(msg) {
    this._ws.send(JSON.stringify(msg));
  }
}

module.exports = ServerSession;

Usage example:

server-side:

const Session = require('./session')
const WebSocket = require('ws')
const admin = require('firebase-admin');
const serviceAccount = ...redacted...

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: ...redacted...
});

var server = new WebSocket.Server({port: 10000});

server.on('connection', (ws, req) => {
  console.log('connection established');

  var session = new Session(admin, ws);

  session.on("foo", e => {
    session.send("bar", e.data*2);
  });

  session.on("ping", e => {
    session.reply(e, {message:"Hello from server."});
  });
});

client-side (lives inside a react component):

var cfg = {
  firebaseConfig: fb_cfg,
  endpoint: "ws://localhost:10000"
};
this._session = new ClientSession(cfg);

this._session.addEventListener('session_authenticated', e => {
  this._session.sendQuery("ping", {})
    .then(d => {
      console.log("ping reply: " + d.message);
    });

  this._session.send("foo", 2);
});

this._session.addEventListener("bar" , e => {
  console.log(`bar received: ${e.detail}`);
});

N.B.

  • My current project does not need the server to make query->reply messages to the client, hence the ommision.
  • Streaming replies is an obvious next step, but I've got other fish to fry before hand.
  • I'm really not a big fan of the session handling events getting mixed in with the user-specified events (hence the big session_ prefixes, but apart from creating an extra layer of indirection for the user, it "felt" like the right way to tackle this.
  • I don't like checking for firebaseUid in this on every single received message in the server. I guess I could swap out the "message" handler after handshaking, but that also feels icky.

Since I'm a huge JavaScript neophite, I'm sure there's a lot to improve in there. So I'm looking for any and all feedback on style, general practices, functionallity, etc.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.