xmpp.js

1
// xmpp.js - Server-side XMPP in Javascript
2
// (C) 2010 Matthew Wild
3
// This project is released under the MIT/X11
4
// license. For more info see the COPYING file.
5
 
6
// Node libs
7
var tcp = require("tcp");
8
 
9
// External libs
10
var xml = require("./node-xml/lib/node-xml");
11
var sha1 = require("./sha1");
12
 
13
// This lib
14
var xmpp = exports;
15
 
16
// Wraps a function so that its 'this' is always 'context' when called
17
var recontext = function (context, f) { return function () { return f.apply(context, arguments); }; };
18
 
19
xmpp.xmlns = {
20
	streams: "http://etherx.jabber.org/streams",
21
	component_accept: "jabber:component:accept",
22
    chatstates: "http://jabber.org/protocol/chatstates"
23
};
24
 
25
xmpp.Status = {
26
	ERROR: 0,
27
	CONNECTING: 1,
28
	CONNFAIL: 2,
29
	AUTHENTICATING: 3,
30
	AUTHFAIL: 4,
31
	CONNECTED: 5,
32
	DISCONNECTED: 6,
33
	DISCONNECTING: 7,
34
};
35
 
36
xmpp.LogLevel = {
37
	DEBUG: 0,
38
	INFO: 1,
39
	WARN: 2,
40
	ERROR: 3,
41
	FATAL: 4
42
};
43
/** XMPPStream: Takes a parser, eats bytes, fires callbacks on stream events **/
44
xmpp.Stream = function (callbacks)
45
{
46
	this.callbacks = callbacks;
47
	var stream = this;
48
	var stanza;
49
	this.parser = new xml.SaxParser(function (cb)
50
	{
51
		cb.onStartElementNS(function (tagname, attr_arr, prefix, uri, namespaces)
52
		{
53
			var attr = {};
54
			if(uri != xmpp.xmlns.component_accept)
55
				attr.xmlns = uri;
56
			for(var i=0;i<attr_arr.length;i++)
57
				attr[attr_arr[i][0]] = attr_arr[i][1];
58
			for(var i=0;i<namespaces.length;i++)
59
				if(namespaces[i][0].length > 0)
60
					attr["xmlns:"+namespaces[i][0]] = namespaces[i][1];
61
			if(!stanza)
62
			{
63
				if(stream.opened)
64
					stanza = xmpp.stanza(tagname, attr);
65
				else if(tagname == "stream" && uri == xmpp.xmlns.streams)
66
				{
67
					stream.opened = true;
68
					callbacks.opened(attr);
69
				}
70
				else
71
				{
72
					callbacks.error("no-stream");
73
				}
74
			}
75
			else
76
			{
77
				stanza.c(tagname, attr);
78
			}
79
			
80
		});
81
		
82
		cb.onEndElementNS(function(tagname) {
83
			if(stanza)
84
				if(stanza.last_node.length == 1)
85
				{
86
					callbacks.stanza(stanza);
87
					stanza = null;
88
				}
89
				else
90
					stanza.up();
91
			else
92
			{
93
				stream.opened = false;
94
				callbacks.closed();
95
			}
96
		});
97
		
98
		cb.onCharacters(function(chars) {
99
			if(stanza)
100
				stanza.t(chars);
101
		});
102
	});
103
	
104
	this.data = function (data)
105
	{
106
		return this.parser.parseString(data);
107
	}
108
	
109
	return this;
110
};
111
 
112
 
113
/** Connection: Takes host/port, manages stream **/
114
xmpp.Connection = function (host, port)
115
{
116
	this.host = host || "localhost";
117
	this.port = port || 5347;
118
	
119
	this.stream = new xmpp.Stream({
120
		opened: recontext(this, this._stream_opened),
121
		stanza: recontext(this, this._handle_stanza),
122
		closed: recontext(this, this._stream_closed)
123
	});
124
	
125
	this._uniqueId = 0;
126
	
127
	return this;
128
};
129
 
