-
-
Save defsprite/858997 to your computer and use it in GitHub Desktop.
a (w)hacky strophe websockets connection implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** Class: Strophe.WebSocket | |
* XMPP Connection manager. | |
* | |
* Thie class is the main part of Strophe. It manages a BOSH connection | |
* to an XMPP server and dispatches events to the user callbacks as | |
* data arrives. It supports SASL PLAIN, SASL DIGEST-MD5, and legacy | |
* authentication. | |
* | |
* After creating a Strophe.Connection object, the user will typically | |
* call connect() with a user supplied callback to handle connection level | |
* events like authentication failure, disconnection, or connection | |
* complete. | |
* | |
* The user will also have several event handlers defined by using | |
* addHandler() and addTimedHandler(). These will allow the user code to | |
* respond to interesting stanzas or do something periodically with the | |
* connection. These handlers will be active once authentication is | |
* finished. | |
* | |
* To send data to the connection, use send(). | |
*/ | |
/** Constructor: Strophe.Connection | |
* Create and initialize a Strophe.Connection object. | |
* | |
* Parameters: | |
* (String) service - The WebSocket service URL. | |
* | |
* Returns: | |
* A new Strophe.WebSocket object. | |
*/ | |
Strophe.WebSocket = function (service) | |
{ | |
/* The websocket url. */ | |
this.service = service; | |
this.ws = null; | |
this.connect_timeout = 300; | |
this.buffered_data = ""; | |
/* The connected JID. */ | |
this.jid = ""; | |
/* The current stream ID. */ | |
this.streamId = null; | |
// SASL | |
this.do_session = false; | |
this.do_bind = false; | |
// handler lists | |
this.timedHandlers = []; | |
this.handlers = []; | |
this.removeTimeds = []; | |
this.removeHandlers = []; | |
this.addTimeds = []; | |
this.addHandlers = []; | |
this._idleTimeout = null; | |
this._disconnectTimeout = null; | |
this.authenticated = false; | |
this.disconnecting = false; | |
this.connected = false; | |
this._keep_alive_timer = 20000 | |
this.errors = 0; | |
this._data = []; | |
this._requests = []; | |
this._uniqueId = Math.round(Math.random() * 10000); | |
this._sasl_success_handler = null; | |
this._sasl_failure_handler = null; | |
this._sasl_challenge_handler = null; | |
this._rebind_success_handler = null; | |
this._rebind_failure_handler = null; | |
// setup onIdle callback every 1/10th of a second | |
this._idleTimeout = setTimeout(this._onIdle.bind(this), 100); | |
// initialize plugins | |
for (var k in Strophe._connectionPlugins) { | |
if (Strophe._connectionPlugins.hasOwnProperty(k)) { | |
var ptype = Strophe._connectionPlugins[k]; | |
// jslint complaints about the below line, but this is fine | |
var F = function () {}; | |
F.prototype = ptype; | |
this[k] = new F(); | |
this[k].init(this); | |
} | |
} | |
}; | |
Strophe.WebSocket.prototype = { | |
/** Function: reset | |
* Reset the connection. | |
* | |
* This function should be called after a connection is disconnected | |
* before that connection is reused. | |
*/ | |
reset: function () | |
{ | |
this.sid = null; | |
this.streamId = null; | |
// SASL | |
this.do_session = false; | |
this.do_bind = false; | |
// handler lists | |
this.timedHandlers = []; | |
this.handlers = []; | |
this.removeTimeds = []; | |
this.removeHandlers = []; | |
this.addTimeds = []; | |
this.addHandlers = []; | |
this.authenticated = false; | |
this.disconnecting = false; | |
this.connected = false; | |
this.errors = 0; | |
}, | |
/** Function: pause | |
* UNUSED with websockets | |
*/ | |
pause: function () | |
{ | |
return; | |
}, | |
/** Function: resume | |
* UNUSED with websockets | |
*/ | |
resume: function () | |
{ | |
return; | |
}, | |
/** Function: getUniqueId | |
* Generate a unique ID for use in <iq/> elements. | |
* | |
* All <iq/> stanzas are required to have unique id attributes. This | |
* function makes creating these easy. Each connection instance has | |
* a counter which starts from zero, and the value of this counter | |
* plus a colon followed by the suffix becomes the unique id. If no | |
* suffix is supplied, the counter is used as the unique id. | |
* | |
* Suffixes are used to make debugging easier when reading the stream | |
* data, and their use is recommended. The counter resets to 0 for | |
* every new connection for the same reason. For connections to the | |
* same server that authenticate the same way, all the ids should be | |
* the same, which makes it easy to see changes. This is useful for | |
* automated testing as well. | |
* | |
* Parameters: | |
* (String) suffix - A optional suffix to append to the id. | |
* | |
* Returns: | |
* A unique string to be used for the id attribute. | |
*/ | |
getUniqueId: function (suffix) | |
{ | |
if (typeof(suffix) == "string" || typeof(suffix) == "number") { | |
return ++this._uniqueId + ":" + suffix; | |
} else { | |
return ++this._uniqueId + ""; | |
} | |
}, | |
/** Function: connect | |
* Starts the connection process. | |
* | |
* As the connection process proceeds, the user supplied callback will | |
* be triggered multiple times with status updates. The callback | |
* should take two arguments - the status code and the error condition. | |
* | |
* The status code will be one of the values in the Strophe.Status | |
* constants. The error condition will be one of the conditions | |
* defined in RFC 3920 or the condition 'strophe-parsererror'. | |
* | |
* Please see XEP 124 for a more detailed explanation of the optional | |
* parameters below. | |
* | |
* Parameters: | |
* (String) jid - The user's JID. This may be a bare JID, | |
* or a full JID. If a node is not supplied, SASL ANONYMOUS | |
* authentication will be attempted. | |
* (String) pass - The user's password. | |
* (Function) callback The connect callback function. | |
*/ | |
connect: function (jid, pass, callback) | |
{ | |
this.jid = jid; | |
this.pass = pass; | |
this.connect_callback = callback; | |
this.disconnecting = false; | |
this.connected = false; | |
this.authenticated = false; | |
this.errors = 0; | |
// parse jid for domain and resource | |
this.domain = Strophe.getDomainFromJid(this.jid); | |
if(window.WebSocket){ | |
try{ | |
this.ws = new WebSocket(this.service); | |
this.ws.onopen = this._send_initial_stream.bind(this); | |
this._addSysTimedHandler(this._keep_alive_timer, this._keep_alive_handler.bind(this)); | |
this.ws.onmessage = this._get_stream_id.bind(this) | |
.prependArg(this._connect_cb.bind(this)); | |
this.ws.onerror = function(e){console.log("error : " + e)}; | |
this.ws.onclose = this._ws_on_close.bind(this); | |
} catch(e){ | |
//console.log("exception "+e); | |
} | |
} else{ | |
throw "no websocket support" | |
} | |
}, | |
/** Function: attach | |
* UNUSED, use rebind | |
*/ | |
attach: function(){return}, | |
rebind: function (jid, sid, callback) | |
{ | |
this.jid = jid; | |
this.connect_callback = callback; | |
this.domain = Strophe.getDomainFromJid(this.jid); | |
this._addSysTimedHandler(this._keep_alive_timer, this._keep_alive_handler.bind(this)); | |
this.ws = new WebSocket(this.service); | |
this.ws.onopen = this._send_initial_stream.bind(this); | |
this.ws.onmessage = this._get_stream_id.bind(this) | |
.prependArg(this._rebind_cb.bind(this) | |
.prependArg(jid).prependArg(sid)); | |
this.ws.onclose = this._ws_on_close.bind(this); | |
}, | |
save: function(success, failure){ | |
var push = $iq({type: "set"}).c("push", {xmlns: "p1:push"}) | |
.c("keepalive", {max: "30"}) | |
.up() | |
.c("session", {duration:"1"}); | |
this.sendIQ(push, success,failure ); | |
}, | |
/** Function: xmlInput | |
* User overrideable function that receives XML data coming into the | |
* connection. | |
* | |
* The default function does nothing. User code can override this with | |
* > Strophe.Connection.xmlInput = function (elem) { | |
* > (user code) | |
* > }; | |
* | |
* Parameters: | |
* (XMLElement) elem - The XML data received by the connection. | |
*/ | |
xmlInput: function (elem) | |
{ | |
return; | |
}, | |
/** Function: xmlOutput | |
* User overrideable function that receives XML data sent to the | |
* connection. | |
* | |
* The default function does nothing. User code can override this with | |
* > Strophe.Connection.xmlOutput = function (elem) { | |
* > (user code) | |
* > }; | |
* | |
* Parameters: | |
* (XMLElement) elem - The XMLdata sent by the connection. | |
*/ | |
xmlOutput: function (elem) | |
{ | |
return; | |
}, | |
/** Function: rawInput | |
* User overrideable function that receives raw data coming into the | |
* connection. | |
* | |
* The default function does nothing. User code can override this with | |
* > Strophe.Connection.rawInput = function (data) { | |
* > (user code) | |
* > }; | |
* | |
* Parameters: | |
* (String) data - The data received by the connection. | |
*/ | |
rawInput: function (data) | |
{ | |
return; | |
}, | |
/** Function: rawOutput | |
* User overrideable function that receives raw data sent to the | |
* connection. | |
* | |
* The default function does nothing. User code can override this with | |
* > Strophe.Connection.rawOutput = function (data) { | |
* > (user code) | |
* > }; | |
* | |
* Parameters: | |
* (String) data - The data sent by the connection. | |
*/ | |
rawOutput: function (data) | |
{ | |
return; | |
}, | |
/** Function: send | |
* Send a stanza. | |
* | |
* This function is called to push data onto the send queue to | |
* go out over the wire. Whenever a request is sent to the BOSH | |
* server, all pending data is sent and the queue is flushed. | |
* | |
* Parameters: | |
* (XMLElement | | |
* [XMLElement] | | |
* Strophe.Builder) elem - The stanza to send. | |
*/ | |
send: function (elem) | |
{ | |
var toSend = ""; | |
if (elem === null) {return ;} | |
if (typeof(elem.sort) === "function") { | |
for (var i = 0; i < elem.length; i++) { | |
toSend += Strophe.serialize(elem[i]); | |
this.xmlOutput(elem); | |
} | |
} else if (typeof(elem.tree) === "function") { | |
toSend = Strophe.serialize(elem.tree()); | |
this.xmlOutput(elem.tree()); | |
} else { | |
toSend = Strophe.serialize(elem); | |
this.xmlOutput(elem); | |
} | |
this.rawOutput(toSend); | |
this.ws.send(toSend); | |
}, | |
/** Function: flush | |
* UNUSED | |
*/ | |
flush: function () | |
{ | |
return | |
}, | |
/** Function: sendIQ | |
* Helper function to send IQ stanzas. | |
* | |
* Parameters: | |
* (XMLElement) elem - The stanza to send. | |
* (Function) callback - The callback function for a successful request. | |
* (Function) errback - The callback function for a failed or timed | |
* out request. On timeout, the stanza will be null. | |
* (Integer) timeout - The time specified in milliseconds for a | |
* timeout to occur. | |
* | |
* Returns: | |
* The id used to send the IQ. | |
*/ | |
sendIQ: function(elem, callback, errback, timeout) { | |
var timeoutHandler = null; | |
var that = this; | |
if (typeof(elem.tree) === "function") { | |
elem = elem.tree(); | |
} | |
var id = elem.getAttribute('id'); | |
// inject id if not found | |
if (!id) { | |
id = this.getUniqueId("sendIQ"); | |
elem.setAttribute("id", id); | |
} | |
var handler = this.addHandler(function (stanza) { | |
// remove timeout handler if there is one | |
if (timeoutHandler) { | |
that.deleteTimedHandler(timeoutHandler); | |
} | |
var iqtype = stanza.getAttribute('type'); | |
if (iqtype == 'result') { | |
if (callback) {callback(stanza);} | |
} else if (iqtype == 'error') { | |
if (errback) {errback(stanza);} | |
} else { | |
throw { | |
name: "StropheError", | |
message: "Got bad IQ type of " + iqtype | |
}; | |
} | |
}, null, 'iq', null, id); | |
// if timeout specified, setup timeout handler. | |
if (timeout) { | |
timeoutHandler = this.addTimedHandler(timeout, function () { | |
// get rid of normal handler | |
that.deleteHandler(handler); | |
// call errback on timeout with null stanza | |
if (errback) { | |
errback(null); | |
} | |
return false; | |
}); | |
} | |
this.send(elem); | |
return id; | |
}, | |
/** Function: addTimedHandler | |
* Add a timed handler to the connection. | |
* | |
* This function adds a timed handler. The provided handler will | |
* be called every period milliseconds until it returns false, | |
* the connection is terminated, or the handler is removed. Handlers | |
* that wish to continue being invoked should return true. | |
* | |
* Because of method binding it is necessary to save the result of | |
* this function if you wish to remove a handler with | |
* deleteTimedHandler(). | |
* | |
* Note that user handlers are not active until authentication is | |
* successful. | |
* | |
* Parameters: | |
* (Integer) period - The period of the handler. | |
* (Function) handler - The callback function. | |
* | |
* Returns: | |
* A reference to the handler that can be used to remove it. | |
*/ | |
addTimedHandler: function (period, handler) | |
{ | |
var thand = new Strophe.TimedHandler(period, handler); | |
this.addTimeds.push(thand); | |
return thand; | |
}, | |
/** Function: deleteTimedHandler | |
* Delete a timed handler for a connection. | |
* | |
* This function removes a timed handler from the connection. The | |
* handRef parameter is *not* the function passed to addTimedHandler(), | |
* but is the reference returned from addTimedHandler(). | |
* | |
* Parameters: | |
* (Strophe.TimedHandler) handRef - The handler reference. | |
*/ | |
deleteTimedHandler: function (handRef) | |
{ | |
// this must be done in the Idle loop so that we don't change | |
// the handlers during iteration | |
this.removeTimeds.push(handRef); | |
}, | |
/** Function: addHandler | |
* Add a stanza handler for the connection. | |
* | |
* This function adds a stanza handler to the connection. The | |
* handler callback will be called for any stanza that matches | |
* the parameters. Note that if multiple parameters are supplied, | |
* they must all match for the handler to be invoked. | |
* | |
* The handler will receive the stanza that triggered it as its argument. | |
* The handler should return true if it is to be invoked again; | |
* returning false will remove the handler after it returns. | |
* | |
* As a convenience, the ns parameters applies to the top level element | |
* and also any of its immediate children. This is primarily to make | |
* matching /iq/query elements easy. | |
* | |
* The options argument contains handler matching flags that affect how | |
* matches are determined. Currently the only flag is matchBare (a | |
* boolean). When matchBare is true, the from parameter and the from | |
* attribute on the stanza will be matched as bare JIDs instead of | |
* full JIDs. To use this, pass {matchBare: true} as the value of | |
* options. The default value for matchBare is false. | |
* | |
* The return value should be saved if you wish to remove the handler | |
* with deleteHandler(). | |
* | |
* Parameters: | |
* (Function) handler - The user callback. | |
* (String) ns - The namespace to match. | |
* (String) name - The stanza name to match. | |
* (String) type - The stanza type attribute to match. | |
* (String) id - The stanza id attribute to match. | |
* (String) from - The stanza from attribute to match. | |
* (String) options - The handler options | |
* | |
* Returns: | |
* A reference to the handler that can be used to remove it. | |
*/ | |
addHandler: function (handler, ns, name, type, id, from, options) | |
{ | |
var hand = new Strophe.Handler(handler, ns, name, type, id, from, options); | |
this.addHandlers.push(hand); | |
return hand; | |
}, | |
/** Function: deleteHandler | |
* Delete a stanza handler for a connection. | |
* | |
* This function removes a stanza handler from the connection. The | |
* handRef parameter is *not* the function passed to addHandler(), | |
* but is the reference returned from addHandler(). | |
* | |
* Parameters: | |
* (Strophe.Handler) handRef - The handler reference. | |
*/ | |
deleteHandler: function (handRef) | |
{ | |
// this must be done in the Idle loop so that we don't change | |
// the handlers during iteration | |
this.removeHandlers.push(handRef); | |
}, | |
/** Function: disconnect | |
* Start the graceful disconnection process. | |
* | |
* This function starts the disconnection process. This process starts | |
* by sending unavailable presence and sending BOSH body of type | |
* terminate. A timeout handler makes sure that disconnection happens | |
* even if the BOSH server does not respond. | |
* | |
* The user supplied connection callback will be notified of the | |
* progress as this process happens. | |
* | |
* Parameters: | |
* (String) reason - The reason the disconnect is occuring. | |
*/ | |
disconnect: function (reason) | |
{ | |
this._changeConnectStatus(Strophe.Status.DISCONNECTING, reason); | |
Strophe.info("Disconnect was called because: " + reason); | |
if (this.connected) { | |
// setup timeout handler | |
this._disconnectTimeout = this._addSysTimedHandler( | |
3000, this._onDisconnectTimeout.bind(this)); | |
this._sendTerminate(); | |
} | |
}, | |
/** PrivateFunction: _changeConnectStatus | |
* _Private_ helper function that makes sure plugins and the user's | |
* callback are notified of connection status changes. | |
* | |
* Parameters: | |
* (Integer) status - the new connection status, one of the values | |
* in Strophe.Status | |
* (String) condition - the error condition or null | |
*/ | |
_changeConnectStatus: function (status, condition) | |
{ | |
//window.console.log("_changeConnectStatus:" +status); | |
// notify all plugins listening for status changes | |
for (var k in Strophe._connectionPlugins) { | |
if (Strophe._connectionPlugins.hasOwnProperty(k)) { | |
var plugin = this[k]; | |
if (plugin.statusChanged) { | |
try { | |
plugin.statusChanged(status, condition); | |
} catch (err) { | |
Strophe.error("" + k + " plugin caused an exception " + | |
"changing status: " + err); | |
} | |
} | |
} | |
} | |
// notify the user's callback | |
if (this.connect_callback) { | |
try { | |
this.connect_callback(status, condition); | |
} catch (e) { | |
Strophe.error("User connection callback caused an " + | |
"exception: " + e); | |
} | |
} | |
}, | |
/** PrivateFunction: _doDisconnect | |
* _Private_ function to disconnect. | |
* | |
* This is the last piece of the disconnection logic. This resets the | |
* connection and alerts the user's connection callback. | |
*/ | |
_doDisconnect: function () | |
{ | |
Strophe.info("_doDisconnect was called"); | |
this.authenticated = false; | |
this.disconnecting = false; | |
this.sid = null; | |
this.streamId = null; | |
// tell the parent we disconnected | |
if (this.connected) { | |
this._changeConnectStatus(Strophe.Status.DISCONNECTED, null); | |
this.connected = false; | |
} | |
// delete handlers | |
this.handlers = []; | |
this.timedHandlers = []; | |
this.removeTimeds = []; | |
this.removeHandlers = []; | |
this.addTimeds = []; | |
this.addHandlers = []; | |
if(this.ws.readyState != this.ws.CLOSED) | |
{ | |
this.ws.close(); | |
} | |
}, | |
_ws_on_close: function(ev){ | |
Strophe.info("websocket closed"); | |
this._doDisconnect(); | |
}, | |
_keep_alive_handler: function(){ | |
this.ws.send("\n"); | |
return true; | |
}, | |
_send_initial_stream: function(){ | |
//window.console.log("_send_initial_stream "); | |
this._changeConnectStatus(Strophe.Status.CONNECTING, null); | |
var stream = '<?xml version="1.0"?><stream:stream xmlns:stream="http://etherx.jabber.org/streams" version="1.0" xmlns="jabber:client" to="'+ this.domain + '" xml:lang="en" xmlns:xml="http://www.w3.org/XML/1998/namespace" >' | |
this.rawOutput(stream); | |
this.ws.send(stream); | |
}, | |
_get_stream_id: function(onmessage,event){ | |
elem = event.data | |
this.rawInput(elem); | |
if (event.data.match(/id=[\'\"]([^\'\"]+)[\'\"]/)) | |
this.streamId = RegExp.$1; | |
//window.console.log("_get_stream_id: "+this.streamId); | |
this.ws.onmessage = onmessage; | |
}, | |
_parseTree: function(elem){ | |
//window.console.log("_parseTree for '"+elem+"'."); | |
try { | |
if(this._parser == undefined){ | |
this._parser = new DOMParser(); | |
} | |
this._parser = new DOMParser(); | |
// Because FF wants valid XML, with correct namespaces ! | |
// node = this._parser.parseFromString("<body xmlns:stream='foo' >" + elem + "</body>", "text/xml").documentElement.firstChild; | |
//and chrome doesn't | |
node = this._parser.parseFromString(elem, "text/xml").documentElement; | |
//window.console.log("Document element is: "+ node.tagName); | |
if (node && node.tagName == "parsererror") | |
{ | |
//window.console.log("_parseTree got parseerror when reading: '"+elem+"'."); | |
return null; | |
} | |
return node; | |
} catch (e) { | |
//window.console.log("Exception when parsing: "+e); | |
Strophe.error("Error : " + e) | |
} | |
return null; | |
}, | |
/** PrivateFunction: _dataRecv | |
* _Private_ handler to processes incoming data from the the connection. | |
* | |
* Except for _connect_cb handling the initial connection request, | |
* this function handles the incoming data for all requests. This | |
* function also fires stanza handlers that match each incoming | |
* stanza. | |
* | |
* Parameters: | |
* (Strophe.Request) req - The request that has data ready. | |
*/ | |
_dataRecv: function (event) | |
{ | |
//window.console.log("_dataRecv"); | |
if(!this.closingRegEx && event.data.match(/^<(\w+)/)) { | |
this.closingRegEx = new RegExp("\\</"+RegExp.$1+"\\>"); | |
console.log("regex:" +this.closingRegEx); | |
} | |
this.buffered_data += event.data+""; | |
if (!event.data.match(this.closingRegEx) && !event.data.match(/\/>$/) && !event.data.match(/\<\?xml/) ) { | |
console.log("buffering: "+event.data); | |
return; | |
} | |
this.closingRegEx = null; | |
var elem, data = this.buffered_data; | |
// var elem, data = event.data; | |
console.log("performing data: "+data); | |
try { | |
elem = this._parseTree(data); | |
} catch (e) { | |
if (e != "parsererror") {throw e;} | |
window.console.log("_dataRecv strophe-parsererror"); | |
this.disconnect("strophe-parsererror"); | |
} | |
if (elem === null) { | |
this.buffered_data = ""; | |
return; | |
} | |
this.xmlInput(elem); | |
this.rawInput(data); | |
// remove handlers scheduled for deletion | |
var i, hand; | |
while (this.removeHandlers.length > 0) { | |
hand = this.removeHandlers.pop(); | |
i = this.handlers.indexOf(hand); | |
if (i >= 0) { | |
this.handlers.splice(i, 1); | |
} | |
} | |
// add handlers scheduled for addition | |
while (this.addHandlers.length > 0) { | |
this.handlers.push(this.addHandlers.pop()); | |
} | |
// handle graceful disconnect | |
if (this.disconnecting) { | |
this.deleteTimedHandler(this._disconnectTimeout); | |
this._disconnectTimeout = null; | |
this._doDisconnect(); | |
return; | |
} | |
var typ = elem.getAttribute("type"); | |
var cond, conflict; | |
if (typ !== null && typ == "terminate") { | |
// an error occurred | |
cond = elem.getAttribute("condition"); | |
conflict = elem.getElementsByTagName("conflict"); | |
if (cond !== null) { | |
if (cond == "remote-stream-error" && conflict.length > 0) { | |
cond = "conflict"; | |
} | |
this._changeConnectStatus(Strophe.Status.CONNFAIL, cond); | |
} else { | |
this._changeConnectStatus(Strophe.Status.CONNFAIL, "unknown"); | |
} | |
this.disconnect(); | |
return; | |
} | |
// // send each incoming stanza through the handler chain | |
// var i, newList; | |
// // process handlers | |
// newList = this.handlers; | |
// this.handlers = []; | |
// for (i = 0; i < newList.length; i++) { | |
// var hand = newList[i]; | |
// window.console.log("_dataRecv checking handler: "+ hand.name + " (match: for '"+elem.tagName +"' :"+hand.isMatch(elem)); | |
// if (hand.isMatch(elem) && | |
// (this.authenticated || !hand.user)) { | |
// window.console.log("_dataRecv running handler: "+ hand.name); | |
// if (hand.run(elem)) { | |
// this.handlers.push(hand); | |
// } | |
// } else { | |
// this.handlers.push(hand); | |
// } | |
// } | |
//check if the document element needs to be handled | |
//window.console.log("_dataRev Checking root node for registered handlers"); | |
newList = this.handlers; | |
this.handlers = []; | |
for (i = 0; i < newList.length; i++) { | |
hand = newList[i]; | |
window.console.log("_dataRecv checking handler: "+ hand.name + " (match: for '"+elem.tagName +"' :"+hand.isMatch(elem)); | |
if (hand.isMatch(elem) && | |
(this.authenticated || !hand.user)) { | |
//window.console.log("_dataRecv running handler: "+ hand.name); | |
if (hand.run(elem)) { | |
this.handlers.push(hand); | |
} | |
} else { | |
this.handlers.push(hand); | |
} | |
} | |
// send each incoming stanza through the handler chain | |
var j, childNode; | |
if (elem.childNodes.length >0) | |
{ | |
//window.console.log("_dataRev Checking child nodes..."); | |
} | |
for (j = 0; j < elem.childNodes.length; j++) { | |
childNode = elem.childNodes[j]; | |
if (childNode.nodeType == Strophe.ElementType.NORMAL) { | |
var newList; | |
// process handlers | |
newList = this.handlers; | |
this.handlers = []; | |
for (i = 0; i < newList.length; i++) { | |
var myHand = newList[i]; | |
//window.console.log("_dataRecv checking handler: "+ myHand.name + " (match: for '"+childNode.tagName +"' :"+myHand.isMatch(childNode)); | |
if (myHand.isMatch(childNode) && | |
(this.authenticated || !myHand.user)) { | |
//window.console.log("_dataRecv running handler: "+ myHand.name); | |
if (myHand.run(childNode)) { | |
this.handlers.push(myHand); | |
} | |
} else { | |
this.handlers.push(myHand); | |
} | |
} | |
} | |
} | |
this.buffered_data = ""; | |
}, | |
/** PrivateFunction: _sendTerminate | |
* _Private_ function to send initial disconnect sequence. | |
* | |
* This is the first step in a graceful disconnect. It sends | |
* the BOSH server a terminate body and includes an unavailable | |
* presence if authentication has completed. | |
*/ | |
_sendTerminate: function () | |
{ | |
Strophe.info("_sendTerminate was called"); | |
window.console.log("_sendTerminate"); | |
var stanza = {} | |
if (this.authenticated) { | |
stanza = $pres({ | |
xmlns: Strophe.NS.CLIENT, | |
type: 'unavailable' | |
}); | |
} | |
this.disconnecting = true; | |
this.send(stanza); | |
}, | |
/** PrivateFunction: _connect_cb | |
* _Private_ handler for initial connection request. | |
* | |
* This handler is used to process the initial connection request | |
* response from the BOSH server. It is used to set up authentication | |
* handlers and start the authentication process. | |
* | |
* SASL authentication will be attempted if available, otherwise | |
* the code will fall back to legacy authentication. | |
* | |
* Parameters: | |
* (Strophe.Request) req - The current request. | |
*/ | |
_connect_cb: function (event) | |
{ | |
//window.console.log("_connect_cb was called"); | |
Strophe.info("_connect_cb was called"); | |
this.connected = true; | |
this.ws.onmessage=this._dataRecv.bind(this); | |
var strStanza = event.data; | |
if (!strStanza) { | |
//window.console.log("! strStanza"); | |
return; | |
} | |
stanza = this._parseTree(strStanza) | |
this.xmlInput(stanza); | |
this.rawInput(Strophe.serialize(stanza)); | |
var do_sasl_plain = false; | |
var do_sasl_digest_md5 = false; | |
var do_sasl_anonymous = false; | |
var mechanisms = stanza.getElementsByTagName("mechanism"); | |
var i, mech, auth_str, hashed_auth_str; | |
if (mechanisms.length > 0) { | |
for (i = 0; i < mechanisms.length; i++) { | |
mech = Strophe.getText(mechanisms[i]); | |
if (mech == 'DIGEST-MD5') { | |
do_sasl_digest_md5 = true; | |
} else if (mech == 'PLAIN') { | |
do_sasl_plain = true; | |
} else if (mech == 'ANONYMOUS') { | |
do_sasl_anonymous = true; | |
} | |
} | |
} | |
if (Strophe.getNodeFromJid(this.jid) === null && | |
do_sasl_anonymous) { | |
this._changeConnectStatus(Strophe.Status.AUTHENTICATING, null); | |
this._sasl_success_handler = this._addSysHandler( | |
this._sasl_success_cb.bind(this), null, | |
"success", null, null); | |
this._sasl_failure_handler = this._addSysHandler( | |
this._sasl_failure_cb.bind(this), null, | |
"failure", null, null); | |
this.send($build("auth", { | |
xmlns: Strophe.NS.SASL, | |
mechanism: "ANONYMOUS" | |
}).tree()); | |
} else if (Strophe.getNodeFromJid(this.jid) === null) { | |
// we don't have a node, which is required for non-anonymous | |
// client connections | |
this._changeConnectStatus(Strophe.Status.CONNFAIL, | |
'x-strophe-bad-non-anon-jid'); | |
this.disconnect(); | |
} else if (do_sasl_digest_md5) { | |
//window.console.log("_connect_cb: authenticating digest_md5"); | |
this._changeConnectStatus(Strophe.Status.AUTHENTICATING, null); | |
this._sasl_challenge_handler = this._addSysHandler( | |
this._sasl_challenge1_cb.bind(this), null, | |
"challenge", null, null); | |
this._sasl_failure_handler = this._addSysHandler( | |
this._sasl_failure_cb.bind(this), null, | |
"failure", null, null); | |
this.send($build("auth", { | |
xmlns: Strophe.NS.SASL, | |
mechanism: "DIGEST-MD5" | |
}).tree()); | |
} else if (do_sasl_plain) { | |
// Build the plain auth string (barejid null | |
// username null password) and base 64 encoded. | |
auth_str = Strophe.getBareJidFromJid(this.jid); | |
auth_str = auth_str + "\u0000"; | |
auth_str = auth_str + Strophe.getNodeFromJid(this.jid); | |
auth_str = auth_str + "\u0000"; | |
auth_str = auth_str + this.pass; | |
this._changeConnectStatus(Strophe.Status.AUTHENTICATING, null); | |
this._sasl_success_handler = this._addSysHandler( | |
this._sasl_success_cb.bind(this), null, | |
"success", null, null); | |
this._sasl_failure_handler = this._addSysHandler( | |
this._sasl_failure_cb.bind(this), null, | |
"failure", null, null); | |
hashed_auth_str = Base64.encode(auth_str); | |
this.send($build("auth", { | |
xmlns: Strophe.NS.SASL, | |
mechanism: "PLAIN" | |
}).t(hashed_auth_str).tree()); | |
} else { | |
this._changeConnectStatus(Strophe.Status.AUTHENTICATING, null); | |
this._addSysHandler(this._auth1_cb.bind(this), null, null, | |
null, "_auth_1"); | |
this.send($iq({ | |
type: "get", | |
to: this.domain, | |
id: "_auth_1" | |
}).c("query", { | |
xmlns: Strophe.NS.AUTH | |
}).c("username", {}).t(Strophe.getNodeFromJid(this.jid)).tree()); | |
} | |
}, | |
_rebind_cb: function (jid, sid, event){ | |
this.connected = true; | |
this.ws.onmessage=this._dataRecv.bind(this); | |
var strStanza = event.data; | |
if (!strStanza) {return;} | |
stanza = this._parseTree(strStanza) | |
this.xmlInput(stanza); | |
this.rawInput(Strophe.serialize(stanza)); | |
var rebinds = stanza.getElementsByTagName("rebind"); | |
if (rebinds.length > 0){ | |
this._changeConnectStatus(Strophe.Status.ATTACHED, null); | |
this.send($build('rebind',{ | |
xmlns:"p1:rebind" | |
}).c("jid", {}).t(jid) | |
.up() | |
.c("sid", {}).t(sid).tree()); | |
this._rebind_success_handler = this._addSysHandler( | |
this._rebind_success_cb.bind(this), null, | |
"rebind", null, null); | |
this._rebind_failure_handler = this._addSysHandler( | |
this._rebind_failure_cb.bind(this), null, | |
"failure", null, null); | |
} else { | |
this._changeConnectStatus(Strophe.Status.CONNFAIL, | |
'x-strophe-rebind-not-supported'); | |
} | |
}, | |
_rebind_success_cb: function(elem){ | |
Strophe.info("Rebinding succeeded."); | |
//window.console.log("_rebind_success_cb "); | |
// remove old handlers | |
this.authenticated=true; | |
this.connected=true; | |
this.deleteHandler(this._rebind_failure_handler); | |
this._rebind_failure_handler = null; | |
this._changeConnectStatus(Strophe.Status.ATTACHED, null); | |
return false; | |
}, | |
_rebind_failure_cb: function(elem){ | |
Strophe.info("Rebinding failed."); | |
//window.console.log("_rebind_failed_cb "); | |
// delete unneeded handlers | |
if (this._rebind_success_handler) { | |
this.deleteHandler(this._rebind_success_handler); | |
this._rebind_success_handler = null; | |
} | |
this._changeConnectStatus(Strophe.Status.CONNFAIL, null); | |
return false; | |
}, | |
/** PrivateFunction: _sasl_challenge1_cb | |
* _Private_ handler for DIGEST-MD5 SASL authentication. | |
* | |
* Parameters: | |
* (XMLElement) elem - The challenge stanza. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_sasl_challenge1_cb: function (elem) | |
{ | |
//window.console.log("_sasl_challenge1_cb "); | |
var attribMatch = /([a-z]+)=("[^"]+"|[^,"]+)(?:,|$)/; | |
var challenge = Base64.decode(Strophe.getText(elem)); | |
var cnonce = MD5.hexdigest(Math.random() * 1234567890); | |
var realm = ""; | |
var host = null; | |
var nonce = ""; | |
var qop = ""; | |
var matches; | |
// remove unneeded handlers | |
this.deleteHandler(this._sasl_failure_handler); | |
//window.console.log("_sasl_challenge1_cb "+elem); | |
while (challenge.match(attribMatch)) { | |
matches = challenge.match(attribMatch); | |
challenge = challenge.replace(matches[0], ""); | |
matches[2] = matches[2].replace(/^"(.+)"$/, "$1"); | |
switch (matches[1]) { | |
case "realm": | |
realm = matches[2]; | |
break; | |
case "nonce": | |
nonce = matches[2]; | |
break; | |
case "qop": | |
qop = matches[2]; | |
break; | |
case "host": | |
host = matches[2]; | |
break; | |
} | |
} | |
var digest_uri = "xmpp/" + this.domain; | |
if (host !== null) { | |
digest_uri = digest_uri + "/" + host; | |
} | |
var A1 = MD5.hash(Strophe.getNodeFromJid(this.jid) + | |
":" + realm + ":" + this.pass) + | |
":" + nonce + ":" + cnonce; | |
var A2 = 'AUTHENTICATE:' + digest_uri; | |
var responseText = ""; | |
responseText += 'username=' + | |
this._quote(Strophe.getNodeFromJid(this.jid)) + ','; | |
responseText += 'realm=' + this._quote(realm) + ','; | |
responseText += 'nonce=' + this._quote(nonce) + ','; | |
responseText += 'cnonce=' + this._quote(cnonce) + ','; | |
responseText += 'nc="00000001",'; | |
responseText += 'qop="auth",'; | |
responseText += 'digest-uri=' + this._quote(digest_uri) + ','; | |
responseText += 'response=' + this._quote( | |
MD5.hexdigest(MD5.hexdigest(A1) + ":" + | |
nonce + ":00000001:" + | |
cnonce + ":auth:" + | |
MD5.hexdigest(A2))) + ','; | |
responseText += 'charset="utf-8"'; | |
this._sasl_challenge_handler = this._addSysHandler( | |
this._sasl_challenge2_cb.bind(this), null, | |
"challenge", null, null); | |
this._sasl_success_handler = this._addSysHandler( | |
this._sasl_success_cb.bind(this), null, | |
"success", null, null); | |
this._sasl_failure_handler = this._addSysHandler( | |
this._sasl_failure_cb.bind(this), null, | |
"failure", null, null); | |
this.send($build('response', { | |
xmlns: Strophe.NS.SASL | |
}).t(Base64.encode(responseText)).tree()); | |
return false; | |
}, | |
/** PrivateFunction: _quote | |
* _Private_ utility function to backslash escape and quote strings. | |
* | |
* Parameters: | |
* (String) str - The string to be quoted. | |
* | |
* Returns: | |
* quoted string | |
*/ | |
_quote: function (str) | |
{ | |
return '"' + str.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; | |
//" end string workaround for emacs | |
}, | |
/** PrivateFunction: _sasl_challenge2_cb | |
* _Private_ handler for second step of DIGEST-MD5 SASL authentication. | |
* | |
* Parameters: | |
* (XMLElement) elem - The challenge stanza. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_sasl_challenge2_cb: function (elem) | |
{ | |
//window.console.log("_sasl_challenge2_cb "); | |
// remove unneeded handlers | |
this.deleteHandler(this._sasl_success_handler); | |
this.deleteHandler(this._sasl_failure_handler); | |
this._sasl_success_handler = this._addSysHandler( | |
this._sasl_success_cb.bind(this), null, | |
"success", null, null); | |
this._sasl_failure_handler = this._addSysHandler( | |
this._sasl_failure_cb.bind(this), null, | |
"failure", null, null); | |
this.send($build('response', {xmlns: Strophe.NS.SASL}).tree()); | |
return false; | |
}, | |
/** PrivateFunction: _auth1_cb | |
* _Private_ handler for legacy authentication. | |
* | |
* This handler is called in response to the initial <iq type='get'/> | |
* for legacy authentication. It builds an authentication <iq/> and | |
* sends it, creating a handler (calling back to _auth2_cb()) to | |
* handle the result | |
* | |
* Parameters: | |
* (XMLElement) elem - The stanza that triggered the callback. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_auth1_cb: function (elem) | |
{ | |
// build plaintext auth iq | |
var iq = $iq({type: "set", id: "_auth_2"}) | |
.c('query', {xmlns: Strophe.NS.AUTH}) | |
.c('username', {}).t(Strophe.getNodeFromJid(this.jid)) | |
.up() | |
.c('password').t(this.pass); | |
if (!Strophe.getResourceFromJid(this.jid)) { | |
// since the user has not supplied a resource, we pick | |
// a default one here. unlike other auth methods, the server | |
// cannot do this for us. | |
this.jid = Strophe.getBareJidFromJid(this.jid) + '/strophe'; | |
} | |
iq.up().c('resource', {}).t(Strophe.getResourceFromJid(this.jid)); | |
this._addSysHandler(this._auth2_cb.bind(this), null, | |
null, null, "_auth_2"); | |
this.send(iq.tree()); | |
return false; | |
}, | |
/** PrivateFunction: _sasl_success_cb | |
* _Private_ handler for succesful SASL authentication. | |
* | |
* Parameters: | |
* (XMLElement) elem - The matching stanza. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_sasl_success_cb: function (elem) | |
{ | |
Strophe.info("SASL authentication succeeded."); | |
//window.console.log("_sasl_success_cb "); | |
// remove old handlers | |
this.deleteHandler(this._sasl_failure_handler); | |
this._sasl_failure_handler = null; | |
if (this._sasl_challenge_handler) { | |
this.deleteHandler(this._sasl_challenge_handler); | |
this._sasl_challenge_handler = null; | |
} | |
this._addSysHandler(this._sasl_auth1_cb.bind(this), null, | |
"stream:features", null, null); | |
// We need the new stream_id | |
// this.ws.onmessage = this._get_stream_id.bind(this).prependArg(this._dataRecv.bind(this)); | |
this._get_stream_id.bind(this).prependArg(this._dataRecv.bind(this)); | |
// we must send an xmpp:restart now | |
this._send_initial_stream(); | |
return false; | |
}, | |
/** PrivateFunction: _sasl_auth1_cb | |
* _Private_ handler to start stream binding. | |
* | |
* Parameters: | |
* (XMLElement) elem - The matching stanza. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_sasl_auth1_cb: function (elem) | |
{ | |
var i, child; | |
//window.console.log("_sasl_auth1_cb"); | |
for (i = 0; i < elem.childNodes.length; i++) { | |
child = elem.childNodes[i]; | |
if (child.nodeName == 'bind') { | |
//window.console.log("_sasl_auth1_cb bind"); | |
this.do_bind = true; | |
} | |
if (child.nodeName == 'session') { | |
//window.console.log("_sasl_auth1_cb session"); | |
this.do_session = true; | |
} | |
} | |
if (!this.do_bind) { | |
//window.console.log("_sasl_auth1_cb auth fail"); | |
this._changeConnectStatus(Strophe.Status.AUTHFAIL, null); | |
return false; | |
} else { | |
this._addSysHandler(this._sasl_bind_cb.bind(this), null, null, | |
null, "_bind_auth_2"); | |
var resource = Strophe.getResourceFromJid(this.jid); | |
if (resource) { | |
this.send($iq({type: "set", id: "_bind_auth_2"}) | |
.c('bind', {xmlns: Strophe.NS.BIND}) | |
.c('resource', {}).t(resource).tree()); | |
} else { | |
this.send($iq({type: "set", id: "_bind_auth_2"}) | |
.c('bind', {xmlns: Strophe.NS.BIND}) | |
.tree()); | |
} | |
} | |
return false; | |
}, | |
/** PrivateFunction: _sasl_bind_cb | |
* _Private_ handler for binding result and session start. | |
* | |
* Parameters: | |
* (XMLElement) elem - The matching stanza. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_sasl_bind_cb: function (elem) | |
{ | |
//window.console.log("_sasl_bind_cb "); | |
if (elem.getAttribute("type") == "error") { | |
Strophe.info("SASL binding failed."); | |
this._changeConnectStatus(Strophe.Status.AUTHFAIL, null); | |
return false; | |
} | |
// TODO - need to grab errors | |
var bind = elem.getElementsByTagName("bind"); | |
var jidNode; | |
if (bind.length > 0) { | |
// Grab jid | |
jidNode = bind[0].getElementsByTagName("jid"); | |
if (jidNode.length > 0) { | |
this.jid = Strophe.getText(jidNode[0]); | |
if (this.do_session) { | |
this._addSysHandler(this._sasl_session_cb.bind(this), | |
null, null, null, "_session_auth_2"); | |
this.send($iq({type: "set", id: "_session_auth_2"}) | |
.c('session', {xmlns: Strophe.NS.SESSION}) | |
.tree()); | |
} else { | |
this.authenticated = true; | |
this._changeConnectStatus(Strophe.Status.CONNECTED, null); | |
} | |
} | |
} else { | |
Strophe.info("SASL binding failed."); | |
this._changeConnectStatus(Strophe.Status.AUTHFAIL, null); | |
return false; | |
} | |
}, | |
/** PrivateFunction: _sasl_session_cb | |
* _Private_ handler to finish successful SASL connection. | |
* | |
* This sets Connection.authenticated to true on success, which | |
* starts the processing of user handlers. | |
* | |
* Parameters: | |
* (XMLElement) elem - The matching stanza. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_sasl_session_cb: function (elem) | |
{ | |
//window.console.log("_sasl_session_cb "); | |
if (elem.getAttribute("type") == "result") { | |
this.authenticated = true; | |
this._changeConnectStatus(Strophe.Status.CONNECTED, null); | |
} else if (elem.getAttribute("type") == "error") { | |
Strophe.info("Session creation failed."); | |
this._changeConnectStatus(Strophe.Status.AUTHFAIL, null); | |
return false; | |
} | |
return false; | |
}, | |
/** PrivateFunction: _sasl_failure_cb | |
* _Private_ handler for SASL authentication failure. | |
* | |
* Parameters: | |
* (XMLElement) elem - The matching stanza. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_sasl_failure_cb: function (elem) | |
{ | |
//window.console.log("_sasl_failure_cb "); | |
// delete unneeded handlers | |
if (this._sasl_success_handler) { | |
this.deleteHandler(this._sasl_success_handler); | |
this._sasl_success_handler = null; | |
} | |
if (this._sasl_challenge_handler) { | |
this.deleteHandler(this._sasl_challenge_handler); | |
this._sasl_challenge_handler = null; | |
} | |
this._changeConnectStatus(Strophe.Status.AUTHFAIL, null); | |
return false; | |
}, | |
/** PrivateFunction: _auth2_cb | |
* _Private_ handler to finish legacy authentication. | |
* | |
* This handler is called when the result from the jabber:iq:auth | |
* <iq/> stanza is returned. | |
* | |
* Parameters: | |
* (XMLElement) elem - The stanza that triggered the callback. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_auth2_cb: function (elem) | |
{ | |
//window.console.log("_auth2_cb "); | |
if (elem.getAttribute("type") == "result") { | |
this.authenticated = true; | |
this._changeConnectStatus(Strophe.Status.CONNECTED, null); | |
} else if (elem.getAttribute("type") == "error") { | |
this._changeConnectStatus(Strophe.Status.AUTHFAIL, null); | |
this.disconnect(); | |
} | |
return false; | |
}, | |
/** PrivateFunction: _addSysTimedHandler | |
* _Private_ function to add a system level timed handler. | |
* | |
* This function is used to add a Strophe.TimedHandler for the | |
* library code. System timed handlers are allowed to run before | |
* authentication is complete. | |
* | |
* Parameters: | |
* (Integer) period - The period of the handler. | |
* (Function) handler - The callback function. | |
*/ | |
_addSysTimedHandler: function (period, handler) | |
{ | |
var thand = new Strophe.TimedHandler(period, handler); | |
thand.user = false; | |
this.addTimeds.push(thand); | |
return thand; | |
}, | |
/** PrivateFunction: _addSysHandler | |
* _Private_ function to add a system level stanza handler. | |
* | |
* This function is used to add a Strophe.Handler for the | |
* library code. System stanza handlers are allowed to run before | |
* authentication is complete. | |
* | |
* Parameters: | |
* (Function) handler - The callback function. | |
* (String) ns - The namespace to match. | |
* (String) name - The stanza name to match. | |
* (String) type - The stanza type attribute to match. | |
* (String) id - The stanza id attribute to match. | |
*/ | |
_addSysHandler: function (handler, ns, name, type, id) | |
{ | |
var hand = new Strophe.Handler(handler, ns, name, type, id); | |
hand.user = false; | |
this.addHandlers.push(hand); | |
return hand; | |
}, | |
/** PrivateFunction: _onDisconnectTimeout | |
* _Private_ timeout handler for handling non-graceful disconnection. | |
* | |
* If the graceful disconnect process does not complete within the | |
* time allotted, this handler finishes the disconnect anyway. | |
* | |
* Returns: | |
* false to remove the handler. | |
*/ | |
_onDisconnectTimeout: function () | |
{ | |
Strophe.info("_onDisconnectTimeout was called"); | |
// cancel all remaining requests and clear the queue | |
// actually disconnect | |
this._doDisconnect(); | |
return false; | |
}, | |
/** PrivateFunction: _onIdle | |
* _Private_ handler to process events during idle cycle. | |
* | |
* This handler is called every 100ms to fire timed handlers that | |
* are ready and keep poll requests going. | |
*/ | |
_onIdle: function () | |
{ | |
var i, thand, since, newList; | |
// remove timed handlers that have been scheduled for deletion | |
while (this.removeTimeds.length > 0) { | |
thand = this.removeTimeds.pop(); | |
i = this.timedHandlers.indexOf(thand); | |
if (i >= 0) { | |
this.timedHandlers.splice(i, 1); | |
} | |
} | |
// add timed handlers scheduled for addition | |
while (this.addTimeds.length > 0) { | |
this.timedHandlers.push(this.addTimeds.pop()); | |
} | |
// call ready timed handlers | |
var now = new Date().getTime(); | |
newList = []; | |
for (i = 0; i < this.timedHandlers.length; i++) { | |
thand = this.timedHandlers[i]; | |
if (this.authenticated || !thand.user) { | |
since = thand.lastCalled + thand.period; | |
if (since - now <= 0) { | |
if (thand.run()) { | |
newList.push(thand); | |
} | |
} else { | |
newList.push(thand); | |
} | |
} | |
} | |
this.timedHandlers = newList; | |
// reactivate the timer | |
clearTimeout(this._idleTimeout); | |
this._idleTimeout = setTimeout(this._onIdle.bind(this), 100); | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment