All Articles

I built a Slack clone in BoxLang

Robert built a Slack clone on BoxLang with no bundler, no database, no auth. Channels are free because addresses are free. Presence isn't stored, it's derived. DMs are channels with computed names. Two bugs taught me more than the features: frames aren't ordered, and `content-length` counts bytes.

I wanted to see how far you could get building a realtime chat app without the usual scaffolding. No npm install on the frontend. No bundler. No auth service. No database. Just BoxLang running under CommandBox, SocketBox's STOMP broker doing the realtime work, and Vue 3 loaded straight from a CDN.

The answer is: further than you'd think. The whole server is one file plus a state object.

The code is at github.com/robertz/boxlang-stomp-chat. Two commands to run it:

git clone https://github.com/robertz/boxlang-stomp-chat.git && cd boxlang-stomp-chat && box install && box server start

That pulls SocketBox down as a CommandBox dependency, starts BoxLang on port 8080, and opens it. There's no frontend install step because there's no frontend toolchain — the JavaScript in the repo is the JavaScript the browser runs. Append ?debug to the URL to get STOMP frame logging in the console.

Here's how it works, in plain terms.

The messaging layer is a post office, not a program

The hard part of chat is normally the plumbing — who's connected, who should receive what, how a message gets from one browser to five others. SocketBox gives you a STOMP broker, and STOMP is basically a post office protocol. Clients say "I want mail addressed to chat.general" (SUBSCRIBE) or "deliver this to chat.general" (SEND). The broker matches them up.

The thing that makes this so cheap is the direct exchange. It routes a message to whatever subscriptions currently exist at that exact address — nothing more. There's no registry of valid addresses. So when someone creates a channel called design-lab, there is no configuration step, no server restart, no allocation of anything. The first client to subscribe to chat.design-lab makes that address real, and the first SEND to it gets delivered. Channels are free because addresses are free.

I use six address patterns:

AddressPurpose
chat.<slug>messages in one channel
typing.<slug>transient "X is typing" pings, never stored
channelsthe channel directory
channels.createasking the server to make a new one
presence.<slug>who's currently in one channel
private.<session>replies meant for exactly one connection

Direct messages, which I added later, needed no new rows here — they reuse the first three with a computed name. More on that below.

A single authorize() function enforces that table. Some addresses are write-only from the client's side, some are read-only, and a connection can only read its own private. address. That last one matters — without it, anyone could subscribe to someone else's private line.

Presence is derived, not tracked

This is the design decision I'm happiest with.

The obvious way to build a presence list is to keep one: a user connects, you add them to a set; they disconnect, you remove them. That's also the way you end up with ghosts. Someone's laptop sleeps, the socket dies in a way that doesn't fire the clean disconnect path, and now there's a user in your sidebar who left forty minutes ago. Every chat app has had this bug.

So I don't keep a list. When the server needs to know who's in #general, it asks the broker who is currently subscribed to chat.general and reads the usernames off those connections. Presence is a view of the subscription table, not a copy of it.

That single choice makes several problems disappear at once. A dropped connection vanishes from presence automatically, because the subscription is gone — there's nothing to clean up. A user with two tabs open appears once, because you're deduplicating on the username rather than counting connections. And the list can't drift out of sync with reality, because it is reality.

The general principle: if you can compute something from state you already have, don't store it a second time. Two copies of a fact is two chances to disagree.

The server doesn't trust the client about anything

There's no login. You pick a display name and you're in. That sounds like it means there's no security model, but it doesn't — it means the security model is narrower, not absent.

Every message that arrives gets its author stamped server-side. The client sends the text; the server throws away whatever from field the client attached and writes in the username it has on record for that socket. You can send me any JSON you like and you still can't post as someone else. It's the difference between trusting the envelope and checking the postmark.

Names are unique among connected users, checked at connect time. That's not identity — nothing stops you claiming a name someone used yesterday — but it does stop two people being confusingly named rob in the same room right now, which is the actual problem in a no-auth app.

