EliAndrewC / sideboard

BSD 3-Clause "New" or "Revised" License
0 stars 0 forks source link

Sideboard should ship with a Javascript websocket library #28

Closed EliAndrewC closed 10 years ago

EliAndrewC commented 10 years ago

We didn't take the time to open source our websocket.js library, which was woefully incomplete anyway. We'd ideally like to do the following:

The above might be too big for this one issue, so maybe we can split it up. At the very least, having a js client that implements our WebSocket RPC system would be great, especially since our old Ext one isn't really suitable for reuse outside of an Ext app.

EliAndrewC commented 10 years ago

Since Github apparently doesn't let you attach files to issues, I'll just copy/paste websockets.js here:

var SideboardUtils = {
    noop: function() { },

    applyIf: function(d1, d2) {
        d1 = d1 || {};
        for(var k in d2) {
            if(!(k in d1)) {
                d1[k] = d2[k];
            }
        }
        return d1;
    },

    bind: function(func, scope) {
        return function() {
            func.apply(scope, arguments);
        };
    },

    getScheme: function() {
        if(window.location.protocol == "https:") {
            return "wss";
        } else {
            return "ws";
        }
    },

    getDefaultUrl : function() {
        var host = window.location.host;
        // this is needed exclusively to simplify the need to have 443 be the http
        // and 8443 be the ws port in the case of apache being the server
//        if (window.location.protocol == "https:" && host.indexOf(":") === -1) {
//            host += ":8443";
//        }
        return SideboardUtils.getScheme() + "://" + host + "/ws";
    }
};

var SideboardWebSocket = function(url, protocols) {
    this.url = url || this.url;
    this.protocols = protocols || this.protocols;
    this.eventQueues = {
        open: [],
        close: [],
        error: [],
        message: []
    };

    if (!this.WebSocketClass) {
        throw "WebSockets not supported by this browser.";
    }
    this.CONNECTING = this.WebSocketClass.CONNECTING;
    this.OPEN       = this.WebSocketClass.OPEN;
    this.CLOSING    = this.WebSocketClass.CLOSING;
    this.CLOSED     = this.WebSocketClass.CLOSED;

    return this;
};
SideboardWebSocket.prototype = {
    url: "ws://" + window.location.host + "/ws",

    protocols: null,

    WebSocketClass: window.WebSocket,

    on: function(eventName, callback, settings) {
        settings = settings || {};
        if (eventName in this.eventQueues) {
            this.eventQueues[eventName].push({
                callback: callback,
                scope: settings.scope || this,
                single: settings.single || false
            });
        } else {
            throw new Error("'" + eventName + "' is not a valid event name");
        }
    },

    fire: function(eventName) {
        if (eventName in this.eventQueues) {
            var args = Array.prototype.slice.call(arguments, 1);
            var events = this.eventQueues[eventName];
            var indexesToRemove = [];
            for(var i=0, event; event=events[i]; i++) {
                event.callback.apply(event.scope, args);
                if (event.single) {
                    indexesToRemove.splice(0, 0, i);
                }
            }
            for(var i=0; i<indexesToRemove.length; i++) {
                events.pop(indexesToRemove[i]);
            }
        } else {
            throw new Error("'" + eventName + "' is not a valid event name");
        }
    },

    getUrl: function(webSocket) {
        return webSocket.url || webSocket.URL;
    },

    getStatus: function() {
        if (this.webSocket) {
            return this.webSocket.readyState;
        } else {
            return this.CLOSED;
        }
    },

    getStatusString: function() {
        var status = this.getStatus();
        if (status === this.CONNECTING) {
            return "CONNECTING";
        } else if (status === this.OPEN) {
            return "OPEN";
        } else if (status === this.CLOSING) {
            return "CLOSING";
        } else {
            return "CLOSED";
        }
    },

    connect: function(url, protocols) {
        var doConnect = function() {
            this.url = url || this.url;
            this.protocols = protocols || this.protocols;

            this.webSocket = new this.WebSocketClass(this.url, this.protocols);

            this.webSocket.onopen = SideboardUtils.bind(this.handleOpen, this);
            this.webSocket.onerror = SideboardUtils.bind(this.handleError, this);
            this.webSocket.onclose = SideboardUtils.bind(this.handleClose, this);
            this.webSocket.onmessage = SideboardUtils.bind(this.handleMessage, this);
        };

        if (!this.webSocket || this.webSocket.readyState == this.CLOSED) {
            doConnect.call(this);
        } else if (this.webSocket.readyState == this.CLOSING) {
            this.on("close", doConnect, {single: true});
        } else if (this.getStatus() == this.OPEN || this.getStatus() == this.CONNECTING) {
            if (url && url != this.url) {
                this.on("close", doConnect, {single: true});
                this.close();
            }
        }
    },

    close: function(clamp, reason) {
        this.webSocket.close(clamp, reason);
    },

    send: function(data) {
        if (typeof(data) != "string") {
            data = JSON.stringify(data)
        }

        if (this.getStatus() == this.OPEN) {
            console.log("Socket send immediate", new Date().toISOString(), data);
            this.webSocket.send(data);
        } else {
            throw new Error("Attemping to send data over a " + this.getStatusString() + " connection");
        }
    },

    handleOpen: function(event) {
        this.fire("open", this, event);
    },

    handleError: function(event) {
        this.fire("error", this, event);
    },

    handleClose: function(event) {
        this.webSocket.onopen = null;
        this.webSocket.onerror = null;
        this.webSocket.onclose = null;
        this.webSocket.onmessage = null;
        this.webSocket = null;
        this.fire("close", this, event);
    },

    handleMessage: function(event) {
        console.log("Socket receive message", new Date().toISOString(), event);
        var data = event.data ? JSON.parse(event.data) : null;
        this.fire("message", this, data, event);
    }
};

var SideboardSocketManager = function() {
    this.sockets = {};
    this.requests = {};
    return this;
};
SideboardSocketManager.prototype = {
    currId: 0,

    nextClientId: function() {
        this.currId++;
        return "client-" + this.currId;
    },

    nextCallbackId: function() {
        this.currId++;
        return "callback-" + this.currId;
    },

    requestFromStringOrObject: function(request) {
        if (typeof(request) == "string") {
            request = {client : request};
        }
        request = request || {};
        request.url = request.url || SideboardUtils.getDefaultUrl();
        return request;
    },

    normalizeRequest: function(request) {
        request = this.requestFromStringOrObject(request);
        if (!request.client) {
            request.client = this.nextClientId()
        }
        return SideboardUtils.applyIf(request, {
            scope: request,
            single: false,
            error: SideboardUtils.noop,
            callback: SideboardUtils.noop,
            url: SideboardUtils.getDefaultUrl()
        });
    },

    closeAll: function() {
        for(var url in this.sockets) {
            try {
                this.sockets[url].webSocket.close();
            } catch(ex) { }
            delete this.sockets[url];
        }
    },

    open: function(opts) {
        opts = SideboardUtils.applyIf(opts, {
            scope: this,
            callback: SideboardUtils.noop,
            url: SideboardUtils.getDefaultUrl()
        });
        var url = opts.url;
        if (!this.requests[url]) {
            this.requests[url] = {};
        }
        if (this.sockets[url]) {
            var ws = this.sockets[url];
            if (ws.getStatus() == ws.CONNECTING) {
                ws.on("open", opts.callback, {single: true, scope: opts.scope})
            } else {
                opts.callback.call(opts.scope);
            }
        } else {
            var ws = this.sockets[url] = new SideboardWebSocket(url);
            ws.connect();
            ws.on("open", opts.callback, {single: true, scope: opts.scope});
            ws.on("close", this.handleClose, {scope: this});
            ws.on("message", this.handleMessage, {scope: this});
        }
        return this.sockets[url];
    },

    unsubscribe: function(client) {
        var request = this.requestFromStringOrObject(client);
        if (request.client) {
            this.sockets[request.url].send({action: "unsubscribe", client: request.client});
            if (request.url in this.requests) {
                delete this.requests[request.url][request.client];
            }
        } else {
            console.error("Unknown client id", client);
        }
    },

    send: function(request) {
        request = this.normalizeRequest(request);
        this.open({
            url: request.url,
            callback: function() {
                this.handleSend(request);
            }
        });
        return request.client;
    },

    subscribe: function(request) {
        request.single = false;
        return this.send(request);
    },

    call: function(request) {
        request.single = true;
        return this.send(request);
    },

    handleSend: function(request) {
        if (request.single) {
            delete request.client;
            request.cbid = this.nextCallbackId();
            this.requests[request.url][request.cbid] = request;
        } else {
            this.requests[request.url][request.client] = request;
        }
        this.sockets[request.url].send({
            method: request.method,
            params: request.params,
            client: request.client,
            callback: request.cbid
        });
    },

    handleClose: function(ws, event) {
        delete this.sockets[ws.url];
        delete this.requests[ws.url];
    },

    handleMessage: function(ws, json, event) {
        var id = json && (json.client || json.callback);
        var request = this.requests[ws.url][id];
        if (request) {
            fn = json.error ? "error" : "callback";
            try {
                request[fn].call(request.scope, json.data, ws, event);
            } catch(ex) {
                console.error("Error executing client." + fn, ex);
            }
            if (request.single) {
                delete this.requests[request.url][id];
            }
        } else {
            console.error("unknown client and/or callback id", json);
        }
    }
};
SocketManager = new SideboardSocketManager();
robdennis commented 10 years ago

