git » sdk » commit e467603

Initial IBR and fillable forms support

author Stephen Paul Weber
2025-11-11 15:29:03 UTC
committer Stephen Paul Weber
2025-11-11 15:29:03 UTC
parent 797c73b39630058f1cbe5993fa43055ba8d4fd41

Initial IBR and fillable forms support

borogove/Caps.hx +2 -2
borogove/Chat.hx +1 -0
borogove/Client.hx +10 -20
borogove/DataForm.hx +129 -5
borogove/Form.hx +208 -0
borogove/GenericStream.hx +3 -1
borogove/Register.hx +149 -0
borogove/Util.hx +58 -0
borogove/streams/XmppJsStream.hx +105 -4
borogove/streams/XmppStropheStream.hx +5 -0
browserjs.hxml +1 -0
nodejs.hxml +1 -0
npm/index.ts +4 -0

diff --git a/borogove/Caps.hx b/borogove/Caps.hx
index db52fdf..6b1ff29 100644
--- a/borogove/Caps.hx
+++ b/borogove/Caps.hx
@@ -113,7 +113,7 @@ class Caps {
 		}
 		s.writeByte(0x1c);
 		for (form in data) {
-			final fields = form.fields();
+			final fields = form.fields;
 			fields.sort((x, y) -> Reflect.compare([x.name].concat(x.value).join("\x1f"), [y.name].concat(y.value).join("\x1f")));
 			for (field in fields) {
 				final values = field.value;
@@ -142,7 +142,7 @@ class Caps {
 		}
 		for (form in data) {
 			s += form.field("FORM_TYPE").value[0] + "<";
-			final fields = form.fields();
+			final fields = form.fields;
 			fields.sort((x, y) -> Reflect.compare(x.name, y.name));
 			for (field in fields) {
 				if (field.name != "FORM_TYPE") {
diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 90067cb..a769530 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -20,6 +20,7 @@ import borogove.queries.DiscoInfoGet;
 import borogove.queries.MAMQuery;
 using Lambda;
 using StringTools;
+using borogove.Util;
 
 #if cpp
 import HaxeCBridge;
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 9503dec..dc66671 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -17,6 +17,7 @@ import borogove.OMEMO;
 #end
 import borogove.PubsubEvent;
 import borogove.Stream;
+import borogove.Util;
 #if !NO_JINGLE
 import borogove.calls.IceServer;
 import borogove.calls.PeerConnection;
@@ -911,17 +912,17 @@ class Client extends EventEmitter {
 		final vcard_regex = ~/\nIMPP[^:]*:xmpp:(.+)\n/;
 		final jid = if (StringTools.startsWith(query, "xmpp:")) {
 			final parts = query.substr(5).split("?");
-			JID.parse(StringTools.urlDecode(parts[0]));
+			JID.parse(uriDecode(parts[0]));
 		} else if (StringTools.startsWith(query, "BEGIN:VCARD") && vcard_regex.match(query)) {
 			final parts = vcard_regex.matched(1).split("?");
-			JID.parse(StringTools.urlDecode(parts[0]));
+			JID.parse(uriDecode(parts[0]));
 		} else if (StringTools.startsWith(query, "https://")) {
 			final hashParts = query.split("#");
 			if (hashParts.length > 1) {
-				JID.parse(StringTools.urlDecode(hashParts[1]));
+				JID.parse(uriDecode(hashParts[1]));
 			} else {
 				final pathParts = hashParts[0].split("/");
-				JID.parse(StringTools.urlDecode(pathParts[pathParts.length - 1]));
+				JID.parse(uriDecode(pathParts[pathParts.length - 1]));
 			}
 		} else {
 			JID.parse(query);
@@ -931,22 +932,11 @@ class Client extends EventEmitter {
 		}
 
 		if (StringTools.startsWith(query, "https://")) {
-			tink.http.Client.fetch(query, {
-				method: HEAD
-			}).all()
-				.handle(function(o) switch o {
-					case Success(res):
-						final regex = ~/<xmpp:([^>]+)>/;
-						for (link in res.header.get("link")) {
-							if (regex.match(link)) {
-								final parts = regex.matched(1).split("?");
-								final jid = JID.parse(StringTools.urlDecode(parts[0]));
-								if (jid.isValid()) checkAndAdd(jid, true);
-							}
-						}
-					case Failure(e):
-						trace("findAvailable request failed", e);
-				});
+			xmppLinkHeader(query).then(xmppUri -> {
+				final parts = xmppUri.substr(5).split("?");
+				final jid = JID.parse(uriDecode(parts[0]));
+				if (jid.isValid()) checkAndAdd(jid, true);
+			});
 		}
 
 		for (chat in chats) {
diff --git a/borogove/DataForm.hx b/borogove/DataForm.hx
index ffad925..f4af46a 100644
--- a/borogove/DataForm.hx
+++ b/borogove/DataForm.hx
@@ -2,14 +2,25 @@ package borogove;
 
 import borogove.Stanza;
 
+#if cpp
+import HaxeCBridge;
+#end
+
 @:forward(toString)
 abstract DataForm(Stanza) from Stanza to Stanza {
-	inline public function fields(): Array<Field> {
+	public var title(get, never): Null<String>;
+	public var fields(get, never): Array<Field>;
+
+	inline public function get_title() {
+		return this.getChildText("title");
+	}
+
+	inline public function get_fields() {
 		return this.allTags("field");
 	}
 
 	public function field(name: String): Null<Field> {
-		final matches = fields().filter(f -> f.name == name);
+		final matches = fields.filter(f -> f.name == name);
 		if (matches.length > 1) {
 			trace('Multiple fields matching ${name}');
 		}
@@ -19,13 +30,126 @@ abstract DataForm(Stanza) from Stanza to Stanza {
 
 abstract Field(Stanza) from Stanza to Stanza {
 	public var name(get, never): String;
-	public var value(get, never): Array<String>;
+	public var label(get, never): Null<String>;
+	public var value(get, set): Array<String>;
+	public var type(get, never): String;
+	public var datatype(get, never): String;
+	public var open(get, never): Bool;
+	public var rangeMin(get, never): Null<String>;
+	public var rangeMax(get, never): Null<String>;
+	public var regex(get, never): Null<String>;
+	public var required(get, never): Bool;
 
 	inline public function get_name() {
-		return this.attr.get("var");
+		return this.attr.get("var") ?? "";
+	}
+
+	inline public function get_label() {
+		return this.attr.get("label");
 	}
 
 	public function get_value() {
-		return this.allTags("value").map(v -> v.getText());
+		final isbool = (this : Field).datatype == "xs:boolean";
+		return this.allTags("value").map(v -> {
+			final txt = v.getText();
+			if (isbool) {
+				Stanza.parseXmlBool(txt) ? "true" : "false";
+			} else {
+				return txt;
+			}
+		});
+	}
+
+	public function set_value(val: Array<String>) {
+		this.removeChildren("value");
+		for (v in val) {
+			this.textTag("value", v);
+		}
+		return val;
+	}
+
+	inline public function get_type() {
+		final attr = this.attr.get("type");
+
+		// We will treat jid as a datatype not a field type
+		if (attr == "jid-single") return "text-single";
+		if (attr == "jid-multi") return "text-multi";
+		return attr;
+	}
+
+	public function get_datatype() {
+		final validate = this.getChild("validate", "http://jabber.org/protocol/xdata-validate");
+		if (validate != null && validate.attr.get("datatype") != null) {
+			return validate.attr.get("datatype");
+		}
+		if (["jid-single", "jid-multi"].contains(this.attr.get("type"))) {
+			return "jid";
+		}
+		if (this.attr.get("type") == "boolean") return "xs:boolean";
+		return "xs:string";
+	}
+
+	inline public function get_open() {
+		final validate = this.getChild("validate", "http://jabber.org/protocol/xdata-validate");
+		return validate?.getChild("open") != null;
+	}
+
+	inline public function get_rangeMin() {
+		return range()?.attr?.get("min");
+	}
+
+	inline public function get_rangeMax() {
+		return range()?.attr?.get("max");
+	}
+
+	inline private function range() {
+		final validate = this.getChild("validate", "http://jabber.org/protocol/xdata-validate");
+		return validate?.getChild("range");
+	}
+
+	inline public function get_regex() {
+		final validate = this.getChild("validate", "http://jabber.org/protocol/xdata-validate");
+		return validate?.getChildText("regex");
+	}
+
+	inline public function get_required() {
+		return this.getChild("required") != null;
+	}
+
+	@:to
+	inline public function toFormField(): FormField {
+		return this == null ? null : new FormField(this);
+	}
+}
+
+@:expose
+#if cpp
+@:build(HaxeCBridge.expose())
+@:build(HaxeSwiftBridge.expose())
+#end
+class FormField {
+	public final name: String;
+	public final label: Null<String>;
+	public final value: Array<String>;
+	public final required: Bool;
+	public final type: String;
+	public final datatype: String;
+	public final open: Bool;
+	public final rangeMin: Null<String>;
+	public final rangeMax: Null<String>;
+	public final regex: Null<String>;
+
+	@:allow(borogove)
+	private function new(field: Field) {
+		name = field.name;
+		label = field.label;
+		value = field.value;
+		required = field.required;
+		type = field.type;
+		datatype = field.datatype;
+		open = field.open;
+		rangeMin = field.rangeMin;
+		rangeMax = field.rangeMax;
+		regex = field.regex;
 	}
 }
diff --git a/borogove/Form.hx b/borogove/Form.hx
new file mode 100644
index 0000000..5b51684
--- /dev/null
+++ b/borogove/Form.hx
@@ -0,0 +1,208 @@
+package borogove;
+
+import borogove.DataForm;
+
+#if cpp
+import HaxeCBridge;
+#end
+
+@:expose
+#if cpp
+@:build(HaxeSwiftBridge.expose())
+#end
+interface FormSection {
+	public function title(): Null<String>;
+	public function items(): Array<FormItem>;
+}
+
+@:expose
+#if cpp
+@:build(HaxeCBridge.expose())
+@:build(HaxeSwiftBridge.expose())
+#end
+class FormItem {
+	public final text: Null<String>;
+	public final field: Null<FormField>;
+	public final section: Null<FormSection>;
+
+	@:allow(borogove)
+	private function new(text: Null<String>, field: Null<FormField>, section: Null<FormSection>) {
+		this.text = text;
+		this.field = field;
+		this.section = section;
+	}
+}
+
+#if cpp
+@:build(HaxeCBridge.expose())
+@:build(HaxeSwiftBridge.expose())
+#end
+class FormSubmitBuilder {
+	private final data: Map<String, Array<String>> = [];
+
+	public function new() { }
+
+	public function add(k: String, v: String) {
+		if (data.has(k)) {
+			data.set(k, data.get(k).concat([v]));
+		} else {
+			data.set(k, [v]);
+		}
+	}
+
+	@:allow(borogove)
+	private function submit(form: DataForm) {
+		final toSubmit = new Stanza("x", { xmlns: "jabber:x:data", type: "submit" });
+		for (f in form.fields) {
+			if (!data.has(f.name) && f.value.length > 0) {
+				final tag = toSubmit.tag("field", { "var": f.name });
+				for (v in f.value) {
+					tag.textTag("value", v);
+				}
+				tag.up();
+			} else if (f.required && (!data.has(f.name) || data[f.name].length < 1)) {
+				trace("No value provided for required field", f.name);
+				return null;
+			}
+		}
+		for (k => vs in data) {
+			final tag = toSubmit.tag("field", { "var": k });
+			for (v in vs) {
+				tag.textTag("value", v);
+			}
+			tag.up();
+		}
+
+		return toSubmit;
+	}
+}
+
+typedef StringOrArray = haxe.extern.EitherType<String, Array<String>>;
+
+@:expose
+#if cpp
+@:build(HaxeCBridge.expose())
+@:build(HaxeSwiftBridge.expose())
+#end
+class Form implements FormSection {
+	private final form: DataForm;
+
+	@:allow(borogove)
+	private function new(form: DataForm) {
+		this.form = form;
+	}
+
+	public function title() {
+		return form.title;
+	}
+
+	public function items() {
+		final s: Stanza = form;
+		final hasLayout = s.getChild("page", "http://jabber.org/protocol/xdata-layout") != null;
+		final items = [];
+		for (child in s.allTags()) {
+			if (child.name == "instructions" && (child.attr.get("xmlns") == null || child.attr.get("xmlns") == "jabber:x:data")) {
+				items.push(new FormItem(child.getText(), null, null));
+			}
+			if (!hasLayout && child.name == "field" && (child.attr.get("xmlns") == null || child.attr.get("xmlns") == "jabber:x:data")) {
+				final fld: Null<Field> = child;
+				if (fld.type != "hidden") {
+					items.push(new FormItem(null, fld, null));
+				}
+			}
+			if (!hasLayout && child.name == "reported" && (child.attr.get("xmlns") == null || child.attr.get("xmlns") == "jabber:x:data")) {
+				throw "TODO";
+			}
+			if (child.name == "page" && child.attr.get("xmlns") == "http://jabber.org/protocol/xdata-layout") {
+				items.push(new FormItem(null, null, new FormLayoutSection(form, child)));
+			}
+		}
+
+		return items;
+	}
+
+	#if js
+	@:allow(borogove)
+	private function submit(
+		data: haxe.extern.EitherType<
+			haxe.extern.EitherType<
+				haxe.DynamicAccess<StringOrArray>,
+				Map<String, StringOrArray>
+			>,
+			js.html.FormData
+		>
+	) {
+		final builder = new FormSubmitBuilder();
+
+		if (Std.isOfType(data, js.lib.Map)) {
+			for (k => v in ((cast data) : Map<String, StringOrArray>)) {
+				if (Std.isOfType(v, String)) {
+					builder.add(k, v);
+				} else {
+					for (oneV in ((cast v) : Array<String>)) {
+						builder.add(k, oneV);
+					}
+				}
+			}
+		#if !nodejs
+		} else if (Std.isOfType(data, js.html.FormData)) {
+			for (entry in new js.lib.HaxeIterator(((cast data) : js.html.FormData).entries())) {
+				builder.add(entry[0], entry[1]);
+			}
+		#end
+		} else {
+			for (k => v in ((cast data) : haxe.DynamicAccess<StringOrArray>)) {
+				if (Std.isOfType(v, String)) {
+					builder.add(k, v);
+				} else {
+					for (oneV in ((cast v) : Array<String>)) {
+						builder.add(k, oneV);
+					}
+				}
+			}
+		}
+
+		return builder.submit(form);
+	}
+	#else
+	@:allow(borogove)
+	private function submit(data: FormSubmitBuilder) {
+		return data.submit(form);
+	}
+	#end
+}
+
+class FormLayoutSection implements FormSection {
+	private final form: DataForm;
+	private final section: Stanza;
+
+	@:allow(borogove)
+	private function new(form: DataForm, section: Stanza) {
+		this.form = form;
+		this.section = section;
+	}
+
+	public function title() {
+		return section.attr.get("label");
+	}
+
+	public function items() {
+		final items = [];
+		for (child in section.allTags()) {
+			if (child.name == "text" && (child.attr.get("xmlns") == null || child.attr.get("xmlns") == "http://jabber.org/protocol/xdata-layout")) {
+				items.push(new FormItem(child.getText(), null, null));
+			}
+			if (child.name == "fieldref" && (child.attr.get("xmlns") == null || child.attr.get("xmlns") == "http://jabber.org/protocol/xdata-layout")) {
+				items.push(new FormItem(null, form.field(child.attr.get("var")), null));
+			}
+			if (child.name == "reportedref" && (child.attr.get("xmlns") == null || child.attr.get("xmlns") == "http://jabber.org/protocol/xdata-layout")) {
+				throw "TODO";
+			}
+			if (child.name == "section" && (child.attr.get("xmlns") == null || child.attr.get("xmlns") == "http://jabber.org/protocol/xdata-layout")) {
+				items.push(new FormItem(null, null, new FormLayoutSection(form, child)));
+			}
+		}
+
+		return items;
+	}
+}
diff --git a/borogove/GenericStream.hx b/borogove/GenericStream.hx
index f1bdd0a..4404049 100644
--- a/borogove/GenericStream.hx
+++ b/borogove/GenericStream.hx
@@ -1,6 +1,7 @@
 package borogove;
 
 import haxe.io.BytesData;
+import thenshim.Promise;
 import borogove.Stanza;
 import borogove.EventEmitter;
 
@@ -18,9 +19,10 @@ abstract class GenericStream extends EventEmitter {
 	public function new() {
 		super();
 	}
-	
+
 	/* Connections and streams */
 
+	abstract public function register(domain: String, preAuth: Null<String>):Promise<Stanza>;
 	abstract public function connect(jid:String, sm:Null<BytesData>):Void;
 	abstract public function disconnect():Void;
 	abstract public function sendStanza(stanza:Stanza):Void;
diff --git a/borogove/Register.hx b/borogove/Register.hx
new file mode 100644
index 0000000..c635d4d
--- /dev/null
+++ b/borogove/Register.hx
@@ -0,0 +1,149 @@
+package borogove;
+
+import thenshim.Promise;
+
+import borogove.Form;
+import borogove.Stanza;
+import borogove.Stream;
+import borogove.Util;
+
+using StringTools;
+
+#if cpp
+import HaxeCBridge;
+#end
+
+@:expose
+#if cpp
+@:build(HaxeCBridge.expose())
+@:build(HaxeSwiftBridge.expose())
+#end
+class Register {
+	private final stream: GenericStream;
+	private final username: Null<String>;
+	private final domain: String;
+	private final preAuth: Null<String>;
+	private var form: Null<Form> = null;
+
+	private function new(domain: String, preAuth: Null<String>, username: Null<String>) {
+		stream = new Stream();
+		this.domain = domain;
+		this.preAuth = preAuth;
+		this.username = username;
+	}
+
+	/**
+		Start new registration flow for a given domain or invite URL
+	**/
+	public static function fromDomainOrInvite(domainOrInvite: String) {
+		if (domainOrInvite.startsWith("xmpp:")) {
+			return Promise.resolve(fromXmppURI(domainOrInvite));
+		} else if (domainOrInvite.startsWith("https://")) {
+			return xmppLinkHeader(domainOrInvite).then(xmppUri -> {
+				return fromXmppURI(xmppUri);
+			});
+		} else {
+			return Promise.resolve(new Register(domainOrInvite, null, null));
+		}
+	}
+
+	private static function fromXmppURI(xmppUri: String) {
+		final parts = xmppUri.substr(5).split("?");
+		final authParts = parts[0].split("@");
+		final domain = uriDecode(authParts.length > 1 ? authParts[1] : authParts[0]);
+		var preAuth: Null<String> = null;
+		var username: Null<String> = null;
+		if (parts.length > 1) {
+			final queryParts = parts[1].split(";");
+			for (part in queryParts) {
+				if (part == "register" && authParts.length > 1) username = uriDecode(authParts[0]);
+				if (part.startsWith("preauth=")) {
+					preAuth = uriDecode(part.substr(8));
+				}
+			}
+		}
+		return new Register(domain, preAuth, username);
+	}
+
+	/**
+		Fetch registration form from the server.
+		If you already know what fields your server wants, this is optional.
+	**/
+	public function getForm() {
+		return stream.register(domain, preAuth).then(reply -> {
+			final error = getError(reply);
+			if (error != null) return Promise.reject(error);
+
+			final query = reply.getChild("query", "jabber:iq:register");
+			final form: DataForm = query.getChild("x", "jabber:x:data");
+			if (form == null) {
+				return Promise.reject("No form found");
+			}
+
+			if (username != null) {
+				final fuser = form.field("username");
+				fuser.value = [username];
+				(fuser : Stanza).attr.set("type", "fixed");
+			}
+
+			this.form = new Form(form);
+			return Promise.resolve(this.form);
+		});
+	}
+
+	/**
+		Submit registration data to the server
+	**/
+	#if js
+	public function submit(
+		data: haxe.extern.EitherType<
+			haxe.extern.EitherType<
+				haxe.DynamicAccess<StringOrArray>,
+				Map<String, StringOrArray>
+			>,
+			js.html.FormData
+		>
+	)
+	#else
+	public function submit(data: FormSubmitBuilder)
+	#end
+	: Promise<String> {
+		return (form == null ? getForm() : Promise.resolve(null)).then(_ -> {
+			final toSubmit: DataForm = form.submit(data);
+			if (toSubmit == null) return Promise.reject("Invalid submission");
+
+			return new Promise((resolve, reject) -> {
+				stream.sendIq(
+					new Stanza("iq", { type: "set", to: domain })
+						.tag("query", { xmlns: "jabber:iq:register" })
+						.addChild(toSubmit),
+					(reply) -> {
+						final error = getError(reply);
+						if (error != null) return reject(error);
+
+						// It is conventional for username@domain to be the registered JID
+						// IBR doesn't really give us a better option right now
+						resolve(toSubmit.field("username")?.value?.join("") + "@" + domain);
+					}
+				);
+			});
+		});
+	}
+
+	/**
+		Disconnect from the server after registration is done
+	**/
+	public function disconnect() {
+		stream.disconnect();
+	}
+
+	private function getError(iq: Stanza) {
+		if (iq.attr.get("type") == "error") {
+			final error = iq.getChild("error");
+			final text = error.getChildText("text", "urn:ietf:params:xml:ns:xmpp-stanzas");
+			return text ?? error.getFirstChild()?.name ?? "error";
+		}
+
+		return null;
+	}
+}
diff --git a/borogove/Util.hx b/borogove/Util.hx
index f6bdf5a..f33949d 100644
--- a/borogove/Util.hx
+++ b/borogove/Util.hx
@@ -1,6 +1,7 @@
 package borogove;
 
 import haxe.io.Bytes;
+import thenshim.Promise;
 #if js
 import js.html.TextEncoder;
 final textEncoder = new TextEncoder();
@@ -22,6 +23,26 @@ function setupTrace() {
 #end
 }
 
+function xmppLinkHeader(url: String) {
+	return new Promise((resolve, reject) -> {
+		tink.http.Client.fetch(url, { method: HEAD })
+			.all()
+			.handle(function(o) switch o {
+				case Success(res):
+					final regex = ~/<(xmpp:[^>]+)>/;
+					for (link in res.header.get("link")) {
+						if (regex.match(link)) {
+							resolve(regex.matched(1));
+							return;
+						}
+					}
+					reject(null);
+				case Failure(e):
+					reject(e);
+			});
+	});
+}
+
 inline function bytesOfString(s: String) {
 #if js
 	return Bytes.ofData(textEncoder.encode(s).buffer);
@@ -64,6 +85,43 @@ function xmlEscape(s: String) {
 	return StringTools.replace(StringTools.replace(StringTools.replace(s, "&", "&amp;"), "<", "&lt;"), ">", "&gt;");
 }
 
+// Taken from StringTools but modified not to decode + as space
+#if (flash || js || cs || python) inline #end function uriDecode(s:String):String {
+	#if flash
+	return untyped __global__["decodeURIComponent"](s);
+	#elseif js
+	return untyped decodeURIComponent(s);
+	#elseif cs
+	return untyped cs.system.Uri.UnescapeDataString(s);
+	#elseif python
+	return python.lib.urllib.Parse.unquote(s);
+	#elseif lua
+	s = lua.NativeStringTools.gsub(s, "%%(%x%x)", function(h) {
+		return lua.NativeStringTools.char(lua.Lua.tonumber(h, 16));
+	});
+	return s;
+	#else
+	final bytes = new haxe.io.BytesBuffer();
+	var i = 0;
+
+	while (i < s.length) {
+		var c = s.charAt(i);
+		if (c == "%" && i + 2 < s.length) {
+			final value = Std.parseInt("0x" + s.substr(i + 1, 2));
+			if (value != null) {
+				bytes.addByte(value);
+				i += 3;
+				continue;
+			}
+		}
+		bytes.addString(c, UTF8);
+		i++;
+	}
+
+	return bytes.getBytes().toString(); // UTF-8 decode
+	#end
+}
+
 macro function getGitVersion():haxe.macro.Expr.ExprOf<String> {
 	#if !display
 	var process = new sys.io.Process('git', ['describe', '--always']);
diff --git a/borogove/streams/XmppJsStream.hx b/borogove/streams/XmppJsStream.hx
index 746f693..1c7b852 100644
--- a/borogove/streams/XmppJsStream.hx
+++ b/borogove/streams/XmppJsStream.hx
@@ -24,6 +24,7 @@ extern class XmppJsClient {
 	function stop():Promise<Dynamic>;
 	function on(eventName:String, callback:(Dynamic)->Void):Void;
 	function send(stanza:XmppJsXml):Void;
+	final NS:String;
 	var jid:XmppJsJID;
 	var streamFrom:Null<XmppJsJID>;
 	var status: String;
@@ -100,6 +101,47 @@ extern class XmppJsError {
 	public final application: String;
 }
 
+
+@:js.import("@xmpp/client-core", "Client")
+extern class XmppJsClientCore {
+	function new(params: { service: String, domain: String });
+	function start():Promise<Dynamic>;
+}
+
+@:js.import(@default "@xmpp/websocket")
+extern class XmppJsWebsocket {
+	function new(params: { entity: XmppJsClientCore });
+}
+
+#if nodejs
+@:js.import(@default "@xmpp/tcp")
+extern class XmppJsTcp {
+	function new(params: { entity: XmppJsClientCore });
+}
+
+
+@:js.import(@default "@xmpp/starttls")
+extern class XmppJsSTARTTLS {
+	function new(params: { streamFeatures: XmppJsStreamFeatures });
+}
+#end
+
+@:js.import(@default "@xmpp/resolve")
+extern class XmppJsResolve {
+	function new(params: { entity: XmppJsClientCore });
+}
+
+@:js.import(@default "@xmpp/middleware")
+extern class XmppJsMiddleware {
+	function new(params: { entity: XmppJsClientCore });
+}
+
+@:js.import(@default "@xmpp/stream-features")
+extern class XmppJsStreamFeatures {
+	function new(params: { middleware: XmppJsMiddleware });
+	function use(feature: String, ns: String, cb: (ctx: Any, next: ()->Void, feature: Any) -> Promise<Void>):Void;
+}
+
 class XmppJsStream extends GenericStream {
 	private var client:XmppJsClient;
 	private var jid:XmppJsJID;
@@ -130,6 +172,63 @@ class XmppJsStream extends GenericStream {
 		}, "offline");
 	}
 
+	public function register(domain: String, preAuth: Null<String>) {
+		final entity = new XmppJsClientCore({ service: domain, domain: domain });
+		final middleware = new XmppJsMiddleware({ entity: entity });
+		final streamFeatures = new XmppJsStreamFeatures({ middleware: middleware });
+
+		return new Promise((resolve, reject) -> {
+			if (preAuth != null) {
+				streamFeatures.use("register", "urn:xmpp:ibr-token:0", (ctx, next, feature) -> {
+					client.status = "online";
+					this.sendIq(
+						new Stanza("iq", { type: "set", to: domain }).tag("preauth", { xmlns: "urn:xmpp:pars:0", token: preAuth }),
+						reply -> {
+							if (reply.attr.get("type") == "error") {
+								resolve(reply);
+							} else {
+								next();
+							}
+						}
+					);
+					return Promise.resolve(null);
+				});
+			}
+
+			streamFeatures.use("register", "http://jabber.org/features/iq-register", (ctx, next, feature) -> {
+				client.status = "online";
+				this.sendIq(
+					new Stanza("iq", { type: "get", to: domain }).tag("query", { xmlns: "jabber:iq:register" }),
+					resolve
+				);
+				return Promise.resolve(null);
+			});
+
+			client = cast js.lib.Object.assign(
+				entity,
+				#if nodejs
+				new XmppJsTcp({ entity: entity }),
+				#end
+				new XmppJsWebsocket({ entity: entity }),
+				middleware,
+				streamFeatures,
+				new XmppJsResolve({ entity: entity }),
+				#if nodejs
+				new XmppJsSTARTTLS({ streamFeatures: streamFeatures }),
+				#end
+			);
+
+			client.on("stanza", function (stanza) {
+				this.onStanza(convertToStanza(stanza, client.NS));
+			});
+
+			client.start().catchError((err) -> {
+				trace(err);
+				reject(err);
+			});
+		});
+	}
+
 	public function connect(jidS:String, sm:Null<BytesData>) {
 		this.state.event("connect-requested");
 		this.jid = new XmppJsJID(jidS);
@@ -226,7 +325,7 @@ class XmppJsStream extends GenericStream {
 
 		xmpp.on("stanza", function (stanza) {
 			triggerSMupdate();
-			this.onStanza(convertToStanza(stanza));
+			this.onStanza(convertToStanza(stanza, client.NS));
 		});
 
 		xmpp.streamManagement.on("ack", (stanza) -> {
@@ -283,8 +382,10 @@ class XmppJsStream extends GenericStream {
 		return xml;
 	}
 
-	private static function convertToStanza(el:XmppJsXml):Stanza {
-		var stanza = new Stanza(el.name, el.attrs);
+	private static function convertToStanza(el:XmppJsXml, xmlns:Null<String> = null):Stanza {
+		var attrs: haxe.DynamicAccess<String> = el.attrs ?? {};
+		if (attrs.get("xmlns") == null && xmlns != null) attrs.set("xmlns", xmlns);
+		var stanza = new Stanza(el.name, attrs);
 		for (child in el.children) {
 			if(XmppJsLtx.isText(child)) {
 				stanza.text(cast(child, String));
@@ -313,7 +414,7 @@ class XmppJsStream extends GenericStream {
 	}
 
 	private function triggerSMupdate() {
-		if (client == null || !client.streamManagement.enabled || !emitSMupdates) return;
+		if (client == null || !client.streamManagement?.enabled || !emitSMupdates) return;
 		this.trigger(
 			"sm/update",
 			{
diff --git a/borogove/streams/XmppStropheStream.hx b/borogove/streams/XmppStropheStream.hx
index f87d04c..6d6e86d 100644
--- a/borogove/streams/XmppStropheStream.hx
+++ b/borogove/streams/XmppStropheStream.hx
@@ -3,6 +3,7 @@ package borogove.streams;
 import haxe.DynamicAccess;
 import haxe.io.Bytes;
 import haxe.io.BytesData;
+import thenshim.Promise;
 
 import cpp.Char;
 import cpp.ConstPointer;
@@ -318,6 +319,10 @@ class XmppStropheStream extends GenericStream {
 		}
 	}
 
+	public function register(domain: String, preAuth: Null<String>) {
+		return Promise.reject("TODO");
+	}
+
 	public function connect(jid:String, sm:Null<BytesData>) {
 		StropheConn.set_jid(conn, NativeString.c_str(jid));
 		this.on("auth/password", function (event) {
diff --git a/browserjs.hxml b/browserjs.hxml
index 48d3979..6646135 100644
--- a/browserjs.hxml
+++ b/browserjs.hxml
@@ -9,6 +9,7 @@
 --library tink_http
 
 borogove.Client
+borogove.Register
 borogove.Push
 borogove.Version
 borogove.persistence.Sqlite
diff --git a/nodejs.hxml b/nodejs.hxml
index 8bc330c..9d36c8f 100644
--- a/nodejs.hxml
+++ b/nodejs.hxml
@@ -10,6 +10,7 @@
 --library tink_http
 
 borogove.Client
+borogove.Register
 borogove.Push
 borogove.Version
 borogove.persistence.Sqlite
diff --git a/npm/index.ts b/npm/index.ts
index 101a04e..57558b1 100644
--- a/npm/index.ts
+++ b/npm/index.ts
@@ -15,6 +15,9 @@ export import Config = borogove.Config;
 export import CustomEmojiReaction = borogove.CustomEmojiReaction;
 export import DirectChat = borogove.DirectChat;
 export import EventEmitter = borogove.EventEmitter;
+export import FormSection = borogove.FormSection;
+export import FormItem = borogove.FormItem;
+export import FormField = borogove.FormField;
 export import Hash = borogove.Hash;
 export import Identicon = borogove.Identicon;
 export import Notification = borogove.Notification;
@@ -22,6 +25,7 @@ export import Participant = borogove.Participant;
 export import Persistence = borogove.Persistence;
 export import Push = borogove.Push;
 export import Reaction = borogove.Reaction;
+export import Register = borogove.Register;
 export import SerializedChat = borogove.SerializedChat;
 export const VERSION = borogove.Version.HUMAN;
 export import calls = borogove.calls;