130
exports.Connection.prototype = {
131
	connect: function (jid, pass, callback)
132
	{
133
		this.jid = jid;
134
		this.password = pass;
135
		this.connect_callback = callback;
136
		
137
		var conn = this;
138
 
139
        	// Note that tcp.createConnection also initiates the connection.
140
        	// This doesn't appear to create problems with adding listeners
141
        	// afterward, but should be kept in mind should any arise.
142
        	this.socket = tcp.createConnection(this.port, this.host)
143
        
144
        	this.socket.setTimeout(0); // Because Node's default timeout is 60s
145
 
146
		this.socket.addListener("connect", recontext(this, conn._socket_connected));
147
		this.socket.addListener("disconnect", recontext(this, conn._socket_disconnected));
148
		this.socket.addListener("data", recontext(this, conn._socket_received));
149
		
150
		this.handlers = [];
151
		
152
		this._setStatus(xmpp.Status.CONNECTING);
153
	},
154
	
155
	send: function (data)
156
	{
157
		this.debug("SND: "+data);
158
		this.socket.write(data.toString());
159
	},
160
	
161
	sendIQ: function (iq, on_result, on_error)
162
	{
163
		if(!iq.attr.id)
164
			iq.attr.id = this.getUniqueId();
165
		this.addHandler(function (reply) {
166
			if(reply.attr.type == "result")
167
				return on_result(reply);
168
			elseif(on_error)
169
				return on_error(reply);
170
			return false;
171
			
172
		}, null, "iq", null, iq.attr.id);
173
		this.send(iq);
174
	},
175
	
176
	addHandler: function (handler, ns, name, type, id, from, options)
177
	{
178
		return this.handlers.push({
179
			callback: handler,
180
			xmlns: ns,
181
			name: name,
182
			type: type,
183
			id: id,
184
			from: from,
185
			matchBare: options && options.matchBare});
186
	},
187
	
188
	getUniqueId: function (suffix)
189
	{
190
		return ++this._uniqueId + (suffix?(":"+suffix):"");
191
	},
192
	
193
	// Update the status of the connection, call connect_callback
194
	_setStatus: function (status, condition)
195
	{
196
		this.status = status;
197
		this.connect_callback(status, condition);
198
	},
199
	
200
	// Socket listeners, called on TCP-level events
201
	_socket_connected: function ()
202
	{
203
		this.info("CONNECTED.");
204
		this.send("<stream:stream xmlns='jabber:component:accept' xmlns:stream='http://etherx.jabber.org/streams' to='"+this.jid+"'>");
205
	},
206
	
207
	_socket_disconnected: function (had_error)
208
	{
209
		if(this.status == xmpp.Status.CONNECTING)
210
			this._setStatus(xmpp.Status.CONNFAIL);
211
		elseif(this.status == xmpp.Status.CONNECTED)
212
			this._setStatus(xmpp.Status.DISCONNECTED);
213
		this.info("DISCONNECTED.");
214
	},
215
	
216
	_socket_received: function (data)
217
	{
218
		this.debug("RCV: "+data);
219
		// Push to parser
220
		this.stream.data(data);
221
	},
222
	
223
	// Stream listeners, called on XMPP-level events
224
	_stream_opened: function (attr)
225
	{
226
		this.debug("STREAM: opened.");
227
		this._setStatus(xmpp.Status.AUTHENTICATING);
228
		var handshake = sha1.hex(attr.id + this.password);
229
		this.debug("Calculated authentication token " + handshake
230
			+ " from stream id '" + attr.id
231
			+ "' and password '" + this.password + "'");
232
		this.debug("Sending authentication token...");
233
		this.send("<handshake>"+handshake+"</handshake>");
234
	},
235
	
236
	_handle_stanza: function (stanza)
237
	{
238
		if(!stanza.attr.xmlns) // Default namespace
239
		{
240
			if(stanza.name == "handshake")
241
			{
242
				this._setStatus(xmpp.Status.CONNECTED);
243
			}
244
		}
245
		this.debug("STANZA: "+stanza.toString());
246
		
247
		// Match and call handlers
248
		var removeHandlers = [];
249
		for(var i=0;i<this.handlers.length;i++)
250
		{
251
			var handler = this.handlers[i];
252
			if(
253
				(!handler.name || handler.name == stanza.name) &&
254
				(!handler.xmlns || (handler.xmlns == stanza.attr.xmlns
255
					|| (stanza.tags[0] && handler.xmlns == stanza.tags[0].attr.xmlns))) &&
256
				(!handler.type || handler.type == stanza.attr.type) &&
257
				(!handler.id || handler.id == stanza.attr.id) &&
258
				(!handler.from || (handler.from == (handler.matchBare?xmpp.getBareJID(stanza.attr.from):stanza.attr.from))) &&
259
				(!handler.to || (handler.to == (handler.matchBare?xmpp.getBareJID(stanza.attr.to):stanza.attr.to)))
260
			)
261
			{
262
				var ret = handler.callback(stanza);
263
				if(ret == false)
264
					removeHandlers.push(i);
265
			}
266
		}
267
		
268
		var adjust = 0;
269
		for(var i=0;i<removeHandlers.length;i++)
270
			this.handlers.splice(removeHandlers[i]-(adjust++), 1);
271
	},
272
	
273
	_stream_closed: function ()
274
	{
275
		this.debug("STREAM: closed.");
276
		this.socket.close();
277
		if(this.status == xmpp.Status.CONNECTING)
278
			this._setStatus(xmpp.status.CONNFAIL);
279
		else
280
			this._setStatus(xmpp.Status.DISCONNECTED);
281
	},
282
	
283
	_stream_error: function (condition)
284
	{
285
		this._setStatus(xmpp.Status.ERROR, condition);
286
	},
287
	
288
	// Logging
289
	log: function (level, message) {},
290
	debug: function (message) { return this.log(xmpp.LogLevel.DEBUG, message); },
291
	info:  function (message) { return this.log(xmpp.LogLevel.INFO , message); },
292
	warn:  function (message) { return this.log(xmpp.LogLevel.WARN , message); },
293
	error: function (message) { return this.log(xmpp.LogLevel.ERROR, message); },
294
	fatal: function (message) { return this.log(xmpp.LogLevel.FATAL, message); }
295
	
296
};
297
 
