Closed EliAndrewC closed 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();
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
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:
SocketManager
, so by making this into an Angular service we'd need to either have the tutorial involve a simple Angular app (which makes it somewhat more complicated, especially to non-Angular folks) or call into the Angular service from non-Angular code (which is possible but non-idiomatic)This is offset by two benefits:
merge
and events, etcThat 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)
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:
/ws
and /wsrpc
, the former being login-protected while the latter is not. The tutorial originally had the user set restricted=True
, which used to be required for a plugin to be able to establish websocket connections. However, since we can't expect a random developer on the internet to have an LDAP server to point to, this is no longer a reasonable requirement. So I added a new ws.auth_required
option which is True
by default but overridden to be False
in Sideboard's development-defaults.ini
, which allows us to make a websocket connection.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:
call
, subscribe
, and unsubscribe
methods of WebSocketService
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:
kill -9
'ed. In this case the browser should eventually detect this, since the websocket protocol involves a ping/pong request, but in practice we haven't always seen this happen. This was true for Firefox specifically, though we haven't tested within the last 10 or so Firefox versions, which was why we added polling in the first place to our original Ext javascript library.fg
) the Sideboard process on my Linux machine (ws4py
doesn't seem to deal with this gracefully). In this case what happens is that the client keeps sending requests and the server never sends any response; this seems like a situation where we would want polling to eventually fail and 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
});
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.
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.
Random style question: what's the best place for the Angular unit tests to go? Three potential choices:
sideboardAngularUtils.js
(the sideboard/static
directory)sideboard/tests
directorytests
directoryI'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.
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:
- in the same directory as
sideboardAngularUtils.js
(thesideboard/static
directory)- in the
sideboard/tests
directoryin the top-level
tests
directory I've finished the implementation ofWebSocketService
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.Reply to this email directly or view it on GitHub: https://github.com/appliedsec/sideboard/issues/28#issuecomment-40170102
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.