Direct messages are just channels with a computed name

Adding DMs later was the real test of whether the address-based design held up, and it did: a private conversation between two people is a channel whose name is derived from the two participants. Sort the two lowercased usernames, hex-encode each, join them — dm-<hexA>--<hexB>. Both browsers compute the same string independently, so there's no conversation registry, no ID to allocate, nothing to look up. History, typing indicators, unread badges and presence all work with no new code, because as far as the broker is concerned it's just another address.

The hex encoding is the part worth explaining, because the obvious alternative is a bug. I already had a slugify() for channel names that lowercases and collapses anything non-alphanumeric to a dash — reusing it would have been the natural move. But usernames here can contain spaces and dots, and slugify maps both "a b" and "a-b" to a-b. Two different people would collide onto one conversation and start receiving each other's private messages. Hex is uglier in a log line and completely collision-free, which is the correct trade when the failure mode is a privacy breach.

That does mean the address is computable by anyone who knows two usernames, and usernames are public. So the only thing standing between a stranger and your conversation is a guard at the top of authorize() that rejects any address whose slug is a DM you're not part of. I didn't want to take my own word for that, so I attacked it: connected as a third user, computed the slug for two other people, planted a message in it, then tried to subscribe. Both the message stream and the presence stream were refused, and so was the write. Worth doing — that's a five-line function carrying the entire privacy model.

The same reasoning killed a shortcut. Channel history is served over plain HTTP by a small JSON endpoint, and it would have been trivial to let DMs use it too. But that endpoint has no identity behind it — it's just a URL — so it would have handed anyone any conversation for the asking. DM history goes over the socket instead, pushed to your private address when you subscribe, where the connection has already been vetted.

One more wrinkle. A DM has nowhere to appear in the recipient's sidebar until they subscribe to it, so the first message to someone would just vanish. The server therefore also pokes every live connection belonging to both participants: this conversation exists, go look. It re-sends that on every message rather than tracking who's been told, which is simpler and idempotent — the client only counts it as unread for a conversation it wasn't already in, so nothing gets double-counted.

Two bugs worth writing down

Building this shook out two failures that are more interesting than the feature.

Frames from one socket aren't handled in order. DM history vanished on every reload, but worked if I subscribed manually a second later. The client opens its private address and then subscribes to each conversation, in that order, in one burst. The broker dispatches those frames across worker threads — so the server's history reply was being sent into a private address whose subscription hadn't been registered yet, and it went nowhere. The fix is to stop assuming order: the client tags its private subscription with a STOMP receipt and waits for the broker's acknowledgement before subscribing to anything that replies there. I'd already worked around this once for presence without recognising the general shape of it, which is the actual lesson — the second time you write the same workaround, you're looking at a rule, not an edge case.

Any message containing an em dash was silently dropped. This one was pure luck: my own test message happened to contain one. The broker library's frame parser loops content-length times reading one character per pass — but content-length counts bytes, and an em dash is three bytes to one character. So it overruns the end of the frame, throws, and discards the message with no feedback to the sender. Accented letters, CJK, emoji: all gone, silently.

The tempting fix is to reach for a charset setting somewhere, and that's wrong — the string is already correct UTF-8. If the encoding were wrong you'd see mojibake, not a length mismatch. It's a units bug, bytes versus characters, and no configuration reconciles those.

My first patch was also wrong in an instructive way. I accumulated characters and added up each one's byte length until it reached content-length — which fixes em dashes and still eats emoji, because a character in this sense is a UTF-16 code unit, and each half of an emoji's surrogate pair encodes as a 3-byte replacement character rather than the pair contributing its real 4 bytes. Slicing the exact byte window and decoding it back sidesteps code units entirely. I now test every one of these separately: 2-byte, 3-byte, 4-byte, and all of them mixed.

Killing stale assets without a build step

Once I had it working I hit the boring problem: I'd change some CSS, reload, and get the old file. The standard fix is a bundler that hashes filenames. I didn't have a bundler.

