WebSockets
A WebSocket route via app.ws(path, callback) — a separate route table from app.get/app.post/etc. and from Router, since a WebSocket connection has no req/res/next() chain to run through.
Registering a route
bxsapp.ws( "/chat", ( connection ) => {
connection.onMessage( ( text ) => connection.send( "echo:" & text ) )
connection.onClose( ( code, reason ) => println( "disconnected: " & code ) )
} )
callback receives a WebSocketConnection the moment a client connects. Register onMessage()/onClose() on it synchronously, before returning from the callback — messages can start arriving as soon as the handshake completes, so registering afterward can race a fast client.
Path matching is exact only — no :params, no mount paths. An upgrade request to a path with no registered app.ws() route falls through to the normal HTTP chain unchanged; an app that registers no app.ws() routes at all pays nothing for the upgrade check.
WebSocketConnection
onMessage(callback)—callback(text), one call per text frame received.onBinary(callback)—callback(bytes)(a Javabyte[]) for each binary message. Binary messages are dropped if none is registered; outbound messages are always text.onClose(callback)—callback(code, reason), fires exactly once whether the client disconnected, the connection dropped, orclose()was called locally (a locally-initiated close did not fire it before 0.2.15). A write that fails or times out also closes the connection and fires it, so your cleanup runs instead of leaving a zombie.send(data)— writes one text frame. Complex data is JSON-serialized unless it's already a simple value, matchingres.sse()'s emitter convention. No-ops once the connection is closed rather than throwing, so broadcasting to many connections doesn't blow up the whole loop on one stale one. The underlying write is bounded by a hard 5s timeout, so a peer that vanishes mid-write can't hang the caller.close()/isClosed().
There's no onOpen/onError callback — the connection is handed to the app.ws() callback at open time instead, which serves the same purpose, and errors surface as a connection close rather than a separate event.
Origin check
A browser sends cookies with the WebSocket handshake whichever site the page is on, so without a check any website could open an authenticated socket as its visitor (cross-site WebSocket hijacking). app.ws() routes accept only same-origin pages by default: the page's Origin must match the Host it connected to (or X-Forwarded-Host from a trusted proxy), and anything else gets a 403 before the upgrade. Clients that send no Origin — servers, CLI tools, the cluster relay — aren't browsers and are let through.
Change it per route, or for every route:
bxsapp.ws( "/chat", handler, { origins: [ "https://app.example.com" ] } )
app.set( "wsOrigins", "*" ) // the old allow-everything behavior
app.ws() used to accept any origin. A page served from a different origin than the socket now needs to be listed in origins or wsOrigins.Message size limit
Incoming messages are capped at 1 MB by default. A larger one is refused and the connection closed with a 1009 (message too big), so an unauthenticated client can't make the server buffer an unbounded message — Undertow's own default is unlimited (CVE-2026-81624, no upstream fix yet). This applies to every app.ws() route, including STOMP and the cluster relay. Change the limit with app.set( "wsMaxMessageSize", bytes ) before listen():
bxsapp.set( "wsMaxMessageSize", 65536 ) // 64 KB
Idle timeout
app.set( "wsIdleTimeoutMs", ms ) (default 0, off) drops a connection that has sent nothing — no message, no ping, no pong — for that long, and fires its onClose. The server pings every third of that period, and a healthy client (every browser, automatically) answers, so a quiet but alive connection is never dropped; one whose peer vanished, or that ignores pings, is. It's opt-in because the right value depends on your clients; 60000 is a reasonable start. Together with the message size limit above, this bounds what an unauthenticated client can make the server hold (CVE-2026-81624).
Broadcasting to multiple clients
connection is a plain object, the same way res.sse()'s emitter is — nothing ties it to being used only inside the callback it was handed to. Stash it somewhere shared and another route can call .send() on it directly:
bxsconnections = {} // shared across requests — id -> connection
app.ws( "/chat", ( connection ) => {
var id = createUUID()
connections[ id ] = connection
connection.onClose( ( code, reason ) => structDelete( connections, id ) )
} )
app.post( "/announce", ( req, res ) => {
for ( var id in connections ) {
connections[ id ].send( req.body.message )
}
res.json( { sentTo: structCount( connections ) } )
} )
send() is safe to call this way from more than one thread at once — each connection funnels through its own per-connection lock, so two writers can't interleave on the shared channel, the same reasoning and fix shape as res.sse()'s emitter thread-safety (see Server-Sent Events).
app.ws() connect callback and onMessage/onClose are all dispatched onto their own virtual thread, never called directly from the I/O thread — a blocking handler (including a synchronous send() in the connect callback) doesn't stall other connections. A handler that mutates shared state (a rooms/subscribers struct, like the broadcast pattern above) still needs its own locking around that mutation; nothing here serializes two connections' callbacks against each other.STOMP pub/sub
middleware/Stomp 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() )
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.
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.
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.
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.
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.
Scheduled background jobs
If what you actually need is periodic work rather than push messaging — a cleanup job, polling an external API — see app.schedule() instead; it doesn't touch WebSockets/STOMP at all.
What this isn't
There's no rooms/presence abstraction beyond STOMP destinations, no auth/middleware chain integration for a raw app.ws() route (auth only exists at the STOMP layer), and no :params or Router mounting for WebSocket paths — the simplest version that covers a real route table, extended if a real need for pattern-matched paths shows up.
A small worked example — multiple channels, join/leave presence, message history, all on raw app.ws() with no STOMP — is live at /games/chat.