Project
boxlang-stomp-chat
A Slack-like multi-channel chat app built on BoxLang (via CommandBox) using SocketBox's STOMP broker for realtime messaging, with a Vue 3 frontend that needs no build step. No auth — pick a display name and go.
Channels & DMs
Create channels and join/leave at runtime; direct messages, presence lists, join/leave system messages, and typing indicators, all over one STOMP broker.
No build step
Vue 3 loaded via plain <script> tags, with a per-page-load asset fingerprint and generated import map instead of a bundler.
Server-enforced auth
A single destination table, enforced in authorize(), decides what any connection can SEND or SUBSCRIBE to — including DM privacy.
One-property theming
Every color is a single light-dark() custom property — no duplicated prefers-color-scheme override block.
Running it
bashbox install && box server start
Then open http://localhost:8080. Append ?debug to the URL for STOMP frame logging in the console, plus window.__chat (reactive state) and window.__chatClient (the STOMP client) for poking at things.
Features
- Create channels and join/leave them;
Generalis the default and can't be left - Message history (last 100 per channel, in memory — cleared on server restart)
- Per-channel presence list, showing who is in the channel you're viewing
- Direct messages — click anyone in the member list to start one
- Join/leave system messages
- Typing indicators
- Per-channel unread badges
How it works
The whole server side is WebSocket.bx, which extends modules.socketbox.models.WebSocketSTOMP. server.json points CommandBox's WebSocket listener at it, exposing the broker at /ws.
State lives in models/ChatStore.bx, a single instance cached in the application scope and shared by the WebSocket listener and HTTP requests.
Asset versioning
models/Assets.bx fingerprints every file under assets/ (path, size, mtime) and hands index.bxm a ?v=<hash> for the stylesheet and entry script. Because a JS module's own relative imports (./useChat.js) are static text nobody can rewrite, the generated import map carries one entry per local module mapping /assets/x.js to /assets/x.js?v=<hash> — import map keys match the resolved URL, so nested imports get the same query string and every importer still lands on a single module instance. index.bxm itself is served Cache-Control: no-cache, since it's the only thing that knows the current fingerprint.
The scan runs per page load, so editing a file busts it immediately; no restart needed.
Direct messages
A conversation between two people is just a channel with a reserved slug: dm-<hexA>--<hexB>, the two lowercased usernames hex-encoded and sorted. Both ends compute it independently, so no lookup is needed, and history, typing indicators, unread counts and presence all come along for free.
Hex rather than the plain names because a username may contain spaces and dots — any lossier flattening could map two different people onto the same conversation, which would leak messages between them. WebSocket.bx and assets/useChat.js each carry the same encoder; they have to agree.
Since the slug is derivable from two public usernames, the only thing keeping a conversation private is the guard at the top of authorize(), which rejects any chat., typing. or presence. destination whose slug is a DM the connection isn't part of. For the same reason DM history is not served by api.bxm — that endpoint has no identity behind it — and arrives over the socket instead, pushed to private.<session> when a participant subscribes.
A DM has nowhere to appear in the recipient's sidebar until they subscribe to it, so onSend also notifies every live connection of both participants. It re-sends on every message; the client only treats it as unread for a conversation it wasn't already in, which is what stops a message being counted twice.
Closing a conversation is local only. The history survives on the server, so the next message reopens it where you left off.
Theming
Every colour is one light-dark( light, dark ) custom property in :root, so there is a single token list rather than a base block plus a duplicated prefers-color-scheme override. light-dark() resolves against the element's used color-scheme, which means a manual override only has to change that one property:
css:root[data-theme='dark'] { color-scheme: dark; }
assets/useTheme.js cycles auto → light → dark and stores the choice in localStorage under stomp.theme; auto (the default) stores nothing and lets prefers-color-scheme decide. An inline script in index.bxm reads the same key before first paint so an explicit choice doesn't flash the wrong theme — the key is deliberately duplicated there, since nothing behind the import map has loaded that early.
Setting color-scheme also fixes the UA-rendered parts (scrollbars, the textarea, focus rings) that no amount of custom properties would reach.
Baseline: Chrome 123, Safari 17.5, Firefox 120.
Layout
Three columns above 1024px. Between 1024 and 760 the presence panel becomes a right-hand drawer behind a member-count button in the header; below 760 the channel sidebar becomes a left-hand drawer behind a hamburger that carries the combined unread count for the channels you aren't looking at. Drawers close on scrim tap, on their own ×, and on any channel switch.
Destinations
Everything runs on SocketBox's default direct exchange, which routes a SEND to whatever subscriptions exist at that exact destination. That's why channels can be created at runtime with no configuration.
| Destination | SEND | SUBSCRIBE | Purpose |
|---|---|---|---|
chat.<slug> | yes | yes | Chat and system messages for one channel |
typing.<slug> | yes | yes | Transient typing pings, never stored |
channels | no | yes | Channel directory broadcasts |
channels.create | yes | no | Request creation of a new channel |
presence.<slug> | no | yes | Who is currently in one channel |
private.<session> | no | yes | Targeted replies to one connection |
Direct messages reuse the chat., typing. and presence. rows above with a dm- slug rather than adding destinations of their own.
authorize() in WebSocket.bx enforces that table. A connection may only read its own private. destination, and channels/presence.* are broadcast-only so a client can't forge a directory or presence update.
Channel membership is derived from the live chat.<slug> subscriptions (getChannelMembers()) rather than tracked in a parallel structure, so it can't drift out of sync — a dropped connection disappears from presence for free, and a user with two tabs open still appears once.
The username arrives in the STOMP CONNECT frame's login header. onSend() overwrites the message body server-side, stamping from from the connection record, so a client can't spoof authorship.
api.bxm
Two read-only JSON actions used to hydrate a freshly loaded page: ?action=channels and ?action=history&channel=<slug>. Everything after load comes over STOMP.
Notes and gotchas
Things worth knowing if you extend this:
MessageParser.readBody() loops content-length times over charAt(), but content-length counts bytes and charAt() walks UTF-16 chars — so a body holding an em dash, an accent, CJK or an emoji overruns the frame and is discarded. Inbound only; serialize() sets the outgoing header in real bytes, so it can look like a client bug. Not a charset setting — the string is already correct UTF-8, which is why you get a length mismatch and not mojibake. Patched locally in modules/socketbox/models/STOMP/MessageParser.cfc by slicing the byte window and decoding it back; modules/ is gitignored, so box install will undo that and it needs to go upstream.
private.<session> and the DM's chat. subscribe went out in the same burst, and the backlog was sent into a destination nobody was listening to yet. The client now puts a receipt on the private subscribe and waits for the broker's RECEIPT before subscribing to any DM. The presence replay in onSubscribe() is a workaround for the same underlying thing.
sendError()closes the channel. Per the STOMP spec, SocketBox terminates the connection after an ERROR frame. Recoverable errors (a bad channel name) are sent to the client'sprivate.<session>destination instead.- The ERROR frame's
messageheader is hardcoded to "Invalid login". Your real reason fromauthenticate()lands in the frame body, which is why the client prefersframe.body. onClose()andonSTOMPDisconnect()delete the connection record before returning, so capture the login and subscriptions before callingsuper.- UNSUBSCRIBE frames carry only an
id, no destination. It has to be resolved from the subscription registry beforesuperremoves it. - Presence needs a replay on subscribe. A client subscribes to
chat.<slug>andpresence.<slug>as two separate frames, so the broadcast triggered by the first can land before the second exists.onSubscribe()re-sends a snapshot when someone subscribes to apresence.destination. - Never pass
debug: undefinedto the stompjsClient. It configures itself withObject.assign( this, conf ), so an explicitundefinedoverwrites the class's default no-opdebugmethod — the first internal call then throws, the socket is never created, and reconnection never fires. Pass() => {}instead. - A SEND can arrive before the connection is registered.
getConnectionDetails()comes back empty for a frame that races CONNECT, which would leave the client's raw body un-stamped.onSend()drops chat/typing frames with no login rather than forwarding them. - A model cached in the application scope keeps its old class. Editing
ChatStore.bxwhile the server is running gets youMethod 'x' not foundfrom the instance already sitting inapplication.chatStore. Restart after changing a cached model; only the unversioned frontend assets pick up edits live. - Media queries add no specificity. The responsive block lives at the bottom of
app.csson purpose — put a@mediaoverride before the base rule it undoes and the base rule wins. - BoxLang has no
chr()orheader(). Usechar()and the<bx:header>tag — calling it in function form inside a<bx:script>block in a.bxmbreaks the template lexer outright. urlis a scope. A method namedurl()on a component resolves to the URL scope struct instead, henceAssets.versioned()..bxfiles run in BoxLang mode, so it'sjsonSerialize(), notserializeJSON(). SocketBox's own.cfcfiles run in CF-compat mode where both exist — don't copy BIF names across the boundary.- Cluster mode is off. SocketBox supports it if you ever run more than one node.