git » sdk » commit 60a5556

Any problem in computer science can be solved with another level of indirection.

author Matthew Wild
2023-06-05 16:57:08 UTC
committer Matthew Wild
2023-06-05 16:57:08 UTC

Any problem in computer science can be solved with another level of indirection.

Main.hx +30 -0
Makefile +12 -0
xmpp/Chat.hx +48 -0
xmpp/ChatMessage.hx +75 -0
xmpp/Client.hx +47 -0
xmpp/EventEmitter.hx +43 -0
xmpp/EventHandler.hx +41 -0
xmpp/FSM.hx +155 -0
xmpp/GenericStream.hx +45 -0
xmpp/ID.hx +33 -0
xmpp/JID.hx +22 -0
xmpp/MessageSync.hx +113 -0
xmpp/ResultSet.hx +14 -0
xmpp/Stanza.hx +250 -0
xmpp/Stream.js.hx +5 -0
xmpp/queries/GenericQuery.hx +34 -0
xmpp/queries/MAMQuery.hx +124 -0
xmpp/streams/XmppJsStream.hx +205 -0

diff --git a/Main.hx b/Main.hx
new file mode 100644
index 0000000..809e1f8
--- /dev/null
+++ b/Main.hx
@@ -0,0 +1,30 @@
+import xmpp.Client;
+import xmpp.EventHandler;
+
+class Main {
+	static public function main():Void {
+		var client = new Client("user@example.com");
+
+		client.on("status/online", function (data) {
+			trace("CONNECTED CLIENT!");
+
+			var chat = client.getDirectChat("user2@example.com");
+			chat.getMessages(function (result) {
+				trace('${result.messages.length} messages received:');
+				for (message in result.messages) {
+					trace('[${message.isIncoming()?"incoming":"outgoing"}]: ${message.text}');
+				}
+				trace("complete: " + !result.sync.hasMore());
+			});
+
+			return EventHandled;
+		});
+
+		client.on("auth/password-needed", function (data) {
+			client.usePassword("secret-password");
+			return EventHandled;
+		});
+
+		client.start();
+	}
+}
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ab0385e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,12 @@
+HAXE_PATH=$$HOME/Software/haxe-4.3.1/hxnodejs/12,1,0/src
+
+.PHONY: all run-js
+
+all: test.node.js
+
+test.node.js: xmpp/*.hx xmpp/queries/*.hx xmpp/streams/*.hx
+	haxe -D nodejs -D no-deprecation-warnings -m Main --js "$@" -cp "$(HAXE_PATH)"
+
+run-nodejs: test.node.js
+	nodejs "$<"
+
diff --git a/xmpp/Chat.hx b/xmpp/Chat.hx
new file mode 100644
index 0000000..9d52453
--- /dev/null
+++ b/xmpp/Chat.hx
@@ -0,0 +1,48 @@
+package xmpp;
+
+import xmpp.MessageSync;
+import xmpp.ChatMessage;
+import xmpp.Chat;
+import xmpp.GenericStream;
+
+enum ChatType {
+	ChatTypeDirect;
+	ChatTypeGroup;
+	ChatTypePublic;
+}
+
+abstract class Chat {
+	private var client:Client;
+	private var stream:GenericStream;
+	public var chatId(default, null):String;
+	public var type(default, null):ChatType;
+
+	private function new(client:Client, stream:GenericStream, chatId:String, type:ChatType) {
+		this.client = client;
+		this.stream = stream;
+		this.chatId = chatId;
+	}
+
+	abstract public function sendMessage(message:ChatMessage):Void;
+
+	abstract public function getMessages(handler:MessageListHandler):MessageSync;
+
+	public function isDirectChat():Bool { return type.match(ChatTypeDirect); };
+	public function isGroupChat():Bool  { return type.match(ChatTypeGroup);  };
+	public function isPublicChat():Bool { return type.match(ChatTypePublic); };
+}
+
+class DirectChat extends Chat {
+	public function new(client:Client, stream:GenericStream, chatId:String) {
+		super(client, stream, chatId, ChatTypeDirect);
+	}
+
+	public function getMessages(handler:MessageListHandler):MessageSync {
+		var sync = new MessageSync(this.client, this.stream, this.chatId, {});
+		sync.onMessages(handler);
+		sync.fetchNext();
+		return sync;
+	}
+
+	public function sendMessage(message:ChatMessage):Void {}
+}
diff --git a/xmpp/ChatMessage.hx b/xmpp/ChatMessage.hx
new file mode 100644
index 0000000..742bd41
--- /dev/null
+++ b/xmpp/ChatMessage.hx
@@ -0,0 +1,75 @@
+package xmpp;
+
+import haxe.Exception;
+
+import xmpp.JID;
+
+enum MessageDirection {
+	MessageReceived;
+	MessageSent;
+}
+
+class ChatAttachment {
+	public var url(default, null):String = null;
+	public var description(default, null):String = null;
+}
+
+class ChatMessage {
+	public var localId (default, set) : String = null;
+	public var serverId (default, set) : String = null;
+
+	public var timestamp (default, set) : String = null;
+
+	public var to (default, null): String = null;
+	public var from (default, null): String = null;
+
+	var threadId (default, null): String = null;
+	var replyTo (default, null): String = null;
+
+	var attachments : Array<ChatAttachment> = null;
+
+	public var text (default, null): String = null;
+
+	private var direction:MessageDirection = null;
+
+	public function new() { }
+
+	public static function fromStanza(stanza:Stanza, localJid:String):ChatMessage {
+		var msg = new ChatMessage();
+		msg.text = stanza.getChildText("body");
+		msg.to = stanza.attr.get("to");
+		msg.from = stanza.attr.get("from");
+		var domain = JID.split(localJid).domain;
+		for (stanzaId in stanza.allTags("stanza-id", "urn:xmpp:sid:0")) {
+			if (stanzaId.attr.get("by") == domain) {
+				msg.serverId = stanzaId.attr.get("id");
+				break;
+			}
+		}
+		msg.direction = (msg.to == localJid) ? MessageReceived : MessageSent;
+		return msg;
+	}
+
+	public function set_localId(localId:String):String {
+		if(this.localId != null) {
+			throw new Exception("Message already has a localId set");
+		}
+		return this.localId = localId;
+	}
+
+	public function set_serverId(serverId:String):String {
+		if(this.serverId != null) {
+			throw new Exception("Message already has a serverId set");
+		}
+		return this.serverId = serverId;
+	}
+
+	public function set_timestamp(timestamp:String):String {
+		return this.timestamp = timestamp;
+	}
+
+	public function isIncoming():Bool {
+		return direction == MessageReceived;
+	}
+}
+
diff --git a/xmpp/Client.hx b/xmpp/Client.hx
new file mode 100644
index 0000000..f857b3a
--- /dev/null
+++ b/xmpp/Client.hx
@@ -0,0 +1,47 @@
+package xmpp;
+
+import xmpp.Chat;
+import xmpp.EventEmitter;
+import xmpp.Stream;
+import xmpp.queries.GenericQuery;
+
+typedef ChatList = Array<Chat>;
+
+class Client extends xmpp.EventEmitter {
+	private var stream:GenericStream;
+	public var jid(default,null):String;
+
+	public function new(jid: String) {
+		super();
+		this.jid = jid;
+		stream = new Stream();
+		stream.on("status/online", this.onConnected);
+		stream.on("auth/password-needed", (data)->this.trigger("auth/password-needed", { jid: this.jid }));
+	}
+
+	public function start() {
+		stream.connect(jid);
+	}
+
+	private function onConnected(data) {
+		return this.trigger("status/online", {});
+	}
+
+	public function usePassword(password: String):Void {
+		this.stream.trigger("auth/password", { password: password });
+	}
+
+	/* Return array of chats, sorted by last activity */
+	public function getChats():ChatList {
+		return [];
+	}
+
+	public function getDirectChat(chatId:String):DirectChat {
+		return new DirectChat(this, this.stream, chatId);
+	}
+
+	/* Internal-ish methods */
+	public function sendQuery(query:GenericQuery) {
+		this.stream.sendIq(query.getQueryStanza(), query.handleResponse);
+	}
+}
diff --git a/xmpp/EventEmitter.hx b/xmpp/EventEmitter.hx
new file mode 100644
index 0000000..dfeb9c7
--- /dev/null
+++ b/xmpp/EventEmitter.hx
@@ -0,0 +1,43 @@
+package xmpp;
+
+import xmpp.EventHandler;
+
+class EventEmitter {
+	private var eventHandlers:Map<String,Array<EventHandler>> = [];
+
+	private function new() { }
+	
+	public function on(eventName:String, callback:EventCallback):EventHandler {
+		var handlers = eventHandlers.get(eventName);
+		if(handlers == null) {
+			handlers = [];
+			eventHandlers.set(eventName, handlers);
+		}
+		var newHandler = new EventHandler(handlers, callback);
+		handlers.push(newHandler);
+		return newHandler;
+	}
+
+	public function once(eventName:String, callback:EventCallback) {
+		return this.on(eventName, callback).once();
+	}
+
+	public function trigger(eventName:String, eventData:Dynamic):EventResult {
+		var handlers = eventHandlers.get(eventName);
+		if(handlers == null || handlers.length == 0) {
+			trace('no event handlers for $eventName');
+			return EventUnhandled;
+		}
+		trace("firing event: "+eventName);
+		var handled = false;
+		for (handler in handlers) {
+			var ret = handler.call(eventData);
+			switch(ret) {
+				case EventHandled: handled = true;
+				case EventUnhandled: continue;
+				case EventStop | EventValue(_): return ret;
+			}
+		}
+		return EventHandled;
+	}
+}
diff --git a/xmpp/EventHandler.hx b/xmpp/EventHandler.hx
new file mode 100644
index 0000000..ff52e93
--- /dev/null
+++ b/xmpp/EventHandler.hx
@@ -0,0 +1,41 @@
+package xmpp;
+
+enum EventResult {
+	EventHandled;
+	EventUnhandled;
+	EventStop;
+	EventValue(result:Dynamic);
+}
+
+typedef EventCallback = (Dynamic)->EventResult;
+
+class EventHandler {
+	private var handlers:Array<EventHandler> = null;
+	private var callback:EventCallback = null;
+	private var onlyOnce:Bool = false;
+
+	public function new(handlers:Array<EventHandler>, callback:EventCallback, ?onlyOnce:Bool) {
+		this.handlers = handlers;
+		this.callback = callback;
+		if(onlyOnce != null) {
+			this.onlyOnce = onlyOnce;
+		}
+	}
+
+	public function call(data:Dynamic):EventResult {
+		if(onlyOnce) {
+			this.unsubscribe();
+		}
+		return callback(data);
+	}
+
+	public function once():EventHandler {
+		onlyOnce = true;
+		return this;
+	}
+
+	public function unsubscribe():Void {
+		this.handlers.remove(this);
+	}
+
+}
diff --git a/xmpp/FSM.hx b/xmpp/FSM.hx
new file mode 100644
index 0000000..f6e1717
--- /dev/null
+++ b/xmpp/FSM.hx
@@ -0,0 +1,155 @@
+package xmpp;
+
+import haxe.Exception;
+
+typedef FSMTransitionName = String;
+typedef FSMStateName = String;
+
+typedef FSMEvent = {
+	var fsm : FSM;
+
+	var ?name : FSMTransitionName;
+
+	var to : FSMStateName;
+	var ?toAttr : Dynamic;
+	var ?from : FSMStateName;
+	var ?fromAttr : Dynamic;
+};
+
+typedef FSMTransition = {
+	var name : FSMTransitionName;
+	var from : Array<FSMStateName>;
+	var to : FSMStateName;
+};
+
+typedef FSMStateHandler = (FSMEvent)->Void;
+typedef FSMTransitionHandler = (FSMEvent)->Bool;
+
+typedef FSMDescription = {
+	var transitions : Array<FSMTransition>;
+
+	var ?state_handlers : Map<FSMStateName,FSMStateHandler>;
+	var ?transition_handlers : Map<FSMTransitionName,FSMTransitionHandler>;
+};
+
+class FSM extends EventEmitter {
+	private var states : Map<FSMStateName,Map<FSMTransitionName,FSMStateName>> = [];
+	private var currentState : FSMStateName = null;
+	private var currentStateAttributes : Dynamic = null;
+	
+	public function new(desc:FSMDescription, initialState:FSMStateName, ?initialAttr:Dynamic) {
+		super();
+		for(transition in desc.transitions) {
+			var from_states = transition.from;
+			for (from_state in from_states) {
+				var from_state_def = states.get(from_state);
+				if (from_state_def == null) {
+					from_state_def = [];
+					states.set(from_state, from_state_def);
+				}
+				var to_state_def = states.get(transition.to);
+				if (to_state_def == null) {
+					to_state_def = [];
+					states.set(transition.to, to_state_def);
+				}
+				if (states.get(from_state).get(transition.name) != null) {
+					throw new Exception("Duplicate transition in FSM specification: " + transition.name + " from " + from_state);
+				}
+				states.get(from_state).set(transition.name, transition.to);
+			}
+		}
+
+		if(desc.state_handlers != null) {
+			for (state => handler in desc.state_handlers) {
+				this.on('enter/$state', function (data) {
+					handler(data);
+					return EventHandled;
+				});
+			}
+		}
+
+		if(desc.transition_handlers != null) {
+			for (transition => handler in desc.transition_handlers) {
+				this.on('transition/$transition', function (data) {
+					if(handler(data) == false) {
+						return EventStop;
+					}
+					return EventHandled;
+				});
+			}
+		}
+		
+		currentState = initialState;
+		currentStateAttributes = initialAttr;
+		var initialEvent:FSMEvent = {
+			fsm: this,
+			to: initialState,
+			toAttr: initialAttr,
+		};
+		this.notifyTransitioned(initialEvent, true);
+	}
+
+	public function can(name:FSMTransitionName):Bool {
+		return states.get(currentState).get(name) != null;
+	}
+
+	public function event(name:FSMTransitionName, ?attr:Dynamic):Bool {
+		var newState = states.get(currentState).get(name);
+		if(newState == null) {
+			throw new Exception("Invalid state transition: " + currentState + " cannot " + name);
+		}
+
+		var event:FSMEvent = {
+			fsm: this,
+
+			name: name,
+			to: newState,
+			toAttr: attr,
+
+			from: currentState,
+			fromAttr: currentStateAttributes,
+		};
+
+		if(notifyTransition(event) == false) {
+			return false;
+		}
+
+		this.currentState = newState;
+		this.currentStateAttributes = attr;
+
+		notifyTransitioned(event, false);
+		return true;
+	}
+
+	private function notifyTransition(event:FSMEvent):Bool {
+		var ret;
+		ret = this.trigger("transition", event);
+		if(ret == EventStop) {
+			return false;
+		}
+		if(event.to != event.from) {
+			ret = this.trigger("leave/"+event.from, event);
+			if (ret == EventStop) {
+				return false;
+			}
+		}
+		ret = this.trigger("transition/"+event.name, event);
+		if(ret == EventStop) {
+			return false;
+		}
+		return true;
+	}
+
+	private function notifyTransitioned(event:FSMEvent, isInitial:Bool):Void {
+		if(event.to != event.from) {
+			this.trigger("enter/"+event.to, event);
+		}
+		if(isInitial == false) {
+			if(event.name != null) {
+				trigger("transitioned/"+event.name, event);
+			}
+			trigger("transitioned", event);
+		}
+	}
+}
+
diff --git a/xmpp/GenericStream.hx b/xmpp/GenericStream.hx
new file mode 100644
index 0000000..a824e33
--- /dev/null
+++ b/xmpp/GenericStream.hx
@@ -0,0 +1,45 @@
+package xmpp;
+
+import xmpp.Stanza;
+import xmpp.EventEmitter;
+
+abstract class GenericStream extends EventEmitter {
+
+	public function new() {
+		super();
+	}
+	
+	/* Connections and streams */
+
+	abstract public function connect(jid:String):Void;
+	abstract public function sendStanza(stanza:Stanza):Void;
+	abstract public function newId():String;
+
+	public function sendIq(stanza:Stanza, callback:(stanza:Stanza)->Void):Void {
+		var id = newId();
+		stanza.attr.set("id", id);
+		this.once('iq-response/$id', function (event) {
+			callback(event.stanza);
+			return EventHandled;
+		});
+		sendStanza(stanza);
+	}
+
+	private function onStanza(stanza:Stanza):Void {
+		trace("stanza received!");
+		final xmlns = stanza.attr.get("xmlns");
+		if(xmlns == "jabber:client") {
+			final name = stanza.name;
+			if(stanza.name == "iq") {
+				var type = stanza.attr.get("type");
+				trace('type: $type');
+				if(type == "result" || type == "error") {
+					var id = stanza.attr.get("id");
+					trigger('iq-response/$id', { stanza: stanza });
+				}
+			} else if (name == "message" || name == "presence") {
+				trigger(name, { stanza: stanza });
+			}
+		}
+	}
+}
diff --git a/xmpp/ID.hx b/xmpp/ID.hx
new file mode 100644
index 0000000..c6ca3be
--- /dev/null
+++ b/xmpp/ID.hx
@@ -0,0 +1,33 @@
+package xmpp;
+
+import haxe.crypto.Base64;
+import haxe.io.Bytes;
+
+#if nodejs
+import js.node.Crypto;
+#end
+
+class ID {
+	public static function tiny():String {
+		return Base64.urlEncode(getRandomBytes(3));
+	}
+
+	public static function short():String {
+		return Base64.urlEncode(getRandomBytes(9));
+	}
+
+	public static function medium():String {
+		return Base64.urlEncode(getRandomBytes(18));
+	}
+
+	public static function long():String {
+		return Base64.urlEncode(getRandomBytes(27));
+	}
+
+#if nodejs
+	private static function getRandomBytes(n:Int):Bytes {
+		return Crypto.randomBytes(n).hxToBytes();
+	}
+#end
+
+}
diff --git a/xmpp/JID.hx b/xmpp/JID.hx
new file mode 100644
index 0000000..ae2718c
--- /dev/null
+++ b/xmpp/JID.hx
@@ -0,0 +1,22 @@
+package xmpp;
+
+typedef SplitJID = {
+	var ?node : String;
+	var domain : String;
+	var ?resource : String;
+};
+
+class JID {
+	public static function split(jid:String):SplitJID {
+		var resourceDelimiter = jid.indexOf("/");
+		var nodeDelimiter = jid.indexOf("@");
+		if(nodeDelimiter >= resourceDelimiter) {
+			nodeDelimiter = -1;
+		}
+		return {
+			node: (nodeDelimiter>0)?jid.substr(0, nodeDelimiter):null,
+			domain: jid.substring((nodeDelimiter == -1)?0:nodeDelimiter+1, (resourceDelimiter == -1)?jid.length+1:resourceDelimiter),
+			resource: (resourceDelimiter == -1)?null:jid.substring(resourceDelimiter+1),
+		};
+	}
+}
diff --git a/xmpp/MessageSync.hx b/xmpp/MessageSync.hx
new file mode 100644
index 0000000..87a0bea
--- /dev/null
+++ b/xmpp/MessageSync.hx
@@ -0,0 +1,113 @@
+package xmpp;
+
+import haxe.Exception;
+
+import xmpp.Client;
+import xmpp.ChatMessage;
+import xmpp.GenericStream;
+import xmpp.ResultSet;
+import xmpp.queries.MAMQuery;
+
+typedef MessageList = {
+	var sync : MessageSync;
+	var messages : Array<ChatMessage>;
+}
+
+typedef MessageListHandler = (MessageList)->Void;
+
+typedef MessageFilter = MAMQueryParams;
+
+class MessageSync {
+	private var client:Client;
+	private var stream:GenericStream;
+	private var chatId:String;
+	private var filter:MessageFilter;
+	private var serviceJID:String;
+	private var handler:MessageListHandler;
+	private var lastPage:ResultSetPageResult;
+	private var complete:Bool = false;
+	private var newestPageFirst:Bool = true;
+
+	public function new(client:Client, stream:GenericStream, chatId:String, filter:MessageFilter, ?serviceJID:String) {
+		this.client = client;
+		this.stream = stream;
+		this.chatId = chatId;
+		this.filter = Reflect.copy(filter);
+		this.serviceJID = serviceJID != null ? serviceJID : client.jid;
+	}
+
+	public function fetchNext():Void {
+		if(handler == null) {
+			throw new Exception("Attempt to fetch messages, but no handler has been set");
+		}
+		var messages:Array<ChatMessage> = [];
+		if(lastPage == null) {
+			if(newestPageFirst == true) {
+				filter.page = {
+					before: "", // Request last page of results
+				};
+			} else {
+				filter.page = null;
+			}
+		} else {
+			if(newestPageFirst == true) {
+				filter.page = {
+					before: lastPage.first,
+				};
+			} else {
+				filter.page = {
+					after: lastPage.last,
+				};
+			}
+		}
+		var query = new MAMQuery(filter);
+		var resultHandler = stream.on("message", function (event) {
+			var message:Stanza = event.stanza;
+			var from = message.attr.exists("from") ? message.attr.get("from") : client.jid;
+			if(from != serviceJID) { // Only listen for results from the JID we queried
+				return EventUnhandled;
+			}
+			var result = message.getChild("result", query.xmlns);
+			if(result == null || result.attr.get("queryid") != query.queryId) { // Not (a|our) MAM result
+				return EventUnhandled;
+			}
+			var originalMessage = result.findChild("{urn:xmpp:forward:0}forwarded/{jabber:client}message");
+			if(originalMessage == null) { // No message, nothing for us to do
+				return EventHandled;
+			}
+			var timestamp = result.findText("{urn:xmpp:forward:0}forwarded/{urn:xmpp:delay}delay@stamp");
+
+			var msg = ChatMessage.fromStanza(originalMessage, client.jid);
+			msg.set_serverId(result.attr.get("id"));
+			msg.set_timestamp(timestamp);
+
+			messages.push(msg);
+
+			return EventHandled;
+		});
+		query.onFinished(function () {
+			resultHandler.unsubscribe();
+			var result = query.getResult();
+			if(result != null) {
+				complete = result.complete;
+			}
+			handler({
+				sync: this,
+				messages: messages,
+			});
+		});
+		client.sendQuery(query);
+	}
+
+	public function hasMore():Bool {
+		return !complete;
+	}
+
+	public function onMessages(handler:MessageListHandler):Void {
+		this.handler = handler;
+	}
+
+	public function setNewestPageFirst(newestPageFirst:Bool):Void {
+		this.newestPageFirst = newestPageFirst;
+	}
+}
diff --git a/xmpp/ResultSet.hx b/xmpp/ResultSet.hx
new file mode 100644
index 0000000..19b0539
--- /dev/null
+++ b/xmpp/ResultSet.hx
@@ -0,0 +1,14 @@
+package xmpp;
+
+typedef ResultSetPageRequest = {
+	var ?before : String; // Set to request the page before a given id (or empty string to request the very last page)
+	var ?after : String; // Set to request the page after a given id
+	var ?limit : String; // Request a limit on the number of items returned in the page
+};
+
+typedef ResultSetPageResult = {
+	var first : String; // The RSM id of the first item in this page
+	var last : String; // The RSM id of the last item in this page
+	var ?index : Int; // The position (within 'count') of the first item in this page
+	var ?count : Int; // Count of the *total* items, not the items in the page
+};
diff --git a/xmpp/Stanza.hx b/xmpp/Stanza.hx
new file mode 100644
index 0000000..6a77f2b
--- /dev/null
+++ b/xmpp/Stanza.hx
@@ -0,0 +1,250 @@
+package xmpp;
+
+import haxe.DynamicAccess;
+import haxe.Exception;
+import haxe.ds.StringMap;
+
+enum Node {
+	Element(stanza:Stanza);
+	CData(textNode:TextNode);
+}
+
+typedef NodeList = Array<Node>;
+
+private interface NodeInterface {
+	public function serialize():String;
+	public function clone():NodeInterface;
+}
+
+class TextNode implements NodeInterface {
+	private var content(default, null):String = "";
+
+	public function new (content:String) {
+		this.content = content;
+	}
+
+	public function serialize():String {
+		return content;
+	}
+
+	public function clone():TextNode {
+		return new TextNode(this.content);
+	}
+}
+
+class Stanza implements NodeInterface {
+	public var name(default, null):String = null;
+	public var attr(default, null):DynamicAccess<String> = null;
+	public var children(default, null):Array<Node> = [];
+	private var last_added(null, null):Stanza;
+	private var last_added_stack(null, null):Array<Stanza> = [];
+
+	public function new(name:String, ?attr:DynamicAccess<String>) {
+		this.name = name;
+		if(attr != null) {
+			this.attr = attr;
+		}
+		this.last_added = this;
+	};
+
+	public function serialize():String {
+		var el = Xml.createElement(name);
+		for (attr_k in this.attr.keys()) {
+			el.set(attr_k, this.attr.get(attr_k));
+		}
+
+		if (this.children.length == 0) {
+			return el.toString();
+		}
+		var serialized = el.toString();
+		var buffer = [serialized.substring(0, serialized.length-2)+">"];
+		for (child in children) {
+			buffer.push(switch (child) {
+				case Element(c): c.serialize();
+				case CData(c): c.serialize();
+			});
+		}
+		buffer.push("</"+this.name+">");
+		return buffer.join("");
+	}
+
+	public function toString():String {
+		return this.serialize();
+	}
+
+	public function tag(name:String, ?attr:DynamicAccess<String>) {
+		var child = new Stanza(name, attr);
+		this.last_added.addDirectChild(Element(child));
+		this.last_added_stack.push(this.last_added);
+		this.last_added = child;
+		return this;
+	}
+
+	public function text(content:String) {
+		this.last_added.addDirectChild(CData(new TextNode(content)));
+		return this;
+	}
+
+	public function textTag(tagName:String, textContent:String, ?attr:DynamicAccess<String>) {
+		this.last_added.addDirectChild(Element(new Stanza(tagName, attr).text(textContent)));
+		return this;
+	}
+
+	public function up() {
+		if(this.last_added != this) {
+			this.last_added = this.last_added_stack.pop();
+		}
+		return this;
+	}
+
+	public function reset():Stanza {
+		this.last_added = this;
+		return this;
+	}
+
+	public function addChild(stanza:Stanza) {
+		this.last_added.children.push(Element(stanza));
+		return this;
+	}
+
+	public function addDirectChild(child:Node) {
+		this.children.push(child);
+		return this;
+	}
+
+	public function clone():Stanza {
+		var clone = new Stanza(this.name, this.attr);
+		for (child in children) {
+			clone.addDirectChild(switch(child) {
+				case Element(c): Element(c.clone());
+				case CData(c): CData(c.clone());
+			});
+		}
+		return clone;
+	}
+
+	public function allTags(?name:String, ?xmlns:String):Array<Stanza> {
+		var tags = this.children
+			.filter((child) -> child.match(Element(_)))
+			.map(function (child:Node) {
+				return switch(child) {
+					case Element(c): c;
+					case _: null;
+				};
+			});
+		if (name != null || xmlns != null) {
+			var ourXmlns = this.attr.get("xmlns");
+			tags = tags.filter(function (child:Stanza):Bool {
+				var childXmlns = child.attr.get("xmlns");
+				return (name == null || child.name == name
+				  && ((xmlns == null && ourXmlns == childXmlns)
+				     || childXmlns == xmlns));
+			});
+		}
+		return tags;
+	}
+
+	public function allText():Array<String> {
+		return this.children
+			.filter((child) -> child.match(CData(_)))
+			.map(function (child:Node) {
+				return switch(child) {
+					case CData(c): c.serialize();
+					case _: null;
+				};
+			});
+	}
+
+	public function getFirstChild():Stanza {
+		return allTags()[0];
+	}
+
+	public function getChild(?name:Null<String>, ?xmlns:Null<String>):Null<Stanza> {
+		var ourXmlns = this.attr.get("xmlns");
+		/*
+		for (child in allTags()) {
+			if (name == null || child.name == name
+			    && ((xmlns == null && ourXmlns == child.attr.get("xmlns"))
+			        || child.attr.get("xmlns") == xmlns)) {
+				return child;
+			}
+		}*/
+		var tags = allTags(name, xmlns);
+		if(tags.length == 0) {
+			return null;
+		}
+		return tags[0];
+	}
+
+	public function getChildText(?name:Null<String>, ?xmlns:Null<String>):String {
+		var child = getChild(name, xmlns);
+		if(child == null) {
+			return null;
+		}
+		return child.getText();
+	}
+
+	public function getText():String {
+		return allText().join("");
+	}
+
+	public function find(path:String):Node {
+		var pos = 0;
+		var len = path.length;
+		var cursor = this;
+
+		do {
+			var xmlns = null, name = null, text = null;
+			var char = path.charAt(pos);
+			if (char == "@") {
+				return CData(new TextNode(cursor.attr.get(path.substr(pos+1))));
+			} else if (char == "{") {
+				xmlns = path.substring(pos+1, path.indexOf("}", pos+1));
+				pos += xmlns.length + 2;
+			}
+			var reName = new EReg("([^@/#]*)([/#]?)", "");
+			if(!reName.matchSub(path, pos)) {
+				throw new Exception("Invalid path to Stanza.find(): "+path);
+			}
+			var name = reName.matched(1), text = reName.matched(2);
+			pos = reName.matchedPos().pos + reName.matchedPos().len;
+			if(name == "") {
+				name = null;
+			};
+			if(pos == len) {
+				if(text == "#") {
+					var text = cursor.getChildText(name, xmlns);
+					if(text == null) {
+						return null;
+					}
+					return CData(new TextNode(text));
+				}
+				return Element(cursor.getChild(name, xmlns));
+			}
+			cursor = cursor.getChild(name, xmlns);
+		} while (cursor != null);
+		return null;
+	}
+
+	public function findChild(path:String):Stanza {
+		var result = find(path);
+		if(result == null) {
+			return null;
+		}
+		return switch(result) {
+			case Element(stanza): stanza;
+			case _: null;
+		};
+	}
+
+	public function findText(path:String):String {
+		var result = find(path);
+		if(result == null) {
+			return null;
+		}
+		return switch(result) {
+			case CData(textNode): textNode.serialize();
+			case _: null;
+		};
+	}
+}
diff --git a/xmpp/Stream.js.hx b/xmpp/Stream.js.hx
new file mode 100644
index 0000000..5c36c25
--- /dev/null
+++ b/xmpp/Stream.js.hx
@@ -0,0 +1,5 @@
+package xmpp;
+
+import xmpp.streams.XmppJsStream;
+
+typedef Stream = xmpp.streams.XmppJsStream;
diff --git a/xmpp/queries/GenericQuery.hx b/xmpp/queries/GenericQuery.hx
new file mode 100644
index 0000000..2139685
--- /dev/null
+++ b/xmpp/queries/GenericQuery.hx
@@ -0,0 +1,34 @@
+package xmpp.queries;
+
+import haxe.Exception;
+
+import xmpp.Stanza;
+
+abstract class GenericQuery {
+	private var queryStanza:Stanza;
+	private var handleFinished:()->Void;
+	private var isFinished:Bool = false;
+
+	public function getQueryStanza():Stanza {
+		if(queryStanza == null) {
+			throw new Exception("Query has not been initialized");
+		}
+		return queryStanza;
+	}
+
+	private function finish() {
+		isFinished = true;
+		if(handleFinished != null) {
+			handleFinished();
+		}
+	}
+
+	abstract public function handleResponse(response:Stanza):Void;
+
+	public function onFinished(handler:()->Void):Void {
+		handleFinished = handler;
+		if(isFinished) {
+			handleFinished();
+		}
+	}
+}
diff --git a/xmpp/queries/MAMQuery.hx b/xmpp/queries/MAMQuery.hx
new file mode 100644
index 0000000..5d83b14
--- /dev/null
+++ b/xmpp/queries/MAMQuery.hx
@@ -0,0 +1,124 @@
+package xmpp.queries;
+
+import haxe.Exception;
+
+import xmpp.ID;
+import xmpp.ResultSet;
+import xmpp.Stanza;
+import xmpp.Stream;
+import xmpp.queries.GenericQuery;
+
+typedef MAMQueryParams = {
+	var ?startTime : String;
+	var ?endTime : String;
+	var ?with : String;
+	var ?beforeId : String;
+	var ?afterId : String;
+	var ?ids : Array<String>;
+
+	var ?page : {
+		var ?before : String;
+		var ?after : String;
+		var ?limit : Int;
+	};
+};
+
+typedef MAMQueryResult = {
+	var complete : Bool;
+	var page : {
+		var firstId : String;
+		var lastId : String;
+	};
+};
+
+class MAMQuery extends GenericQuery {
+	public var xmlns(default, null) = "urn:xmpp:mam:2";
+	public var queryId:String = null;
+	private var responseStanza:Stanza;
+	private var result:MAMQueryResult;
+
+	private function addStringField(name:String, value:String) {
+		if(value == null) {
+			return;
+		}
+		queryStanza
+			.tag("field", { "var": name })
+				.textTag("value", value)
+			.up();
+	}
+
+	private function addArrayField(name:String, values:Array<String>) {
+		if(values == null) {
+			return;
+		}
+		queryStanza.tag("field", { "var": name });
+		for (value in values) {
+			queryStanza.textTag("value", value);
+		}
+		queryStanza.up();
+	}
+
+	public function new(params:MAMQueryParams, ?jid:String) {
+		/* Build basic query */
+		queryId = ID.short();
+		queryStanza = new Stanza("iq", { type: "set", to: jid })
+			.tag("query", { xmlns: xmlns, queryid: queryId })
+				.tag("x", { xmlns: "jabber:x:data", type: "submit" })
+					.tag("field", { "var": "FORM_TYPE", type: "hidden" })
+						.textTag("value", xmlns)
+						.up();
+
+		/* Add filter parameters to query form */
+		addStringField("start", params.startTime);
+		addStringField("end", params.endTime);
+		addStringField("with", params.with);
+		addStringField("before-id", params.beforeId);
+		addStringField("after-id", params.afterId);
+		addArrayField("ids", params.ids);
+
+		queryStanza.up(); // Out of <x/> form
+
+		if(params.page != null) {
+			var page = params.page;
+			queryStanza.tag("set", { xmlns: "http://jabber.org/protocol/rsm" });
+			if(page.limit != null) {
+				queryStanza.textTag("max", Std.string(page.limit));
+			}
+			if(page.before != null && page.after != null) {
+				throw new Exception("It is not allowed to request a page before AND a page after");
+			}
+			if(page.before != null) {
+				queryStanza.textTag("before", page.before);
+			} else if(page.after != null) {
+				queryStanza.textTag("after", page.after);
+			}
+			queryStanza.up(); // out of <set/>
+		}
+	}
+
+	public function handleResponse(stanza:Stanza) {
+		responseStanza = stanza;
+		finish();
+	}
+
+	public function getResult() {
+		if (responseStanza == null) {
+			return null;
+		}
+		if(result == null) {
+			var fin = responseStanza.getFirstChild();
+			if(fin == null || fin.name != "fin" || fin.attr.get("xmlns") != xmlns) {
+				return null;
+			}
+			var rsmInfo = fin.getChild("set", "http://jabber.org/protocol/rsm");
+			result = {
+				complete: fin.attr.get("complete") == "true" || fin.attr.get("complete") == "1",
+				page: {
+					firstId: rsmInfo.getChildText("first"),
+					lastId: rsmInfo.getChildText("last"),
+				}
+			};
+		}
+		return result;
+	}
+}
diff --git a/xmpp/streams/XmppJsStream.hx b/xmpp/streams/XmppJsStream.hx
new file mode 100644
index 0000000..4f09b76
--- /dev/null
+++ b/xmpp/streams/XmppJsStream.hx
@@ -0,0 +1,205 @@
+package xmpp.streams;
+
+import js.lib.Promise;
+import haxe.Http;
+import haxe.Json;
+
+import xmpp.FSM;
+import xmpp.GenericStream;
+import xmpp.Stanza;
+
+@:jsRequire("@xmpp/client", "client")
+extern class XmppJsClient {
+	function new(options:Dynamic);
+	function start():Promise<Dynamic>;
+	function on(eventName:String, callback:(Dynamic)->Void):Void;
+	function send(stanza:XmppJsXml):Void;
+}
+
+@:jsRequire("@xmpp/jid", "jid")
+extern class XmppJsJID {
+	function new(jid:String);
+
+	var local(default, set):String;
+	var domain(default, set):String;
+	var resource(default, set):String;
+}
+
+@:jsRequire("@xmpp/debug")
+extern class XmppJsDebug {
+	@:selfCall
+	function new(client:XmppJsClient, force:Bool):Void;
+}
+
+@:jsRequire("@xmpp/xml")
+extern class XmppJsXml {
+	@:selfCall
+	@:overload(function(tagName:String, ?attr:Dynamic):XmppJsXml { })
+	function new();
+	@:overload(function(textContent:String):Void { })
+	function append(el:XmppJsXml):Void;
+
+	var name:String;
+	var attrs:Dynamic;
+	var children:Array<Dynamic>;
+}
+
+@:jsRequire("ltx") // The default XML library used by xmpp.js
+extern class XmppJsLtx {
+	static function isNode(el:Dynamic):Bool;
+	static function isElement(el:Dynamic):Bool;
+	static function isText(el:Dynamic):Bool;
+}
+
+@:jsRequire("@xmpp/id")
+extern class XmppJsId {
+	@:selfCall
+	static function id():String;
+}
+
+typedef HostMetaRecord = {
+	rel : String,
+	href : String,
+};
+typedef HostMetaJson = {
+	links : Array<HostMetaRecord>,
+};
+
+class XmppJsStream extends GenericStream {
+	private var client:XmppJsClient;
+	private var jid:XmppJsJID;
+	private var connectionURI:String;
+	private var debug = true;
+	private var state:FSM;
+
+	override public function new() {
+		super();
+		state = new FSM({
+			transitions: [
+				{ name: "connect-requested", from: ["offline"], to: "connecting" },
+				{ name: "connection-success", from: ["connecting"], to: "online" },
+				{ name: "connection-error", from: ["connecting"], to: "offline" },
+				{ name: "connection-closed", from: ["connecting", "online"], to: "offline" },
+			],
+			state_handlers: [
+				"online" => this.onOnline,
+				"offline" => this.onOffline,
+				
+			],
+		}, "offline");
+	}
+
+	static private function resolveConnectionURI(domain:String, callback:(String)->Void):Void {
+		var request = new Http('https://$domain/.well-known/host-meta.json');
+		request.onData = function (data:String) {
+			try {
+				var parsed:HostMetaJson = Json.parse(data);
+				for(entry in parsed.links) {
+					trace("ENTRY");
+					if(entry.href.substr(0, 6) == "wss://") {
+						callback(entry.href);
+						return;
+					}
+				}
+			} catch (e) {
+			}
+			callback(null);
+		};
+		request.onError = function (msg:String) {
+			callback(null);
+		}
+		request.request(false);
+	}
+
+	private function connectWithURI(uri:String) {
+		trace("Got connection URI: "+uri);
+		if(uri == null) {
+			this.state.event("connection-error");
+			return;
+		}
+		connectionURI = uri;
+
+		this.on("auth/password", function (event) {
+			var xmpp = new XmppJsClient({
+				service: connectionURI,
+				domain: jid.domain,
+				username: jid.local,
+				password: event.password,
+			});
+
+			if(this.debug) {
+				new XmppJsDebug(xmpp, true);
+			}
+
+			this.client = xmpp;
+
+			xmpp.on("online", function (data) {
+				this.state.event("connection-success");
+			});
+
+			xmpp.on("offline", function (data) {
+				this.state.event("connection-closed");
+			});
+
+			xmpp.on("stanza", function (stanza) {
+				this.onStanza(convertToStanza(stanza));
+			});
+
+			xmpp.start().catchError(function (err) {
+				trace(err);
+			});
+			return EventHandled;
+		});
+		this.trigger("auth/password-needed", {});
+	}
+
+	public function connect(jid:String) {
+		this.state.event("connect-requested");
+		this.jid = new XmppJsJID(jid);
+
+		resolveConnectionURI(this.jid.domain, this.connectWithURI);
+	}
+
+	private function convertFromStanza(el:Stanza):XmppJsXml {
+		var xml = new XmppJsXml(el.name, el.attr);
+		if(el.children.length > 0) {
+			for(child in el.children) {
+				switch(child) {
+					case Element(stanza): xml.append(convertFromStanza(stanza));
+					case CData(text): xml.append(text.serialize());
+				};
+			}
+		}
+		return xml;
+	}
+
+	private function convertToStanza(el:XmppJsXml):Stanza {
+		var stanza = new Stanza(el.name, el.attrs);
+		for (child in el.children) {
+			if(XmppJsLtx.isElement(child)) {
+				stanza.addChild(convertToStanza(child));
+			} else if(XmppJsLtx.isText(child)) {
+				stanza.text(cast(child, String));
+			}
+		}
+		return stanza;
+	}
+
+	public function sendStanza(stanza:Stanza) {
+		client.send(convertFromStanza(stanza));
+	}
+
+	public function newId():String {
+		return XmppJsId.id();
+	}
+
+	/* State handlers */
+
+	private function onOnline(event) {
+		trigger("status/online", {});
+	}
+
+	private function onOffline(event) {
+		trigger("status/offline", {});
+	}
+}