All Articles

Adding SSE and WebSockets to BoxExpress (and Everything That Broke Along the Way)

BoxExpress adds server-sent events and full WebSocket support for pushing live updates to clients — dashboards, progress bars, chat, notifications. Built from scratch, since BoxExpress skips BoxLang's built-in web layer. Along the way: a concurrency bug in broadcast connections, a header-injection-style hole in SSE metadata, a BoxLang update that broke page rendering, and a STOMP bug letting one client hijack another's subscription. All caught by trying to break it.

BoxExpress just picked up two ways to push live updates to a client instead of making it poll: server-sent events (SSE), a one-way stream of updates from server to client, and — landing right behind it — full two-way WebSocket connections, with a STOMP pub/sub layer built on top for anyone who wants destination-based messaging instead of hand-rolling connection bookkeeping. Live dashboards, progress bars for long-running jobs, log tailing, chat-style notifications — anywhere a client wants to know "what changed," or wants to talk back. Here's how both came together, including the parts that didn't go how I expected.

The obvious question first: doesn't BoxLang already do this?

Yes — BoxLang ships its own SSE support. Nice API. So the first thing I tried, before writing a single line of new code, was calling it directly from a BoxExpress route.

It didn't exist as far as BoxExpress was concerned. That built-in SSE support lives in the part of BoxLang that assumes you're running inside a traditional web server setup — and BoxExpress doesn't use that. That's the entire reason BoxExpress exists: BoxLang's bare runtime has no concept of HTTP at all, so BoxExpress builds its own directly on top of a lower-level web server library.

So: not a wrapper. A from-scratch implementation. But a good one already existed to copy the shape of, so I did — same basic API (send an event, add a comment, close the connection, check if it's closed), different guts underneath.

The real architectural wrinkle

Every other response type in BoxExpress — sending JSON, a file, plain text — works the same way: build the whole response, hand it off once, done.

SSE breaks that model on purpose. The connection needs to stay open and accept writes indefinitely, as events happen, rather than being sent all at once. That meant carving out a genuinely different response mode, one that opens the connection and keeps it open rather than finishing it immediately.

The fix ended up smaller than expected, because BoxExpress already had a way to reach past its own abstractions and talk to the underlying connection directly when needed. res.sse() uses that, sets the right headers for a streaming connection, and hands the open connection to your code to write to as events happen.

One header is worth calling out: it tells any reverse proxy sitting in front of the app (like nginx or Cloudflare) not to buffer the stream before forwarding it — which would defeat the entire point of "real-time." BoxLang's own built-in version sets the same header, which was a reassuring sign it's the right call.

Usage on the app side looks like this: open a stream, and as long as it's still open, keep sending updates — say, an active-user count every second — until the client disconnects.

Disconnect detection, almost for free

BoxLang's own built-in SSE support has to think carefully about client disconnects, because of how it manages the pool of resources it uses to handle requests — tying one up indefinitely for a long-lived stream is a real cost there.

BoxExpress doesn't have that problem, thanks to some groundwork laid earlier in this project: every request already gets its own lightweight, disposable thread of execution, so holding one open for the life of a stream is cheap.

Detecting a disconnect turned out to be simple, too — trying to write to a connection the client has closed just fails immediately, which is easy to catch and treat as "we're done here."

I verified this for real rather than just assuming it: opened a raw connection, confirmed the stream had actually started, then killed the connection while the server was still mid-stream. It noticed within one write attempt and shut down cleanly — no polling, no timeout, no orphaned thread.

Two problems I only found by trying to break it

Concurrency. The obvious real use for res.sse() is a broadcast pattern — one route opens a connection and holds it, a different route pushes updates into it later (think: a chat room, or "notify everyone when a new order comes in").

That pattern is powerful and also risky: nothing stops two different parts of the app from trying to write to the same open connection at the same time, and if that happens uncoordinated, you get corrupted, interleaved output. I added a lock around each connection so only one write can happen at a time per connection — not one big lock for the whole app, just one per open stream. Then I actually proved it under load rather than trusting the reasoning: five separate writers hammering the same connection at once, repeatedly. Zero corrupted output, every time.

A real security bug. Running a security review over the whole feature turned up something the concurrency fix didn't touch. SSE messages have a few parts — the actual data, plus optional metadata like an event name or an ID. The data part was being handled carefully, with proper line-by-line safety. The metadata parts weren't.

That's the same shape of bug as smuggling extra content into an HTTP header. If an app ever builds that metadata from something a user or client provided — a channel name, a correlation ID, both completely reasonable things to want — a malicious client could sneak in a line break and inject a fake message into the stream that every other connected client would see as legitimate. A forged notification, indistinguishable from a real one, landing in someone else's browser.

I confirmed this was a real, exploitable bug — not just a theoretical concern — before calling it fixed, then stripped the offending characters from that metadata before it ever reaches the stream. Same malicious input now just shows up as harmless text instead of forging new messages.

Not an SSE bug, but it landed in the same stretch of work and is worth knowing about if you're running anything on a recent BoxLang release: rendering views broke completely, with a generic "file not found" error, even though the file was right where it should be.

The cause: a recent BoxLang security hardening change made the engine more suspicious of certain kinds of file paths by default — a reasonable, deliberate change in isolation, but one that didn't know BoxExpress's own render logic had already independently verified the path was safe. The engine just saw an unfamiliar shape of path and, correctly by its own rules, no longer trusted it automatically.

The fix lives entirely on the BoxExpress side: register the views folder properly with the engine up front, once, when the app starts — rather than relying on the engine to trust an unregistered path at render time.

That fix shipped, and rendering was still broken — a different error this time, but the same root cause. The registration was happening correctly, but a caching layer elsewhere in BoxLang had already grabbed and locked in its own copy of the configuration before the views folder got registered, and never went back to check for updates. The registration existed; the part of the code doing the actual rendering just never knew about it. Fixed with one more step: explicitly telling that cache to refresh right after registering the views folder. Verified end-to-end against a real running server afterward — every page went from broken back to working.

Two fixes, two different layers of the same underlying problem: one about what the engine is willing to trust by default, one about a cache that quietly went stale.

WebSockets followed right behind

SSE covers one direction — server to client. Plenty of real-time features need the other direction too: a client sending messages, not just receiving them. app.ws(path, callback) adds full WebSocket support as its own thing, separate from regular routes, since a WebSocket connection doesn't really fit the usual request/response shape.

The part that took real engineering to make work

Getting WebSockets working meant reaching into a lower-level piece of the underlying web server library that wasn't designed to be easily extended from BoxLang directly — the usual ways BoxExpress bridges out to that kind of code didn't apply here, and I confirmed that the hard way rather than assuming it up front.

The answer was a small, separately compiled helper written directly in the underlying platform's native language, whose only job is to do that one awkward piece of low-level plumbing and then hand off cleanly to a much simpler, ordinary interface that BoxLang code can hook into normally — same pattern as everything else in the project.

The same lesson from SSE's disconnect handling showed up here again, in a nastier form: the first version of this handed incoming messages straight to BoxLang code from the web server's own internal connection-handling thread — and if that code did anything slow, it didn't fail loudly, it intermittently killed the connection instead. Flaky, not consistent, which made it worse to track down. The fix: every incoming message now gets handed off to its own disposable thread before any app code touches it, so nothing app-level can ever block the connection machinery itself. Verified with repeated clean runs after several failing runs beforehand.

The WebSocket API: same shape as SSE, on purpose

Send a message, receive a message, close the connection, check if it's closed — deliberately mirroring the SSE API, for the same reason the SSE API mirrors BoxLang's own: a proven shape, no reason to invent a new one. Sending is protected by the same kind of per-connection lock as SSE, so the exact broadcast pattern from earlier — one route holds a connection open, a different route pushes into it later — is safe here too, for the same reason, with no separate proof needed.

STOMP, and a second real security bug

Plain WebSocket routes give you connections and messages; most chat, notification, and live-update use cases actually want destinations — subscribe to a topic, publish to it, let the system route messages to the right subscribers, rather than manually tracking who's listening to what. A STOMP layer on top of the WebSocket support adds exactly that, plus hooks for authentication and authorization, and a way for ordinary server-side code (like "an order was just placed") to trigger a broadcast.

Running a security review over this the same way the SSE work got one turned up a second real bug, roughly as serious as the first. Subscriptions were being tracked using an ID the client supplies — and that ID isn't meant to be secret or unique across the whole system, just unique per connection. Some client libraries even assign these predictably. That meant one client could accidentally (or deliberately) reuse another client's subscription ID and silently take over their subscription — messages meant for the original subscriber would go only to the new one instead, with no error to either side, and unsubscribing could even delete the wrong person's subscription along with it.

Fixed by tracking subscriptions per-connection rather than by that client-supplied ID alone. Verified live before writing a permanent test for it: two separate connections deliberately using the identical subscription ID now both get their own messages independently, and one unsubscribing no longer affects the other.

Where it ended up

  • res.sse() — a genuinely new response mode for long-lived, streaming connections
  • Same basic API as BoxLang's own SSE support, built from scratch to work with BoxExpress's architecture
  • Safe for the "one route opens it, another route broadcasts into it" pattern, verified under real concurrent load
  • Closed a real header-injection-style security hole in how event metadata was handled
  • Fixed page rendering on a recent BoxLang release — a registration issue plus a stale-cache issue, unrelated to SSE but part of the same stretch of work
  • Full WebSocket support, built on a small custom bridge to the underlying web server library, with every incoming message handled off the connection's own critical thread
  • STOMP-based pub/sub on top of WebSockets, with authentication/authorization hooks and server-triggered broadcasts
  • Closed a second real security bug: subscriptions are now scoped per-connection instead of trusting a client-supplied ID that was never meant to be unique

None of the interesting parts here were guessable from the feature list alone — the missing built-in, the threading model turning out to be an advantage instead of a liability, a broadcast pattern's hidden concurrency bug, a security hole hiding in a feature that looks nothing like the usual header-handling risks, a cache that quietly outlived the fix meant to invalidate it, a piece of the underlying library that took real work to reach at all, and a subscription system with the exact same "looks fine until two clients collide" shape as the first bug. All of it only showed up by trying to actually break the thing before calling it done.

No comments yet — be the first.