What I have instead is a small BoxLang class that walks the assets/ folder and hashes every file's path, size, and modification time into a short fingerprint. Change any file and the fingerprint changes. The page then requests /assets/app.css?v=<fingerprint>, so the browser treats an edited file as a different URL.

That works fine for the stylesheet. It does not work for the JavaScript, and the reason is interesting.

Modules import each other with lines like import { state } from './useChat.js'. That path is static text inside the file. I can add a query string to the <script> tag that loads the entry point, but I can't reach inside it and rewrite its imports — that's the bundler's job, and I don't have one. So the entry file busts, and everything it pulls in comes from cache. Worst case: you get the new app.js talking to the old useChat.js, which is more confusing than getting no update at all.

The escape hatch is the import map. It's a browser feature that lets you say "when anything asks for X, actually load Y," and — this is the part that saves it — the matching happens against the resolved URL, after ./useChat.js has been turned into /assets/useChat.js. So the server generates a map with one entry per local module, each pointing at itself-plus-fingerprint. Nested imports get busted, and because every importer resolves to the same final URL, they all share a single instance of the module. No accidental duplicates.

The HTML page itself is served no-cache, since it's the only thing that knows the current fingerprint. And the scan runs on every page load rather than at startup, so editing a file busts it immediately — no restart in the loop.

Two themes, one list of colours

Dark mode is usually implemented twice: you write your colours, then write a prefers-color-scheme block that overrides them. Two lists, and a permanent low-grade chore keeping them in sync.

CSS has a function now called light-dark() that takes both values at once. One line per colour, both themes covered. It picks a side based on the element's color-scheme property — which means the manual override, the thing that lets you force a theme regardless of your OS setting, is one declaration:

:root[data-theme='dark'] { color-scheme: dark; }

Setting color-scheme has a second benefit that custom properties can't buy you: it fixes the parts of the page the browser draws rather than you. Scrollbars, the textarea chrome, default form colours. Those ignore your variables entirely and follow color-scheme.

There's a small inline script in the page head that reads the saved preference before anything else loads, so an explicit choice doesn't produce a flash of the wrong theme. It duplicates one storage key from the theme module, which I'd normally object to, but nothing behind the import map has loaded that early — it's duplicated on purpose and commented as such.

While I was in there I actually measured the contrast ratios instead of eyeballing them, and found four failures against the WCAG AA threshold, two of which predated dark mode entirely. The instructive one: my first instinct was to make the accent colour lighter for dark mode. That's the reflex — dark theme, brighter accents. But the accent is nearly always a background with white text on it, so lightening it made white-on-accent worse, dropping it to 3.15:1. The fix was to darken it slightly and use the same value in both themes.

What I'd change

The history is in memory and dies with the server, which is fine for what this is and obviously not fine for anything real. It's a single node — SocketBox supports clustering, I just didn't need it. And light-dark() sets a browser floor of roughly Chrome 123 / Safari 17.5.

DMs inherit the weakest part of having no user table: a name is only unique among people currently connected, so if someone disconnects and a different person later takes their name, they inherit the old conversation. There's no fixing that without real identity, which is the one thing this app deliberately doesn't have. It's the point at which "no auth" stops being a simplification and starts being a limitation.

The em-dash fix also isn't really mine to keep. It's a patch to a vendored dependency, which a fresh install will quietly revert, so the durable version has to go upstream.

But the shape of it holds up. One file of server logic, a state object, five Vue components loaded as plain files, and no build tooling anywhere in the loop. Edit a file, reload, see the change.

The whole thing is twenty files and it's all on GitHub. If you only read two, read WebSocket.bx for the server — including the authorize() table and the presence-from-subscriptions trick — and models/Assets.bx for the cache-busting, which is about forty lines. The readme has a "notes and gotchas" section covering the things that cost me time, including one genuinely nasty stompjs bug where passing debug: undefined silently kills reconnection forever.