298
function xmlescape(s)
299
{
300
	return s.replace(/&/g, "&amp;")
301
	        .replace(/</g, "&lt;")
302
	        .replace(/>/g, "&gt;")
303
	        .replace(/\"/g, "&quot;")
304
	        .replace(/\'/g, "&apos;");
305
}
306
 
307
/** StanzaBuilder: Helps create and manipulate XML snippets **/
308
xmpp.StanzaBuilder = function (name, attr)
309
{
310
	this.name = name;
311
	this.attr = attr || {};
312
	this.tags = [];
313
	this.children = [];
314
	this.last_node = [this];
315
	return this;
316
};
317
 
318
xmpp.StanzaBuilder.prototype = {
319
    s: function (name, attr)
320
    {
321
        // This function was created because c() doesn't seem to work for adding
322
        // multiple children on the same level with each other. This is just a
323
        // quick fix mostly to get chatstates working.
324
		var s = new xmpp.StanzaBuilder(name, attr);
325
        var parent = this;
326
        parent.tags.push(s);
327
        parent.children.push(s);
328
		this.last_node.push(s);
329
        return this
330
    },
331
 
332
	c: function (name, attr)
333
	{
334
		var s = new xmpp.StanzaBuilder(name, attr);
335
		var parent = this.last_node[this.last_node.length-1];
336
		parent.tags.push(s);
337
		parent.children.push(s);
338
		this.last_node.push(s);
339
		return this;
340
	},
341
	
342
	t: function (text)
343
	{
344
		var parent = this.last_node[this.last_node.length-1];
345
		parent.children.push(text);
346
		return this;
347
	},
348
	
349
	up: function ()
350
	{
351
		this.last_node.pop();
352
		return this;
353
	},
354
	
355
	toString: function (top_tag_only)
356
	{
357
		var buf = [];
358
		buf.push("<" + this.name);
359
		for(var attr in this.attr)
360
		{
361
			buf.push(" " + attr + "='" + xmlescape(this.attr[attr]) + "'");
362
		}
363
		
364
		// Now add children if wanted
365
		if(top_tag_only)
366
		{
367
			buf.push(">");
368
		}
369
		else if(this.children.length == 0)
370
		{
371
			buf.push("/>");
372
		}
373
		else
374
		{
375
			buf.push(">");
376
			for(var i = 0; i<this.children.length; i++)
377
			{
378
				var child = this.children[i];
379
				if(typeof(child) == "string")
380
					buf.push(xmlescape(child));
381
				else
382
					buf.push(child.toString());
383
			}
384
			buf.push("</" + this.name + ">");
385
		}
386
		return buf.join("");
387
	},
388
	
389
	getChild: function (name, xmlns) {
390
		for(var i=0;i<this.tags.length;i++)
391
		{
392
			var child = this.tags[i];
393
			if((!name || child.name == name) && (!xmlns || child.attr.xmlns == xmlns))
394
				return child;
395
		}
396
		return null;
397
	},
398
	
399
	getText: function () {
400
		var buf = [];
401
		for(var i=0;i<this.children.length;i++)
402
			if(typeof(this.children[i]) == "string")
403
				buf.push(this.children[i]);
404
		return buf.join("");
405
	},
406
	
407
	getAttribute: function (name) {
408
		return this.attr[name] || null;
409
	}
410
}
411
 
412
xmpp.stanza = function (name, attr)
413
{
414
	return new xmpp.StanzaBuilder(name, attr);
415
}
416
 
417
xmpp.message = function (attr)
418
{
419
	return xmpp.stanza("message", attr);
420
}
421
 
422
xmpp.presence = function (attr)
423
{
424
	return xmpp.stanza("presence", attr);
425
}
426
 
427
xmpp.iq = function (attr)
428
{
429
	return xmpp.stanza("iq", attr);
430
}