STOMP
A STOMP 1.0/1.1/1.2 pub/sub broker built on top of app.ws(), via boxExpressStomp().
Setting up a broker
boxExpressStomp() layers a full STOMP 1.0/1.1/1.2 pub/sub broker (destination-based SUBSCRIBE/SEND) on top of app.ws() — the shape most chat/notification/live-update use cases actually want, rather than tracking connections and destinations by hand:
bxsstomp = boxExpressStomp()
app.ws( "/stomp", stomp.handler() )
app.post( "/orders", ( req, res ) => {
stomp.send( "/topic/orders", { orderId: newOrder.id } ) // server-side publish
} )
Or via the underlying class directly, for the full options struct below:
bxsstomp = new bxModules.boxexpress.models.middleware.Stomp( {
authenticate: ( login, passcode, host, connection, connectionMetadata ) => {
connectionMetadata.role = login == "admin" ? "admin" : "guest" // read back in authorize() below
return login == "demo" && passcode == "demo"
},
authorize: ( login, destination, access, connection, connectionMetadata ) => {
return destination != "/topic/admin-only" || connectionMetadata.role == "admin"
},
heartbeatMs: 10000
} )
app.ws( "/stomp", stomp.handler() )
Authentication and authorization
authenticate(login, passcode, host, connection, connectionMetadata) gates CONNECT — return false and the client gets an ERROR frame and the connection closes. authorize(login, destination, access, connection, connectionMetadata) gates each SUBSCRIBE/SEND (access is "subscribe" or "publish") — return false and that one frame gets an ERROR instead of taking effect (the connection stays open). Both are optional — omit either to allow everything. connection.cookies/connection.headers (from the WebSocket handshake, before any STOMP frame arrives) are available on connection in both, alongside STOMP's own login/passcode frame headers — read a session cookie here for cookie-based auth instead of requiring clients to send credentials in the CONNECT frame.
connectionMetadata is a plain struct, empty until authenticate() populates it — mutations persist for the life of the connection (the same struct instance every later authorize() call for that connection receives) and are echoed back to the client as extra CONNECTED headers. Attach whatever per-connection state your app needs once at connect time (a user id, a tenant, roles) instead of re-deriving it on every frame.
Publishing from server code
stomp.send(destination, data, headers) is the server-side publish path for app code outside any connection's own message handling — data is JSON-serialized unless it's already a simple value.
An authenticate, authorize or onSend callback that throws doesn't leave the client with no reply: the exception is logged server-side and the client gets a generic ERROR (authenticate and authorize fail closed).
Message headers
Application headers on a client SEND (reply-to, correlation-id, anything custom) are forwarded on the MESSAGE frame. The headers the protocol or broker owns (destination, transaction, receipt, content-length, message-id, subscription, ack) are not copied, so a client can't forge them. Frames may use \n or \r\n line endings, and content-length is an octet count, so multi-byte bodies round-trip.
Header values are escaped per the STOMP spec (backslash, newline, colon), not just stripped, so a destination or login value built from request-derived input can't break a frame's structure.
Protocol versions
CONNECT's accept-version is negotiated and the connection is served in the highest version both sides offer (no header means 1.0). Per version:
- 1.2 escapes
\\, newline, CR and:in header values; 1.1 the same minus CR; 1.0 not at all.CONNECT/CONNECTEDframes are never escaped. - 1.0/1.1 clients acknowledge by
message-idinstead of 1.2's separateackheader. - 1.0 clients may
SUBSCRIBEwithout anidandUNSUBSCRIBEbydestination. - Heart-beating only exists from 1.1.
Only MESSAGE frames follow the connection's version for escaping; ERROR/RECEIPT use 1.2's. An unsupported version gets an ERROR listing 1.0,1.1,1.2. A second CONNECT on an already-connected socket is an ERROR. A subscription id already in use on the same connection is rejected with an ERROR (ids must be unique per connection, per spec).
Limits
Each bounds what clients can make the broker hold or wait on; pass 0 to disable one. The first seven are per connection, the last two are per node. Exceeding a cap gets an ERROR and leaves the connection open, except where noted:
| Option | Default | Effect |
|---|---|---|
connectTimeoutMs | 10000 | A socket that hasn't completed CONNECT in time is closed |
maxFramesPerSecond | 0 (off) | More inbound frames per second closes the connection — the right value is app-specific, so it's opt-in |
maxSubscriptions | 100 | Subscriptions per connection |
maxTransactions | 16 | Open transactions per connection |
maxTransactionFrames | 1000 | Frames buffered per transaction |
maxPendingAcks | 1000 | Unacknowledged messages per connection — past it the connection is closed, since a client that never acks would otherwise grow this forever |
maxDestinationLength | 255 | Characters in a SUBSCRIBE/SEND destination |
maxConnections | 10000 | Connections per node that have completed CONNECT; further CONNECTs get an ERROR and are closed, before authenticate() runs. Not atomic, so a burst of simultaneous CONNECTs can overshoot slightly |
maxSubscribersPerDestination | 10000 | Subscriptions on one destination (per node); the SUBSCRIBE gets an ERROR and the connection stays open |
A destination's registry entry is dropped once its last subscriber leaves.
Lifecycle hooks
All optional observers — a hook that throws is logged and ignored, and can't affect the connection:
bxsstomp = boxExpressStomp( {
onConnect: ( login, connection, connectionMetadata, connectionId ) => { /* ... */ },
onDisconnect: ( login, connectionMetadata, connectionId ) => { /* ... */ },
onSubscribe: ( login, destination, subscriptionId, connection, connectionMetadata ) => { /* ... */ },
onUnsubscribe: ( login, destination, subscriptionId, connectionMetadata ) => { /* ... */ }
} )
onDisconnect fires for every connection that completed CONNECT, however it ended (client close, dropped socket, server close, a failed write); just before it, onUnsubscribe fires for each subscription the connection still held. stomp.disconnect( connectionId, message ) closes a connection from the server side — with an ERROR frame carrying message first, if given — and returns false if that connection isn't on this node.
Targeted delivery
stomp.sendToUser( login, destination, data, headers ) publishes to destination (through the exchanges, like any publish) but only to subscriptions whose connection authenticated with that login — every connection the user has open, no one else's. stomp.sendToConnection( connectionId, destination, data, headers ) does the same for one connection (a key of getConnections()). Both return how many subscriptions on this node received it, don't invoke server-side listeners, and, with options.cluster, are relayed so the recipient is reached on whichever node holds their connection.
Presence
stomp.getClusterPresence() returns { logins, byNode } — the distinct logins connected across the cluster and each node's own list. Nodes publish their list on the cluster heartbeat, so a peer's entry can lag by up to one heartbeat interval: fine for "who's online," not for anything that must be exact. Without clustering it's just this node. getConnections() remains local to the node.
Delivery
A publish writes to its subscribers concurrently, so unresponsive subscribers cost one slow write in total rather than one each in sequence; a subscriber whose connection turns out to be closed is dropped from the registry immediately. Heartbeats run on virtual threads, not one platform thread per connection.
Receipts
Any client frame carrying a receipt header gets a RECEIPT frame back once it's been processed — not just DISCONNECT. If processing that frame itself failed, the ERROR frame carries the matching receipt-id instead.
Acknowledgment (ACK/NACK)
SUBSCRIBE's ack header — "auto" (default), "client", or "client-individual" — controls whether the client must explicitly acknowledge each MESSAGE. Under "client"/"client-individual", every MESSAGE carries an ack header the client references in a later ACK/NACK frame's id header. "client" mode is cumulative — acking one message also acks every earlier unacked message on that subscription, per spec. An unknown/already-acked id gets an ERROR. NACK is bookkeeping-identical to ACK here: this broker delivers straight to open connections with no message queue to redeliver into, so the positive/negative distinction real brokers use to drive redelivery has nothing to act on.
Transactions
BEGIN transaction:tx-1 / COMMIT / ABORT scope a set of SEND/ACK/NACK frames (via their own transaction header) so they take effect together on COMMIT or are discarded on ABORT — scoped to one connection, per spec. A RECEIPT for a buffered frame is sent when the frame is accepted into the transaction, not deferred to COMMIT (STOMP's receipt semantics are "frame accepted," not "transaction contents individually confirmed").
Heartbeats
Negotiated per spec section 6: send a heart-beat:cx,cy header on CONNECT (cx = fastest interval you can send at, 0 if you can't; cy = interval you want to receive at, 0 if you don't want any) and the server computes both directions independently — heartbeatMs (if configured) is this server's own capability in both directions, max()'d against what the client asked for. A client that sends no heart-beat header defaults to "0,0" (no heartbeats either way) even if the server is configured for them. When an incoming interval is negotiated, the server also monitors it: if nothing arrives (a real frame or a bare heartbeat) within roughly 2x that interval, it sends an ERROR and closes the connection.
Sec-WebSocket-Protocol negotiation (v12.stomp/v11.stomp/v10.stomp) is handled automatically by app.ws()'s handshake, for client libraries (stomp.js and others) that send and expect it echoed — harmless to every other app.ws() route, since a client that never sends that header skips negotiation entirely.
Exchanges & bindings
AMQP-style destination routing — not part of core STOMP itself, but a proven pattern real STOMP brokers (including Ortus's own SocketBox, whose exchange design this mirrors) layer on top. A destination prefixed "exchangeName/rest" routes through the exchange registered under that name; anything else (no "/", or a prefix that isn't a configured exchange name) routes through the always-present direct exchange with the whole destination string as the routing key — so every destination that never uses the "exchangeName/..." convention behaves exactly as it did before exchanges existed:
bxsstomp = new bxModules.boxexpress.models.middleware.Stomp( {
exchanges: {
direct: { bindings: { "orders.created": "notifications" } },
topic: { bindings: { "chat.room.*": "chatFanout", "sys.##": "sysAll" } },
fanout: { bindings: { "myFanout": [ "dest1", "dest2" ] } },
distribution: { type: "roundrobin", bindings: { "myDist": [ "destA", "destB" ] } }
}
} )
direct(always configured even if omitted) — routes a destination throughbindingsif one matches, or passes it through unchanged otherwise.topic— wildcard matching over dot-separated segments:*matches exactly one segment,#matches zero or more remaining segments (the standard AMQP convention — note#needs to be written as##inside a BoxLang string literal, since a bare#starts string interpolation;"sys.##"is the one-character patternsys.#). Every binding whose pattern matches gets routed to, not just the first — a topic message can reach multiple destinations.fanout— one binding name routes to every destination in its array.distribution— the opposite of fanout: one binding name routes to exactly one destination from its array, chosen by"random"(default) or"roundrobin".- A custom exchange — any name other than the four built-ins, with a
classpointing to a dotted class path implementingroute(destination, body, headers)(returning an array of physical destinations to deliver to) — duck-typed, no formal interface.
stomp.getExchanges() returns the configured exchange instances, for debugging.
Connection registry
stomp.getConnections() returns every currently-CONNECTed client, keyed by an internal connection id, each entry { connection, login, connectionMetadata } — automatically populated on CONNECT and cleared on disconnect. stomp.getConnectionDetails(connectionId) reads one entry. stomp.getSubscriptions() and stomp.getConfig() round out the introspection surface — these return live internal structs, meant for debugging/ops, not a stable data API to build app logic against.
Server-side listeners
React to messages routed to a destination from plain server code, without opening a WebSocket connection:
bxsstomp = new bxModules.boxexpress.models.middleware.Stomp( {
listeners: {
"audit-log": ( message ) => {
logMessage( message.getBody() ) // auto-JSON-deserialized if content-type is application/json
// message.getConnection() is the originating WebSocketConnection,
// or null if this arrived via stomp.send() from server code
}
}
} )
A listener's own exception is caught and logged, not allowed to break delivery to real subscribers or other listeners on the same destination. The message object exposes getCommand()/getBody()/getBodyRaw()/getHeaders()/getHeader()/getConnection().
What's still not built
This is a real, if intentionally smaller, first version — not the whole STOMP ecosystem some brokers support. Deliberately not built: binary bodies — inbound frames may arrive as binary WebSocket messages (decoded as UTF-8), but outbound frames are always text, and content-length is honored on read/write so a body containing an embedded NUL byte round-trips correctly while genuinely non-UTF-8 octets can't. Also not built: message redelivery (NACK and unacknowledged messages just clear bookkeeping — there is no queue to redeliver into). Destination matching (both the subscriber registry and exchange bindings) is exact-string only — /topic/a and /topic/a/ are different destinations.
Clustering
To relay a publish to every other process in a cluster instead of just this one's local subscribers, pass boxExpressStomp({ cluster: app.getClusterManager() }) — see Cluster Support.