Crystal XMPP
cr-xmpp is a pure-Crystal, event-driven toolkit for building XMPP clients,
bots, automation services, connected devices, gateways, and XEP-0114 external
components. It manages the wire protocol and connection lifecycle while
exposing XMPP through typed, extensible Crystal APIs.
Highlights include:
- Secure client sessions with verified STARTTLS, SASL1 and SASL2,
SCRAM-SHA-1/256/512 and their
-PLUSvariants, TLS channel binding, and authentication downgrade protection. - Resilient long-running connections with supervised reconnection, XEP-0198 stream resumption, acknowledgement tracking, bounded resend queues, serialized concurrent writes, timeouts, and explicit lifecycle errors.
- Event-based routing for messages, presence, and IQ stanzas, backed by typed parsing and serialization for common messaging, discovery, PubSub/PEP, MUC, forwarding, IoT, and metadata extensions.
- External-component support with service discovery, namespace delegation, privileged-entity helpers, and the same routing and lifecycle model used by clients.
- An extension registry and generic XML-node fallback for adding custom or experimental protocols without forking the shard.
See the protocol support matrix for the exact integrated, API-level, and partial scope of each specification.
Documentation
- Protocol support — implemented, partial, and missing RFCs/XEPs
- Changelog — release and development history
- API documentation — generated types and methods
- Local Prosody environment — Docker setup and troubleshooting
- Examples — clients, components, PubSub, SASL, and channel binding
Installation
Add the shard to shard.yml:
dependencies:
cr-xmpp:
github: naqvis/cr-xmpp
Then install dependencies:
shards install
Channel binding uses the openssl_ext dependency installed with the shard.
Client quick start
require "cr-xmpp"
config = XMPP::Config.new(
host: "xmpp.example.com",
jid: "[email protected]/worker",
password: ENV["XMPP_PASSWORD"],
tls: true
)
router = XMPP::Router.new
router.message do |sender, packet|
next unless message = packet.as?(XMPP::Stanza::Message)
reply = XMPP::Stanza::Message.new
reply.to = message.from
reply.body = "Received: #{message.body}"
sender.send(reply)
end
client = XMPP::Client.new(config, router)
manager = XMPP::StreamManager.new(client)
manager.run
StreamManager supervises the connection and reconnects unexpected
disconnections. Call manager.stop for graceful shutdown. Applications that
need direct lifecycle control can use client.connect, client.send, and
client.disconnect.
Reconnects use bounded exponential backoff with jitter. The defaults start at one second and cap at 30 seconds; applications can tune them:
policy = XMPP::ReconnectPolicy.new(
initial_delay: 500.milliseconds,
max_delay: 20.seconds,
multiplier: 2.0,
jitter: 0.2
)
manager = XMPP::StreamManager.new(
client,
retry_count: 8,
reconnect_policy: policy
)
Authentication, certificate-verification, configuration, and malformed protocol failures are classified as permanent and are not retried. Transport and TLS-negotiation failures remain retryable.
Configuration and security
TLS is enabled by default. The client verifies the certificate against the JID domain, rejects TLS 1.0/1.1, applies socket I/O timeouts, and limits individual XML elements to 1 MiB.
config = XMPP::Config.new(
host: "xmpp.example.com",
port: 5222,
jid: "[email protected]/worker",
password: ENV["XMPP_PASSWORD"],
io_timeout: 20,
max_stanza_size: 512 * 1024,
tls_ca_certificates: "/etc/company/xmpp-ca.pem",
auto_presence: false
)
skip_cert_verify: true disables certificate authentication and is intended
only for controlled development environments.
SASL policy
The default XMPP::SASL_AUTH_ORDER permits SCRAM and SCRAM-PLUS only. It does
not silently fall back to PLAIN, DIGEST-MD5, or ANONYMOUS.
Legacy interoperability must be enabled explicitly:
sasl_auth_order: XMPP::LEGACY_SASL_AUTH_ORDER
Alternatively, select only the mechanisms required by the deployment:
sasl_auth_order: [
XMPP::AuthMechanism::SCRAM_SHA_256,
XMPP::AuthMechanism::PLAIN,
]
PLAIN is accepted only over certificate-verified TLS. Disabling certificate
verification does not satisfy that requirement.
Channel binding
SCRAM-PLUS is selected automatically when the server and TLS connection support it. The library implements:
tls-exporterfor TLS 1.3tls-server-end-pointfor TLS 1.2 and TLS 1.3tls-uniquefor TLS 1.2 and earlier- XEP-0474 SCRAM downgrade protection
- XEP-0515 TLS-version downgrade protection
See the channel-binding example.
Lifecycle and concurrency
current_state reports:
DisconnectedConnectingConnectedSessionEstablishedDisconnectingStreamError
Connection shutdown and manager stop are idempotent. Writes from multiple fibers are serialized so XML fragments cannot interleave.
Unexpected disconnect events include the originating exception and description.
StreamManager#metrics exposes connection/login timing, connection and
reconnect attempts, failures, disconnects, stream errors, XEP-0198 resumption
outcomes, the last error, and current unacknowledged-stanza queue depth.
When XEP-0198 is active, the client retains at most 100 unacknowledged stanzas.
Reaching that boundary raises XMPP::SendQueueFullError. Sending while
disconnected raises XMPP::NotConnectedError, and starting a duplicate
connection raises XMPP::AlreadyConnectedError. These inherit from
XMPP::ConnectionError.
External components
External components use the same router and supervised lifecycle:
options = XMPP::ComponentOptions.new(
domain: "gateway.example.com",
secret: ENV["XMPP_COMPONENT_SECRET"],
host: "xmpp.example.com",
port: 5347,
name: "Example gateway",
category: "gateway",
type: "generic"
)
component = XMPP::Component.new(options, XMPP::Router.new)
XMPP::StreamManager.new(component).run
See the component examples for service discovery, namespace delegation, and privileged-entity usage. Exact protocol coverage and limitations belong in PROTOCOL.md.
Development and testing
Run the same release gate locally that the hosted workflow uses:
./scripts/ci unit
./scripts/ci integration
./scripts/ci all
The unit gate checks dependencies, formatting, specs under default and
four-worker execution, and API documentation. The integration gate generates a
temporary CA, starts digest-pinned Prosody from the canonical
docker-compose.yml, runs live TLS/SASL/concurrency tests, and removes its
isolated container and volume.
For manual server operation and user-management commands, see docker/README.md.
Extending stanzas
XMPP extensions are represented by types under XMPP::Stanza. Custom
extensions implement the appropriate payload module, provide XML parsing and
serialization, and register their XML name with the stanza registry. Existing
implementations under src/xmpp/stanza are the canonical
examples.
Contributing
- Fork the repository.
- Create a focused branch.
- Add or update specs for the change.
- Run
./scripts/ci all. - Open a pull request.
License
cr-xmpp is distributed under the MIT License.
Contributors
- Ali Naqvi — creator and maintainer