This could be a link to a public gist

On Mon, Mar 24, 2014 at 7:14 PM, Eli Courtwright notifications@github.com wrote:

Since Github apparently doesn't let you attach files to issues, I'll just copy/paste websockets.js here:

var SideboardUtils = {
    noop: function() { },

    applyIf: function(d1, d2) {
        d1 = d1 || {};
        for(var k in d2) {
            if(!(k in d1)) {
                d1[k] = d2[k];
            }
        }
        return d1;
    },

    bind: function(func, scope) {
        return function() {
            func.apply(scope, arguments);
        };
    },

    getScheme: function() {
        if(window.location.protocol == "https:") {
            return "wss";
        } else {
            return "ws";
        }
    },
    getDefaultUrl : function() {
        var host = window.location.host;
        // this is needed exclusively to simplify the need to have 443 be the http
        // and 8443 be the ws port in the case of apache being the server
//        if (window.location.protocol == "https:" && host.indexOf(":") === -1) {
//            host += ":8443";
//        }
        return SideboardUtils.getScheme() + "://" + host + "/ws";
    }
};
var SideboardWebSocket = function(url, protocols) {
    this.url = url || this.url;
    this.protocols = protocols || this.protocols;
    this.eventQueues = {
        open: [],
        close: [],
        error: [],
        message: []
    };

    if (!this.WebSocketClass) {
        throw "WebSockets not supported by this browser.";
    }
    this.CONNECTING = this.WebSocketClass.CONNECTING;
    this.OPEN       = this.WebSocketClass.OPEN;
    this.CLOSING    = this.WebSocketClass.CLOSING;
    this.CLOSED     = this.WebSocketClass.CLOSED;

    return this;
};
SideboardWebSocket.prototype = {
    url: "ws://" + window.location.host + "/ws",

    protocols: null,

    WebSocketClass: window.WebSocket,

    on: function(eventName, callback, settings) {
        settings = settings || {};
        if (eventName in this.eventQueues) {
            this.eventQueues[eventName].push({
                callback: callback,
                scope: settings.scope || this,
                single: settings.single || false
            });
        } else {
            throw new Error("'" + eventName + "' is not a valid event name");
        }
    },

    fire: function(eventName) {
        if (eventName in this.eventQueues) {
            var args = Array.prototype.slice.call(arguments, 1);
            var events = this.eventQueues[eventName];
            var indexesToRemove = [];
            for(var i=0, event; event=events[i]; i++) {
                event.callback.apply(event.scope, args);
                if (event.single) {
                    indexesToRemove.splice(0, 0, i);
                }
            }
            for(var i=0; i<indexesToRemove.length; i++) {
                events.pop(indexesToRemove[i]);
            }
        } else {
            throw new Error("'" + eventName + "' is not a valid event name");
        }
    },

    getUrl: function(webSocket) {
        return webSocket.url || webSocket.URL;
    },

    getStatus: function() {
        if (this.webSocket) {
            return this.webSocket.readyState;
        } else {
            return this.CLOSED;
        }
    },

    getStatusString: function() {
        var status = this.getStatus();
        if (status === this.CONNECTING) {
            return "CONNECTING";
        } else if (status === this.OPEN) {
            return "OPEN";
        } else if (status === this.CLOSING) {
            return "CLOSING";
        } else {
            return "CLOSED";
        }
    },
    connect: function(url, protocols) {
        var doConnect = function() {
            this.url = url || this.url;
            this.protocols = protocols || this.protocols;

            this.webSocket = new this.WebSocketClass(this.url, this.protocols);

            this.webSocket.onopen = SideboardUtils.bind(this.handleOpen, this);
            this.webSocket.onerror = SideboardUtils.bind(this.handleError, this);
            this.webSocket.onclose = SideboardUtils.bind(this.handleClose, this);
            this.webSocket.onmessage = SideboardUtils.bind(this.handleMessage, this);
        };

        if (!this.webSocket || this.webSocket.readyState == this.CLOSED) {
            doConnect.call(this);
        } else if (this.webSocket.readyState == this.CLOSING) {
            this.on("close", doConnect, {single: true});
        } else if (this.getStatus() == this.OPEN || this.getStatus() == this.CONNECTING) {
            if (url && url != this.url) {
                this.on("close", doConnect, {single: true});
                this.close();
            }
        }
    },

    close: function(clamp, reason) {
        this.webSocket.close(clamp, reason);
    },

    send: function(data) {
        if (typeof(data) != "string") {
            data = JSON.stringify(data)
        }

        if (this.getStatus() == this.OPEN) {
            console.log("Socket send immediate", new Date().toISOString(), data);
            this.webSocket.send(data);
        } else {
            throw new Error("Attemping to send data over a " + this.getStatusString() + " connection");
        }
    },

    handleOpen: function(event) {
        this.fire("open", this, event);
    },

    handleError: function(event) {
        this.fire("error", this, event);
    },

    handleClose: function(event) {
        this.webSocket.onopen = null;
        this.webSocket.onerror = null;
        this.webSocket.onclose = null;
        this.webSocket.onmessage = null;
        this.webSocket = null;
        this.fire("close", this, event);
    },

    handleMessage: function(event) {
        console.log("Socket receive message", new Date().toISOString(), event);
        var data = event.data ? JSON.parse(event.data) : null;
        this.fire("message", this, data, event);
    }
};
var SideboardSocketManager = function() {
    this.sockets = {};
    this.requests = {};
    return this;
};
SideboardSocketManager.prototype = {
    currId: 0,

    nextClientId: function() {
        this.currId++;
        return "client-" + this.currId;
    },

    nextCallbackId: function() {
        this.currId++;
        return "callback-" + this.currId;
    },

    requestFromStringOrObject: function(request) {
        if (typeof(request) == "string") {
            request = {client : request};
        }
        request = request || {};
        request.url = request.url || SideboardUtils.getDefaultUrl();
        return request;
    },

    normalizeRequest: function(request) {
        request = this.requestFromStringOrObject(request);
        if (!request.client) {
            request.client = this.nextClientId()
        }
        return SideboardUtils.applyIf(request, {
            scope: request,
            single: false,
            error: SideboardUtils.noop,
            callback: SideboardUtils.noop,
            url: SideboardUtils.getDefaultUrl()
        });
    },

    closeAll: function() {
        for(var url in this.sockets) {
            try {
                this.sockets[url].webSocket.close();
            } catch(ex) { }
            delete this.sockets[url];
        }
    },

    open: function(opts) {
        opts = SideboardUtils.applyIf(opts, {
            scope: this,
            callback: SideboardUtils.noop,
            url: SideboardUtils.getDefaultUrl()
        });
        var url = opts.url;
        if (!this.requests[url]) {
            this.requests[url] = {};
        }
        if (this.sockets[url]) {
            var ws = this.sockets[url];
            if (ws.getStatus() == ws.CONNECTING) {
                ws.on("open", opts.callback, {single: true, scope: opts.scope})
            } else {
                opts.callback.call(opts.scope);
            }
        } else {
            var ws = this.sockets[url] = new SideboardWebSocket(url);
            ws.connect();
            ws.on("open", opts.callback, {single: true, scope: opts.scope});
            ws.on("close", this.handleClose, {scope: this});
            ws.on("message", this.handleMessage, {scope: this});
        }
        return this.sockets[url];
    },

    unsubscribe: function(client) {
        var request = this.requestFromStringOrObject(client);
        if (request.client) {
            this.sockets[request.url].send({action: "unsubscribe", client: request.client});
            if (request.url in this.requests) {
                delete this.requests[request.url][request.client];
            }
        } else {
            console.error("Unknown client id", client);
        }
    },

    send: function(request) {
        request = this.normalizeRequest(request);
        this.open({
            url: request.url,
            callback: function() {
                this.handleSend(request);
            }
        });
        return request.client;
    },

    subscribe: function(request) {
        request.single = false;
        return this.send(request);
    },

    call: function(request) {
        request.single = true;
        return this.send(request);
    },

    handleSend: function(request) {
        if (request.single) {
            delete request.client;
            request.cbid = this.nextCallbackId();
            this.requests[request.url][request.cbid] = request;
        } else {
            this.requests[request.url][request.client] = request;
        }
        this.sockets[request.url].send({
            method: request.method,
            params: request.params,
            client: request.client,
            callback: request.cbid
        });
    },

    handleClose: function(ws, event) {
        delete this.sockets[ws.url];
        delete this.requests[ws.url];
    },

    handleMessage: function(ws, json, event) {
        var id = json && (json.client || json.callback);
        var request = this.requests[ws.url][id];
        if (request) {
            fn = json.error ? "error" : "callback";
            try {
                request[fn].call(request.scope, json.data, ws, event);
            } catch(ex) {
                console.error("Error executing client." + fn, ex);
            }
            if (request.single) {
                delete this.requests[request.url][id];
            }
        } else {
            console.error("unknown client and/or callback id", json);
        }
    }
};
SocketManager = new SideboardSocketManager();

