xmpp_dart
A lightweight Dart port of xmpp.js — TCP client only. Connects, secures (STARTTLS or direct TLS), authenticates (SASL PLAIN / SCRAM-SHA-1), binds a resource, and lets you send/receive stanzas.
Resolves servers via DNS SRV (_xmpps-client/_xmpp-client) when host is
omitted, trying candidates in priority/weight order with a plaintext fallback to
domain:5222. Inject a custom SrvResolver (e.g. DNS-over-HTTPS) if needed.
Honors <see-other-host/> stream-error redirects (bounded to 5 hops), both
during negotiation and on a live connection.
Supports XEP-0198 Stream Management: stanza acknowledgement, liveness <r/>
checks, and session resumption (replays unacked stanzas after a reconnect) —
opt in with streamManagement: true, autoReconnect: true.
Not (yet) implemented: WebSocket transport, SCRAM-SHA-256, typed roster/presence models (you work with raw XML instead).
Usage
import 'package:xmpp_dart/xmpp_dart.dart';
import 'package:xml/xml.dart'; // XmlElement — stanzas and IQ responses
final client = XmppClient(
domain: 'example.com', // host omitted -> DNS SRV resolves it
username: 'alice',
password: 'secret',
tls: TlsMode.starttls, // required STARTTLS; also .opportunistic / .direct (5223) / .none
);
// Or pin the server explicitly (skips SRV):
// host: 'xmpp.example.com', port: 5222, tls: TlsMode.starttls
client.states.listen((s) => print('state: $s')); // XmppState enum
await client.connect(); // completes when online
print('online as ${client.jid}'); // Jid — local@domain/resource
client.stanzas.listen((s) => print('recv: ${s.toXmlString()}'));
await client.send(xml('presence'));
await client.send(xml('message',
attrs: {'to': 'bob@example.com', 'type': 'chat'},
children: [xml('body', text: 'hi')]));
// IQ request/response — returns the full <iq type="result"> stanza
final rosterIq = await client.iq(xml('iq', attrs: {'type': 'get'}, children: [
xml('query', attrs: {'xmlns': 'jabber:iq:roster'}),
]));
await client.close();
API reference
Reference for code generation. The library is XML-first: stanzas are
XmlElement values built with xml() and
inspected with the xml package API. There are no typed Roster, Message, or
Presence classes — you parse XML yourself.
Imports
import 'package:xmpp_dart/xmpp_dart.dart'; // all public types
import 'package:xml/xml.dart'; // XmlElement inspection
Public exports from package:xmpp_dart/xmpp_dart.dart:
| Symbol | Kind |
|---|---|
XmppClient |
class — recommended entry point |
XmppConnection |
class — low-level connection state machine |
IqCaller, IqException |
class — IQ request/response pairing |
Jid |
class — XMPP address value type |
TlsMode, XmppState |
enum |
XmppException, StreamErrorException |
exception |
SaslMechanism, PlainMechanism, ScramSha1Mechanism, SaslException |
SASL (usually internal to connection) |
Transport, TcpTransport |
transport abstraction |
Reconnect |
exponential-backoff retry helper |
xml, XmlStreamParser |
XML builder and stream parser |
Dependencies: xml ^7, crypto ^3 (used internally for SCRAM-SHA-1).
Architecture
XmppClient
└─ TcpTransport.connect(host, port)
└─ XmppConnection(transport, domain, username, password, …)
└─ XmlStreamParser (bytes → XmlElement stanzas)
└─ SASL negotiation (PLAIN or SCRAM-SHA-1, auto-selected)
└─ IqCaller(conn.stanzas, conn.send) (IQ id matching)
Use XmppClient for normal apps. Use XmppConnection directly when you
need a custom Transport (tests, proxies). XmppClient exposes states,
stanzas, acks, and errors — the last carries typed `XmppException`s
(with isPermanent) plus ReconnectException when auto-reconnect gives up.
XmppClient
High-level TCP client. Creates transport + connection internally.
XmppClient({
required String host, // TCP hostname (may differ from domain)
required String domain, // XMPP domain for stream 'to' and SASL
required String username, // localpart (without @domain)
required String password,
int? port, // default: 5222 (starttls/none) or 5223 (direct)
TlsMode tls = TlsMode.starttls,
String? resource, // optional; server assigns one if omitted
})
| Member | Type | Description |
|---|---|---|
states |
Stream<XmppState> |
broadcast; emits on every state transition |
stanzas |
Stream<XmlElement> |
broadcast; incoming stanzas once online |
jid |
Jid? |
bound full JID after connect(); null before |
connect() |
Future<void> |
negotiates TLS → SASL → bind; completes at online |
send(element) |
Future<void> |
writes element.toXmlString() to server |
iq(element) |
Future<XmlElement> |
sends IQ, awaits matching-id response |
close() |
Future<void> |
sends </stream:stream>, closes socket and streams (not reusable) |
connect() throws (does not catch): XmppException, SaslException,
StreamErrorException, TimeoutException (negotiation timeout, default 10s on
XmppConnection).
send / iq before connect complete with StateError('not connected').
Subscribe to states and stanzas before connect() so no events are
missed (streams are broadcast).
XmppConnection
Low-level session driver. Same negotiation as XmppClient but you supply the
Transport.
XmppConnection({
required Transport transport,
required String domain,
required String username,
required String password,
TlsMode tls = TlsMode.starttls,
String? resource,
Duration timeout = const Duration(seconds: 10), // per negotiation read
})
| Member | Type | Description |
|---|---|---|
jid |
Jid? |
set after resource binding |
state |
XmppState |
current state (synchronous) |
userClosed |
bool |
true after explicit close() |
states |
Stream<XmppState> |
state transitions |
stanzas |
Stream<XmlElement> |
incoming stanzas while online |
errors |
Stream<Object> |
stream errors after online (triggers auto-close) |
connect() |
Future<Jid> |
returns bound JID |
send(element) |
Future<void> |
write stanza |
close() |
Future<void> |
graceful shutdown |
Pair with IqCaller manually:
final conn = XmppConnection(transport: t, domain: 'ex', username: 'a', password: 'p');
final iq = IqCaller(conn.stanzas, conn.send);
await conn.connect();
final res = await iq.request(xml('iq', attrs: {'type': 'get'}, children: [...]));
await iq.dispose();
await conn.close();
TlsMode
| Value | Port default | Behavior |
|---|---|---|
TlsMode.starttls |
5222 | plaintext socket, require <starttls> upgrade (fails if the server doesn't offer it) |
TlsMode.opportunistic |
5222 | upgrade via <starttls> if offered, else continue plaintext |
TlsMode.direct |
5223 | SecureSocket.connect immediately |
TlsMode.none |
5222 | no TLS (testing only) |
XmppState
Lifecycle enum emitted on states:
offline → connecting → open → authenticating → bound → online
↓
closing → disconnected
| State | Meaning |
|---|---|
offline |
initial |
connecting |
TCP open, negotiation starting |
open |
<stream:stream> sent, reading features |
authenticating |
SASL in progress |
bound |
resource binding |
online |
ready for stanzas |
closing |
user called close() |
disconnected |
socket closed |
Jid
Immutable value type: local@domain/resource.
const Jid(local, domain, resource) // any part may be null except domain
Jid.parse('alice@example.com/phone') // factory
jid.bare() // drops resource → alice@example.com
jid.toString() // canonical string
jid.local, jid.domain, jid.resource // fields
Parse rules: first / splits resource (resource may contain more /); @
splits local from domain.
xml() — stanza builder
XmlElement xml(
String name, {
Map<String, String>? attrs,
List<XmlElement>? children,
String? text,
})
Produces an XmlElement with unqualified element names (no namespace prefix
on the tag). Put namespaces in attrs: {'xmlns': '…'} on the element that
needs them.
// <presence/>
xml('presence')
// <presence type="unavailable"/>
xml('presence', attrs: {'type': 'unavailable'})
// <message to="bob@ex" type="chat"><body>hi</body></message>
xml('message',
attrs: {'to': 'bob@ex', 'type': 'chat'},
children: [xml('body', text: 'hi')],
)
// <iq type="get" id="r1"><query xmlns="jabber:iq:roster"/></iq>
xml('iq', attrs: {'type': 'get', 'id': 'r1'}, children: [
xml('query', attrs: {'xmlns': 'jabber:iq:roster'}),
])
Do not set xmlns on the stream root — the connection handles that.
Outgoing stanzas are serialized with element.toXmlString() (no XML declaration
prepended per stanza).
XmlElement inspection (package:xml)
All incoming stanzas and IQ responses are XmlElement. Common methods:
| Method | Returns | Use |
|---|---|---|
name.local |
String |
tag name without prefix (message, iq, presence) |
getAttribute(name) |
String? |
stanza attrs: to, from, type, id |
getElement(name) |
XmlElement? |
first direct child by local name |
findElements(name) |
Iterable<XmlElement> |
all descendants by local name |
childElements |
Iterable<XmlElement> |
direct children |
innerText |
String |
concatenated text content |
toXmlString() |
String |
serialize (debugging) |
setAttribute(name, value) |
void |
mutate (used by IqCaller for id) |
Stanza routing — branch on stanza.name.local:
client.stanzas.listen((stanza) {
switch (stanza.name.local) {
case 'message':
final body = stanza.getElement('body')?.innerText;
final from = stanza.getAttribute('from');
// ...
case 'presence':
final type = stanza.getAttribute('type'); // unavailable, subscribe, …
final from = Jid.parse(stanza.getAttribute('from')!);
// ...
case 'iq':
// usually handled by IqCaller; unmatched IQs arrive here too
break;
}
});
client.iq / IqCaller
Future<XmlElement> iq(XmlElement element) // XmppClient
Future<XmlElement> request(XmlElement iq) // IqCaller
Behavior:
- If
iqhas noid, assignsiq-0,iq-1, … - Sends the stanza.
- Waits for an
<iq>with the sameid. type="error"→ throwsIqException(message is full XML string).- Any other type (
result, etc.) → returns the full<iq>element. - No response within timeout →
TimeoutException(default 30s onIqCaller).
The returned value is the entire response stanza, not just the payload child.
Extract payload with getElement / findElements:
final res = await client.iq(xml('iq', attrs: {'type': 'get'}, children: [
xml('query', attrs: {'xmlns': 'jabber:iq:version'}),
]));
final query = res.getElement('query');
final name = query?.getElement('name')?.innerText;
IQ type on requests: get (read), set (write). Response types: result,
error.
Common stanza recipes
Namespaces and attrs shown are what servers expect. Adjust to / from as needed.
Initial presence (go online):
await client.send(xml('presence'));
Directed presence:
await client.send(xml('presence', attrs: {'to': 'bob@example.com'}));
Unavailable (go offline):
await client.send(xml('presence', attrs: {'type': 'unavailable'}));
Chat message:
await client.send(xml('message',
attrs: {'to': 'bob@example.com', 'type': 'chat'},
children: [xml('body', text: 'hello')],
));
Roster fetch (jabber:iq:roster):
final rosterIq = await client.iq(xml('iq', attrs: {'type': 'get'}, children: [
xml('query', attrs: {'xmlns': 'jabber:iq:roster'}),
]));
for (final item in rosterIq.findElements('item')) {
final jid = Jid.parse(item.getAttribute('jid')!);
final name = item.getAttribute('name');
final subscription = item.getAttribute('subscription'); // both|to|from|none
}
Roster add / subscribe:
await client.iq(xml('iq', attrs: {'type': 'set'}, children: [
xml('query', attrs: {'xmlns': 'jabber:iq:roster'}, children: [
xml('item', attrs: {'jid': 'bob@example.com', 'name': 'Bob'}),
]),
]));
await client.send(xml('presence',
attrs: {'to': 'bob@example.com', 'type': 'subscribe'}));
Service discovery (http://jabber.org/protocol/disco#info):
final disco = await client.iq(xml('iq',
attrs: {'type': 'get', 'to': 'example.com'},
children: [
xml('query', attrs: {'xmlns': 'http://jabber.org/protocol/disco#info'}),
],
));
Ping (urn:xmpp:ping):
await client.iq(xml('iq',
attrs: {'type': 'get', 'to': 'example.com'},
children: [xml('ping', attrs: {'xmlns': 'urn:xmpp:ping'})],
));
Version (jabber:iq:version):
final ver = await client.iq(xml('iq', attrs: {'type': 'get'}, children: [
xml('query', attrs: {'xmlns': 'jabber:iq:version'}),
]));
Reconnect
Exponential-backoff retry wrapper. Does not own a client — you wire it.
XmppClient is not reusable after close() — it closes states and
stanzas. For reconnect, create a fresh client each attempt (or use
XmppConnection directly, which keeps its streams open across close()).
XmppClient? client;
final reconnect = Reconnect(() async {
client = XmppClient(host: 'example.com', domain: 'example.com',
username: 'alice', password: 'secret');
client!.stanzas.listen(handleStanza); // subscribe before connect
await client!.connect();
await client!.send(xml('presence'));
}, base: const Duration(seconds: 1), max: const Duration(seconds: 60));
// on unexpected disconnect — only retry if user didn't call close():
client?.states.listen((s) {
if (s == XmppState.disconnected) reconnect.run();
});
reconnect.stop(); // cancel pending retries
await client?.close(); // intentional shutdown — don't reconnect
| Member | Description |
|---|---|
backoff(attempt) |
delay for attempt n (1-based): base * 2^(n-1), capped at max |
run() |
calls connect until success or stop() |
stop() |
halt retries |
attempts |
failure count |
Check XmppConnection.userClosed (low-level) to avoid reconnecting after
intentional close().
Transport / TcpTransport
abstract class Transport {
Stream<List<int>> get incoming;
void write(String data);
Future<void> upgradeTls(String host);
Future<void> close();
Future<void> get done;
}
// Real socket:
final t = await TcpTransport.connect('host', 5222, secure: false);
For unit tests, implement Transport or use a fake that records writes and
accepts scripted incoming bytes (see test/support/fake_transport.dart).
XmlStreamParser
Low-level incremental parser for XMPP byte streams. Used internally by
XmppConnection; exposed for custom tooling.
final parser = XmlStreamParser();
parser.streamOpen.listen((el) => /* <stream:stream> attrs */);
parser.stanzas.listen((stanza) => /* complete top-level element */);
parser.streamClose.listen((_) => /* </stream:stream> */);
parser.feed(chunk); // call with decoded UTF-8 strings, any chunk size
parser.reset(); // after STARTTLS or SASL stream restart
parser.close();
Exceptions
| Type | When |
|---|---|
XmppException |
negotiation failure (bad bind, unexpected element, …) |
StreamErrorException |
server sent <stream:error> (unrecoverable) |
SaslException |
auth failure or unsupported mechanism |
IqException |
IQ response type="error" |
TimeoutException |
IQ or negotiation read timed out |
StateError |
send/iq on disconnected XmppClient |
StreamErrorException and errors stream (on XmppConnection) fire after the
session is already online; the connection auto-closes.
SASL
Mechanism selection is automatic via selectMechanism (internal): prefers
SCRAM-SHA-1 over PLAIN from server-offered list. ScramSha1Mechanism
and PlainMechanism are exported for testing; apps do not call them directly.
Username is the localpart only (not user@domain). Domain is the XMPP
service domain passed to XmppClient.domain.
Agent checklist
When generating code against this library:
- Import both
xmpp_dartandxml. - Use
XmppClientunless custom transport is required. - Subscribe to
stanzasbeforeconnect(). - Build all stanzas with
xml(); parse responses withXmlElementmethods. - Use
client.iq()for request/response; useclient.send()for fire-and-forget (presence, messages). client.iq()returns the full<iq>wrapper — drill into children.- Use
Jid.parse()onfrom/toattributes; compare with.bare()for bare-JID matching. - Call
await client.send(xml('presence'))after connect to appear online. - Call
await client.close()on shutdown — creates a newXmppClientto reconnect. - Do not invent helper classes from this package — they do not exist.
- Do not use WebSocket, stream management, or SCRAM-SHA-256 — not implemented.
Answering inbound IQs
Register handlers for iq get/set; the reply (result or error) is built and
sent for you. urn:xmpp:ping is answered automatically.
client.onIqGet('jabber:iq:version', 'query', (iq, child) {
return xml('query', attrs: {'xmlns': 'jabber:iq:version'}, children: [
xml('name', text: 'my-bot'),
xml('version', text: '1.0'),
]);
});
// Reject with a stanza error:
client.onIqSet('urn:example', 'cmd', (iq, child) {
throw IqError('forbidden', type: 'auth');
});
Return null for an empty <iq type="result"/>. Unmatched queries get
service-unavailable, malformed ones bad-request. Handlers persist across
reconnects.
Testing
dart test # unit tests (no network)
dart test --tags integration # real server, needs XMPP_HOST/USER/PASS env vars
The Transport abstraction is the test seam: unit tests drive the full
negotiation with a scripted in-memory transport, no sockets involved.
Design
See docs/superpowers/specs/2026-07-07-xmpp-dart-tcp-client-design.md.
Libraries
- xmpp_dart
- A lightweight Dart port of xmpp.js — a TCP XMPP client.