Opt-in realtime WebSocket transport for Wheels channels. Your app keeps calling
publish() exactly as before — where the engine can serve WebSockets, connected
browsers get the event over a socket; everywhere else, nothing changes and
SSE channels keep working.
publish("orders", "created", serializeJSON(order))
└─> in-memory subscribers ──> SSE clients (core, unchanged)
└─> WebSocket transport ──> WS clients (this package)
| Engine | Transport | Status |
|---|---|---|
| RustCFML | Native (wsPublish + engine-served channel CFCs) |
✅ v0.1.0 |
| Lucee 6.2+ | Over lucee/extension-websocket | ✅ v0.2.0 — shipped, verified live |
| Lucee 7 | Same backend | 3.0.0.20-SNAPSHOT extension pinned (full delivery bar verified live on 7.0.5.41) — an unpinned install gets 3.0.0.18, which can't load on Lucee 7, so the package stays on SSE. Waiting on a jakarta-compatible extension release (#1, #3292). See Lucee 7 |
| Adobe CF / BoxLang | — | Demand-gated (discussion #3286) |
On unsupported engines, or where a backend is detected but can't activate, the package logs one line and stays on SSE — installing it is always safe.
wheels packages add wheels-websockets(or manually: extract the release into vendor/wheels-websockets/ and reload.)
Then, on RustCFML:
- Publish the wire channel CFC (code you own — includes the auth hook):
cp vendor/wheels-websockets/channels/wheels.cfc public/websockets/wheels.cfc
- Publish the JS client:
cp vendor/wheels-websockets/assets/js/wheels-realtime.js public/assets/js/wheels-realtime.js
- Reload the app.
wheels.logshows:[wheels-websockets] Active: 'rustcfml' transport bridging channel publishes to WebSocket clients ...
- Install the official websocket extension once (needs a restart). On Lucee 7, pin
the snapshot build instead — see Lucee 7.
- env pin:
LUCEE_EXTENSIONS="3F9DFF32-B555-449D-B0EB5DB723044045;version=3.0.0.18" - or direct download: drop
websocket-extension-3.0.0.18.lexintolucee-server/deploy/and restart - or Lucee Admin → Extensions → "WebSocket"
- env pin:
- Install this package (
wheels packages add wheels-websockets) and restart/reload. On boot the package detects the extension, and — if the listener is absent — writeswheels.cfcinto the extension's configured websockets directory (skip withset(websocketsListenerInstall=false)); channel publishes then reach WebSocket clients atws://host/ws/wheels. - The listener is code you own — edit its auth gate in
onOpen(). Delete it and reload to regenerate.
| Setting | Default | Meaning |
|---|---|---|
websocketsTransport |
auto |
auto | rustcfml | lucee | none |
websocketsListenerInstall |
true |
Allow boot() to write the listener when absent |
Servlet containers: Tomcat (incl. Lucee Express / wheels start) works today on
Lucee 6.2+ — live-verified end-to-end (handshake, delivery, channel isolation,
eviction) against the store extension above. For Lucee 7, see Lucee 7.
Rewrite rules (wheels start / Tomcat): the WebSocket upgrade has to get past
your app's URL rewriting. Apps generated by a CLI without
wheels-dev/wheels#3676 (4.1.0 and
earlier) ship a
rewrite.config whose front-controller catch-all rewrites /ws/wheels to
/index.cfm/ws/wheels, so the client gets a Wheels 404 instead of a 101. Add these
two lines to your project-root rewrite.config, just above the
# Route everything else through the front controller rule, then restart:
RewriteCond %{HTTP:Upgrade} ^websocket$ [NC]
RewriteRule ^/ws/.*$ - [L]
The RewriteCond keeps ordinary HTTP routes under /ws/ on the Wheels router. A
Dockerfile from wheels deploy init needs the same two lines in its printf rule list
before 'RewriteRule ^/(.*)$ /index.cfm/$1 [L]'.
CommandBox / undertow: not a working path yet. Setting web.webSocket.enable: true
in server.json arms CommandBox's own WebSocket layer, which answers /ws/wheels
upgrades itself — a false-positive 101 handshake with no CFML listener behind it and
no frames ever delivered. Without that flag (retested on CommandBox 6.3.3 + Lucee
7.0.5.41 + 3.0.0.20-SNAPSHOT), the extension accepts the upgrade once ws/ is
excluded from urlrewrite.xml, but the connection closes (1006) without running the
listener — even a trivial echo CFC gets no frame. Use Tomcat (wheels start, or the
official lucee/lucee image) for WebSockets.
Lucee 7 works today when three things line up:
- An engine at 7.0.2.7 or newer. Older 7.x builds never fire extension startup
hooks (LDEV-5955, fixed).
wheels newcurrently pins an older build ("lucee": {"version": "7.0.0.395"}inlucee.json) — raise it, e.g. to7.0.5.41. - The extension's snapshot build, pinned. The store serves a jakarta-compatible
build only as
3.0.0.20-SNAPSHOT;3.0.0.19and3.0.0.20are not in the store.An unpinned install (LUCEE_EXTENSIONS="3F9DFF32-B555-449D-B0EB5DB723044045;version=3.0.0.20-SNAPSHOT" wheels startLUCEE_EXTENSIONSwith the ID only) resolves to the newest release, 3.0.0.18, which predates Lucee 7's API: it fails to load with aNoSuchMethodErrorin Lucee's logs, the engine and your app are unaffected, and the package logs one warning and stays on SSE with zero request-path impact. - The
/ws/rewrite pass-through above, for apps generated by a CLI without wheels-dev/wheels#3676 (4.1.0 and earlier).
Verified live on Lucee 7.0.5.41 (Tomcat 11) with this setup: handshake + welcome,
publish() delivery, channel isolation, dead-client eviction, and the SSE fallback
with set(websocketsTransport="none"). The pin is a stopgap until
lucee/extension-websocket publishes a jakarta-compatible release
(#1); drop it then.
Any container without a JSR-356 ServerContainer (or Lucee < 6.2): the package
logs once and stays on SSE.
Server side — nothing new; the channels API you already use:
publish(channel="orders", event="created", data=SerializeJSON(order));Browser side:
#realtimeScriptTag()#
<script>
var rt = WheelsRealtime.connect({
channels: ["orders", "alerts"],
onEvent: function (channel, event, data, id) {
console.log(channel, event, data);
},
onStatus: function (state) { /* "ws" | "sse" | "reconnecting" | "closed" */ }
});
</script>WheelsRealtime connects over WebSocket and falls back to the stock WheelsSSE
client automatically when it's on the page — one subscription API on every engine.
Helpers mixed into your app:
| Helper | Returns |
|---|---|
websocketsActive() |
true when a WS transport is live |
websocketsInfo() |
{ active, transport, wireChannel } |
realtimeScriptTag([jsPath] [, inline=true]) |
The client <script> tag (view helper) |
// config/settings.cfm
set(websocketsTransport="none"); // force-disable (default: "auto")The published public/websockets/wheels.cfc accepts every connection by default
and lets it subscribe to any channel it names — same trust model as the stock SSE
channel endpoint. If your channels carry per-user data, implement the auth hook in
onConnect() (e.g. validate a signed token from socket.param("token")) and
restrict the rooms you return.
At app boot the package feature-detects the engine (wsPublish in
GetFunctionList() ⇒ RustCFML; else a guarded websocketInfo() call ⇒ Lucee
6.2+) and, when a transport is available, pre-installs a
decorator around the framework's in-memory channel engine. The decorator forwards
every publish() to the transport after normal delivery, failure-isolated — a
broken socket layer can never affect publish() callers or SSE. Wheels channel
names map to rooms (ch:<name>) on one shared wire channel (/ws/wheels), so
clients receive only the channels they subscribed to.
No Wheels core changes are required or made.
tests/WebsocketsSpec.cfc (BDD, wheels.WheelsTest) — copy into an app with the
package installed, or point your runner at the package tests/ directory.
Apache-2.0 — © Wheels Core Team.