All Articles

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 mountable Router) 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 as app, mounted with app.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 answer HEAD too (body discarded, headers intact), matching Express.
  • app.set(name, value) / app.getSetting(name), app.locals (merged into every render, alongside res.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.backlog tunes the TCP accept queue (default 1024).
  • app.close() — stops the server, safely callable from a different thread than the one blocked in listen().
  • 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). Respects NO_COLOR.
  • app.set("reloadOnChange", true) — dev-only auto-restart on .bx/.bxs file 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) gates X-Forwarded-For/-Proto/-Host trust; 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 HTML dump() 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 set ETag/Last-Modified, honor conditional If-None-Match/If-Modified-Since (304s), support options.maxAge for Cache-Control, and contain user-supplied paths to a configured root.
  • Range requests (RFC 7233): 206 Partial Content for a Range header, 416 for an unsatisfiable one, Accept-Ranges: bytes on 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 bundled handlebars.java).
  • app.set("views", dir), app.set("view engine", "hbs") for a default extension.
  • app.locals / res.locals merge into every render() call (app.locals < res.locals < explicit data, 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 handshake headers/cookies for 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 of app.ws():
    • SUBSCRIBE/SEND, authenticate()/authorize() hooks with per-connection metadata echoed back as CONNECTED headers.
    • Receipts, full ACK/NACK (auto/client/client-individual modes), BEGIN/COMMIT/ABORT transactions.
    • Bidirectional heartbeat negotiation with connection monitoring.
    • Sec-WebSocket-Protocol negotiation 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, the setInterval equivalent. One shared ScheduledExecutorService drives 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), and options.allowOverlap (default false — an overlapping tick is skipped, not queued).
  • options.clustered (default false) — 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 shared ClusterManager per app: cross-process peer discovery and manager election, backed by a durable, shared cache() 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.enabled stays false, the default) pays nothing for any of this — every ClusterManager method becomes a no-op matching today's unclustered behavior exactly.
  • Configured via boxlang.json's modules.boxexpress.settings.cluster, or app.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 /__cluster endpoint), peerIdleTimeoutSeconds (default 30).
  • cacheProvider must name a cache backed by a genuinely durable/shared object store — validated at startup against BoxLang's own IObjectStore.isDistributed() (true for JDBCStore, false for the in-memory ConcurrentStore default) rather than just documented as a risk. A store that reports isDistributed() == false can still be allowed explicitly via allowedObjectStores.
  • 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), returning 413 over 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 in req.files as 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/saveUninitialized options 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, mirroring helmet's defaults; every header individually overridable or disable-able. Strict-Transport-Security/Content-Security-Policy are opt-in.
  • CORS: boxExpressCors() — origin reflection or allow-listing, preflight handling, credentials, exposed/allowed headers, mirroring the npm cors package.
  • Rate limiting: boxExpressRateLimit() — fixed-window limiting keyed by req.ip (or a custom keyGenerator), draft-standard RateLimit-* headers, 429 + Retry-After on breach.
  • CSRF protection: boxExpressCsrf() — session-based token strategy mirroring csurf, 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/500 JSON responses, with real error messages logged server-side and only echoed to the client when app.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 of app.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 /graphql endpoint, 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) or If-Range support 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.