Reply to this email directly or view it on GitHub: https://github.com/appliedsec/sideboard/issues/28#issuecomment-38513373

EliAndrewC commented 10 years ago

This is probably the next ticket I'm going to tackle. The biggest question for me is whether I should rewrite this as an Angular service.

The two most important cons to writing this as an Angular service are IMO:

This is offset by two benefits:

That second one is so huge that it dwarfs all of the downsides, in my opinion. This will especially be true when we add in code that handles automatically polling and reconnecting; we are effectively faced with the choice to either never write tests for the core code behind our Javascript websockets or to just use Angular. My vote is to just go with Angular. Thoughts?

(As an implementation detail, we would ship an angular module without actually including Angular, and the tutorial would direct the user to point to the Google Hosted Libraries at https://developers.google.com/speed/libraries/devguide#angularjs)

EliAndrewC commented 10 years ago

I've taken an initial stab at this; it's not ready for a pull request yet but you can see the work I've done so far at https://github.com/EliAndrewC/sideboard/commit/44458ad952156d222eec165d171e30d23ff209ff

A few things to note:

So I'm pretty happy with how the code has turned out so far, but there are definitely a bunch more things I need before this is ready for a pull request:

EliAndrewC commented 10 years ago

I did a lot more work on this last night, and there's some complexity related to polling which I don't think our old Ext implementation ever got right. There are two situations where we might want a poll failure to close the connection:

That second situation seems like it more more generally apply to any RPC request made with WebSocketService.call(). If I send a message to the server and more than X (configurable) seconds go by, I'd expect that to be treated as a failure. Our originally Ext implementation never covered this case, but it would be relatively easy to do.

So assuming no one objects, I'll make it so that callbacks will automatically fail after some configurable amount of time (with a 10 second default). So you could write callbacks like so:

WebSocketService.call('ragnarok.check_for_apocalypse');  // default timeout
WebSocketService.call({
    method: 'ragnarok.check_for_apocalypse',
    timeout: 30000  // milliseconds, overrides default timeout
});
EliAndrewC commented 10 years ago

One additional thing we need to decide is whether to re-fire pending WebSocketService.call requests on a reconnect.

If a user invokes WebSocketService.call() and we're not in a connected state, it makes perfect sense to say "try to connect and make this call as soon as the connected is established (unless I cancel first after timing out)".

But let's say the connection is currently active when you invoke WebSocketService.call() but then the connection closes before you received a response, and then the connection re-opens before we time out. There are two different things we could do in that scenario:

I'm in favor of the second option, which also happens to be simpler to implement. I will be sure to document the different semantics of subscribe and call specifically our assumption that we always assume it's safe to re-fire a subscribe request and so do that on every re-connect, but the user will need to manually re-fire a call request if it fails.

EliAndrewC commented 10 years ago

The last feature that I forgot to mention is that applications which use websockets will often want to know whether the websocket connection to the server is currently active. Like I believe that we displayed a banner at the top of the page in our original app while the connection was dead.

I originally planned to add a WebSocketService.onConnectionChange method which you could pass a callback function to, but then I decided that since we're using Angular, it made more sense to simply advertise that we $rootScope.$broadcast a WebSocketService.connectionChange event along with a boolean whenever the connection is opened or closed.

EliAndrewC commented 10 years ago

Random style question: what's the best place for the Angular unit tests to go? Three potential choices:

I've finished the implementation of WebSocketService and written unit tests for most of its methods, so I should probably figure out where they'll go.

I've made another commit at https://github.com/EliAndrewC/sideboard/commit/b0970fecdf3cfee76edfc1bcdf0d87a11f5d432f if anyone wants to take a look. I'll make sure to edit the history to merge everything into one single commit before making a pull request, since that'll be much easier to review.

robdennis commented 10 years ago

Djangular put this next to the other python tests, for reference, and I didn't end up feeling like it helped.

Of the options that you mention, in the top level seems good, because it's good to keep it far away from a folder we intend to serve to clients, and it doesn't map to the python test hierarchy in any meaningful way

On Fri, Apr 11, 2014 at 12:35 AM, Eli Courtwright notifications@github.com wrote:

Random style question: what's the best place for the Angular unit tests to go? Three potential choices: