What's in BoxExpress Today
BoxExpress is an Express.js-style web framework for BoxLang, running on Undertow with a virtual thread per request — no servlet container needed. It covers routing, middleware, sessions, views, file serving, WebSockets, a full STOMP broker, and a scheduler. New: cross-process cluster support, with leader-elected scheduled jobs and STOMP pub/sub relayed across instances, backed by a durable shared cache. Real example apps: bxThreads (forum) and bx-graphql-demo.
If you've written a Node.js API with Express, BoxExpress will feel immediately
familiar — same app.get()/app.use() shape, same middleware chain, same
req/res conventions. The difference is what's underneath: BoxExpress runs
on BoxLang and Undertow, with a
virtual thread per request, as a standalone server with no servlet container
required.
app = boxExpress()
app.get( "/", ( req, res ) => {
res.send( "Hello World" )
} )
app.listen( 3000, ( port ) => {
println( "listening on #port#" )
} )
Install it from ForgeBox:
box install boxlang-express
Below is the full feature list as it stands today.
Routing & the App
app.get/post/put/patch/delete/head/all(path, ...handlers)— standard verb routing, with variadic middleware chains per route.app.use(handler)/app.use(path, handler)/app.use(path, router)— also accepts multiple handlers (or a mix of handlers and a mountableRouter) in one call.app.param(name, callback)— centralize a lookup/validation step for any route capturing that param name.app.route(path)— a chainable per-path builder (.get().post()...) so multiple methods on the same path don't repeat it.Router— mountable sub-apps (boxExpressRouter()), same routing methods asapp, mounted withapp.use("/api", apiRouter).- Path matching: case-insensitive literal segments,
:params, a trailing optional:name?, and a trailing*wildcard — both*and:name?throw at registration time if used anywhere but the final segment, rather than silently matching more or less than expected. app.get()routes automatically answerHEADtoo (body discarded, headers intact), matching Express.app.set(name, value)/app.getSetting(name),app.locals(merged into every render, alongsideres.locals).app.listen(port, callback, options)— blocks the calling thread by default (no manual keep-alive loop needed, unlike Node);{ block: false }opts back into non-blocking.options.backlogtunes the TCP accept queue (default1024).app.close()— stops the server, safely callable from a different thread than the one blocked inlisten().app.getConnectorStatistics()— live HTTP-layer metrics straight from Undertow (active connections, request counts, bytes sent/received, error count, processing time).- Colored access logging on stdout for every request, toggleable with
app.set("log", false). RespectsNO_COLOR. app.set("reloadOnChange", true)— dev-only auto-restart on.bx/.bxsfile changes, replaying the original process launch.
Request & Response
req:method,path,originalUrl,query,params,headers,get(name),cookies,ip,protocol,secure,hostname,body,rawExchange()(escape hatch to the underlying Undertow exchange).app.set("trust proxy", true)gatesX-Forwarded-For/-Proto/-Hosttrust; a named header list (app.set("trust proxy header", ...)) supports platforms (DigitalOcean, Cloudflare) where forwarded headers are append-only and otherwise spoofable.res:status(),set()/header(),type(),send(),json(),sendStatus(),redirect(),cookie(),sendBytes(),sendFile(),download(),end(),render(),dump()(BoxLang's rich HTMLdump()as a debug escape hatch),getStatusCode()/getBytesWritten(),onBeforeSend(callback).- Calling a second terminal response method throws — mirrors Express's "headers already sent."
- File serving:
sendFile()/download()/static serving all setETag/Last-Modified, honor conditionalIf-None-Match/If-Modified-Since(304s), supportoptions.maxAgeforCache-Control, and contain user-supplied paths to a configured root. - Range requests (RFC 7233):
206 Partial Contentfor aRangeheader,416for an unsatisfiable one,Accept-Ranges: byteson every file response — enables video/audio scrubbing and resumable downloads. - Server-Sent Events (
res.sse): long-lived one-way event streams.emitter.send(data, event, id),emitter.comment(text),emitter.close(),emitter.isClosed(). Thread-safe — an emitter can be stashed and pushed to from an entirely different route for broadcast/fan-out. Newline-stripped fields to prevent event injection.
Views
- Two rendering engines, picked by extension:
.bxm(BoxLang's native server-page format,<bx:output>) and.hbs(Handlebars, via bundledhandlebars.java). app.set("views", dir),app.set("view engine", "hbs")for a default extension.app.locals/res.localsmerge into everyrender()call (app.locals<res.locals< explicitdata, matching Express's precedence).- Path traversal protection: the resolved view is checked against the views directory's real path before either engine touches the file.
WebSockets & Real-Time
app.ws(path, callback)— WebSocket routes, separate from the HTTP method chain.connection.send(),onMessage(),onClose(),isClosed(), plus handshakeheaders/cookiesfor auth. Built on a small vendored piece of compiled Java (the only compiled Java in the project) — Undertow's own WebSocket extension points aren't reachable from BoxLang directly.- STOMP 1.2 broker (
boxExpressStomp()) — a full pub/sub broker on top ofapp.ws():SUBSCRIBE/SEND,authenticate()/authorize()hooks with per-connection metadata echoed back asCONNECTEDheaders.- Receipts, full
ACK/NACK(auto/client/client-individual modes),BEGIN/COMMIT/ABORTtransactions. - Bidirectional heartbeat negotiation with connection monitoring.
Sec-WebSocket-Protocolnegotiation for stomp.js and other client libraries.- Exchanges & bindings — AMQP-style routing:
direct,topic(wildcard*/#matching),fanout,distribution(random/round-robin), and a custom-exchange escape hatch. - A live connection registry (
getConnections(),getConnectionDetails()) and server-side listeners that react to a destination from plain server code, no WebSocket connection required. - Header values escaped per spec (not just stripped) to prevent frame injection.
- Cluster relay (
options.cluster) — see Cluster Support below: relays a publish to every other instance in a cluster, not just this process's own local subscribers.
Scheduler
app.schedule(intervalMs, callback, options)— fixed-interval recurring jobs, thesetIntervalequivalent. One sharedScheduledExecutorServicedrives ticks; each tick's callback runs on its own virtual thread, so a slow job never delays another job's tick.options.name,options.immediate(run first tick right away), andoptions.allowOverlap(defaultfalse— an overlapping tick is skipped, not queued).options.clustered(defaultfalse) — see Cluster Support below: runs the job on exactly one elected instance across a cluster, instead of once per instance, with passive failover if that instance goes down.- Errors inside a job are caught and logged, never left to kill future ticks.
app.getScheduledJobs()for live introspection;app.close()shuts the scheduler down cleanly.- Deliberately no cron-expression parsing and no persistence across restarts.
Cluster Support
app.getClusterManager()— lazily builds one sharedClusterManagerper app: cross-process peer discovery and manager election, backed by a durable, sharedcache()rather than static config, so instances never need to know each other's addresses in advance — each one registers its own identity and reads everyone else's back from the same cache.- Two consumers built on top of it:
app.schedule(intervalMs, callback, { clustered: true })— only the elected instance runs a clustered job's tick; every other instance does nothing for it. Failover is passive: if the elected instance goes down, the next instance to check finds its heartbeat stale and promotes itself.boxExpressStomp({ cluster: app.getClusterManager() })— opens a mesh of outbound WebSocket connections to every live peer; a publish that would otherwise only reach local subscribers is also relayed to every other instance, delivered there through that instance's own normal subscriber/exchange/listener path. A message that arrives via the relay is never relayed back out, so it can't loop.
- An app that never opts in (
cluster.enabledstaysfalse, the default) pays nothing for any of this — everyClusterManagermethod becomes a no-op matching today's unclustered behavior exactly. - Configured via
boxlang.json'smodules.boxexpress.settings.cluster, orapp.set("cluster", {...}):name(this instance's own address, e.g.ws://10.0.1.4:3000, typically resolved at deploy time via${env.POD_IP}-style interpolation rather than hardcoded),cacheProvider(required once enabled),secretKey(gates the relay's/__clusterendpoint),peerIdleTimeoutSeconds(default30). cacheProvidermust name a cache backed by a genuinely durable/shared object store — validated at startup against BoxLang's ownIObjectStore.isDistributed()(true forJDBCStore, false for the in-memoryConcurrentStoredefault) rather than just documented as a risk. A store that reportsisDistributed() == falsecan still be allowed explicitly viaallowedObjectStores.app.getClusterManager().getClusterMembers()— every live peer plus this instance's own name, for a status endpoint or dashboard.- Deliberately not built: per-job leader affinity (one elected instance runs every clustered job in the app, not different jobs spread across different instances), general peer-to-peer RPC (the relay only ever carries a STOMP publish), and binary relay payloads (the envelope is JSON text, matching STOMP's own transport limits here).
Middleware
- Body parsing:
boxExpressJSON(),boxExpressUrlencoded()— both cap the body at 100KB by default (configurable), returning413over the limit. - Static files:
boxExpressStatic(dir, options)— directory-index resolution, trailing-slash redirects, real-path containment against symlink escapes. - File uploads:
boxExpressUpload()(Multipart) — mirrors multer's basic usage. Files land inreq.filesas arrays keyed by field name; saved-to-disk files get generated UUID names (never the client's own filename). - Sessions:
boxExpressSession()— cookie-based, rolling expiry, pluggable store (get/set/destroy).resave/saveUninitializedoptions to skip unnecessary store writes.boxExpressCacheStore()backs sessions with BoxLang's own cache service for durability across restarts and sharing across a process cluster (including a real JDBC-backed table). - Security headers:
boxExpressHelmet()— clickjacking, MIME-sniffing, referrer, and cross-origin hardening headers, mirroringhelmet's defaults; every header individually overridable or disable-able.Strict-Transport-Security/Content-Security-Policyare opt-in. - CORS:
boxExpressCors()— origin reflection or allow-listing, preflight handling, credentials, exposed/allowed headers, mirroring the npmcorspackage. - Rate limiting:
boxExpressRateLimit()— fixed-window limiting keyed byreq.ip(or a customkeyGenerator), draft-standardRateLimit-*headers,429+Retry-Afteron breach. - CSRF protection:
boxExpressCsrf()— session-based token strategy mirroringcsurf,req.csrfToken(), validated from a form field or header on every unsafe method. - Error-handling middleware detected the same way Express does (4-parameter
handlers), default
404/500JSON responses, with real error messages logged server-side and only echoed to the client whenapp.set("env", "development").
Developer Experience
- Graceful shutdown: a JVM shutdown hook runs
close()on Ctrl-C/SIGTERM, and a bad port bind fails with a friendly message instead of a raw stack trace. - Colored console output for the access log and restart notices, honoring
NO_COLOR. - A full TestBox suite — router unit tests, real-HTTP integration tests, BIF
entry-point tests, Undertow adapter regression tests, cluster/leader-
election/relay integration tests, and process-lifecycle tests that spawn
a real subprocess to verify SIGTERM handling and
reloadOnChange.
Example Applications
Real, runnable apps built on BoxExpress, not just the snippets above:
- bxThreads — a public port of
DismalThreads, a Reddit-style forum app, built on BoxExpress instead of
ColdBox (no cbwire). Server-rendered HTML,
fetch()-driven actions, and a STOMP-based realtime layer for comments, votes, and notifications — the most complete example ofapp.ws()/boxExpressStomp()in a real app rather than a test. - bx-graphql-demo — a
runnable demo pairing BoxExpress with
bx-graphql, serving a small
seed dataset (users, posts, comments, shaped like JSONPlaceholder)
through a single
/graphqlendpoint, plus a built-in browser query console. - examples/server.bxs
in the BoxExpress repo itself — a single-file tour of the whole API
surface (routing, middleware, views, sessions, uploads, SSE, and more),
meant to be read top to bottom or run directly with
boxlang examples/server.bxs.
What's Deliberately Not Built (Yet)
BoxExpress is upfront about its edges rather than papering over them:
- No cron-expression parsing in the scheduler (fixed-interval only).
- No persistence or missed-run catch-up for scheduled jobs.
- No per-job leader affinity across a cluster (one elected instance runs every clustered job in the app) and no general peer-to-peer RPC between cluster nodes — see Cluster Support above for what clustering does cover.
- No binary WebSocket/STOMP bodies (a transport limit, not a broker choice).
- No multi-range (
bytes=0-10,20-30) orIf-Rangesupport for range requests.
BoxExpress is available on ForgeBox as
boxlang-express. Full docs at
kisdigital.com/projects/boxlang-express/docs/getting-started,
architecture notes in
docs/ARCHITECTURE.md.
No comments yet — be the first.