Repository navigation
package: wheels-websockets — opt-in realtime WS transport (RustCFML native, Lucee emulated) #3292
Copy link
Copy link
Closed
Labels
Description
What
New first-party package wheels-websockets: an opt-in realtime WebSocket transport for Wheels channels. Core stays SSE-only (the AdonisJS/Transmit position, unchanged); this package adds WS as an additive transport for the same publish()/subscribeToChannel() semantics where the engine can serve it.
Decision record: #3154 (GO as package, 2026-07-06). Parent roadmap: #2962. Demand probe (now prioritizing engine backends + features): #3286.
Engine strategy
| Engine | Backend | Status |
|---|---|---|
| RustCFML | Native (wsPublish BIF + channel CFCs, shipped v0.299–v0.303) |
Day one — bridge seam proven live 2026-07-06: HTTP request → wsPublish() → connected WS client, on stock v0.423 |
| Lucee 6/7 | Emulation over the official lucee/extension-websocket (active, updated 2026-06) — fallback candidate: isapir/lucee-websocket (JSR-356, updated 2026-07) |
Day one — API spike first (extension surface → emulation shape) |
| Adobe / BoxLang | None (cfwebsocket deprecated / absent) |
Demand-gated via #3286; package degrades gracefully — SSE channels keep working |
Programming model — adopt the RustCFML surface
Rather than inventing a third CFML realtime API, the package adopts the channel-CFC shape RustCFML shipped (one CFC per channel, convention lifecycle onConnect/onMessage/onDisconnect/onError, connect reject gate, rooms, io() emitter, ack-by-return):
- On RustCFML: channel CFCs are served natively by the engine; the package contributes only the channels bridge, config, and the JS client.
- On Lucee: the package hosts the same CFC contract over the WS extension — instantiates the app's channel CFCs and provides
socket/io()shims. - Everywhere:
publish()on the Wheels channel bus fans out to connected WS clients when a backend is live.
Architecture
- Channel-bus hook (zero core changes):
Global.cfc::$getChannelEngine()lazily createsapplication.wheels.channelEngine; the package (loaded at app start) pre-installs a decorator that forwards everypublish()to the wrapped engine AND the WS transport. Same for the database adapter singleton. - Engine detection at package load: feature-detect (
wsPublishBIF → RustCFML; WS extension present → Lucee; else inactive with a one-timewheels.lognotice). Per-package error isolation means a broken backend can never take the app down. - JS client:
WheelsRealtime— attempts WS, falls back to the existingWheelsSSEtransparently, one subscription API. - Package manifest:
provides.mixins: globalfor the app-facing helpers;wheelsVersion >= 4.0.
Phasing
- P1 — RustCFML backend + channels bridge: decorator hook, engine detection, JS client, docs, specs; verify against a Wheels app served by RustCFML (ladder rig)
- P2 — Lucee emulation: spike
lucee/extension-websocket's API; implement the channel-CFC host + socket/io() shims; CommandBox/undertow compatibility check - P3 — registry publish: standalone repo
wheels-dev/wheels-websockets, two-PR registry flow,wheels packages add websockets - P4 — demand-gated: Adobe/BoxLang backends if Do you need WebSockets in Wheels? Real-world demand check for channels #3286 shows demand;
requires.enginesmanifest field proposal if gating should become declarative - Upstream ask (RustCFML): configurable channel-CFC directory (Wheels apps would prefer
app/websockets/over webrootpublic/websockets/)
Evidence
- RustCFML spike (2026-07-06): WS handshake 101 →
welcomeframe; then plain HTTP.cfmcallingwsPublish(channel="/chat", event="bridged", ...)delivered the frame to the connected client. The bridge is a thin adapter, not an integration project. - Full surface reference:
docs/websockets.mdin the RustCFML repo (rooms, presence, acks,lastEventIdresumability, multi-node fan-out via memberlist, raw-WS + socket.io wires, socket.io-lucee compat).
Reactions are currently unavailable
Activity
wheels-bot commentedon Jul 6, 2026
Wheels Bot — Triage
Classified as framework-design (API/feature design question).
Cross-framework research will follow shortly — Rails, Laravel, Django,
and other modern frameworks will be consulted before any path forward
is proposed.
wheels-bot commentedon Jul 6, 2026
Wheels Bot — Cross-Framework Research
Question
wheels-websocketsis a proposed opt-in, first-party package that layers a realtime WebSocket transport on top of Wheels' existing SSE channel bus, keeping the samepublish()/subscribeToChannel()semantics. Decision record #3154 already picked GO-as-package; the programming model already picked "adopt the RustCFML channel-CFC surface" (one CFC per channel —onConnect/onMessage/onDisconnect/onError, a connect-reject gate, rooms, anio()emitter, ack-by-return), plus aWheelsRealtimeJS client that tries WS and falls back to the shippedWheelsSSE. So this is less "what should the API be" and more "does the framework landscape validate that surface, and where should we refine it?"How other frameworks handle this
subscribed/unsubscribed(channel),connect/disconnect(Connection); clientreceivedstream_from;ActionCable.server.broadcast; adapters async/redis/pgtransmit)broadcastOn/broadcastAs; channel classjoin(); client.listen/.herebroadcast(new E)->toOthers(); drivers reverb/pusher/ablychannels.php; presence + whisper first-classeventStream()SSE primitive is separate, not a fallbackconnect/receive/disconnect(consumer)channel_layer.group_sendfrom outsideonError, no ack-by-return, no bundled JS clientjoin/handle_in/handle_out/terminate; topics"room:*"Endpoint.broadcastfrom outside; PubSub substrate{:reply,{:ok,map},socket}); two-level auth (Socket.connect + Channel.join); CRDT PresenceafterConnectionEstablished/handleTextMessage/handleTransportError/afterConnectionClosed; STOMP:@MessageMapping/@SendToSimpMessagingTemplate.convertAndSendeventsource(SSE) → xhr-polling — SSE is a real rungio.on('connection'),socket.on(event);io.usegatejoin/to;emitWithAck/return acksio(), ack-by-return)+1 justification: Socket.IO is the direct ancestor of the chosen RustCFML surface (socket.io-compatible wire: namespaces→channels, rooms,
io()emitter, ack-by-return,io.useconnect gate). It's the most load-bearing comparison for validating naming, not a tangent.Dominant pattern
Two things converge hard and one splits. Converge: (1) decoupled broadcast — every framework lets you publish to a channel/topic from outside the socket (
ActionCable.server.broadcast,broadcast(new E),group_send,Endpoint.broadcast,convertAndSend,io.to().emit) backed by a pluggable pub/sub adapter (redis/pg/in-memory). This is Wheels' existingpublish()+ memory/database adapter model — the package needs no new bus. (2) one handler per channel/endpoint is the majority shape (Rails, Django, Phoenix, Spring-raw, Socket.IO-namespace); only Laravel is event-driven. Split: transport fallback. Most native-WS frameworks (Rails, Laravel, Django) ship no fallback at all; only Phoenix (WS↔longpoll) and Spring/SockJS (WS→streaming→SSE→polling) offer built-in negotiation under one client API — and Socket.IO negotiates the opposite direction (poll→WS upgrade). So the proposal's "WS then SSE fallback" has real precedent (Phoenix + SockJS), and is uniquely well-motivated for Wheels specifically because Wheels already ships an SSE channel layer to degrade onto — a luxury Rails/Django don't have.Recommended path for Wheels
Endorse the RustCFML/socket.io surface as chosen — the landscape validates it — with two research-backed refinements: (a) borrow Phoenix's two-level auth (a socket-level
onConnectconnect-reject gate plus a per-room join gate), since a single gate can't distinguish "may open a socket" from "may join this room"; (b) keep the unified WS→SSE fallback (Phoenix/SockJS precedent) but express the emitter and ack in Wheels' named-argument idiom rather than socket.io's chainedio.to(room).emit(...). Ack-by-return is well-precedented (Phoenix, Spring, Socket.IO), so keep it. Nothing here touches core — it rides the existing$getChannelEngine()decorator seam andpublish().// app/websockets/ChatChannel.cfc — app-authored; the channel-CFC contract component { // Connect-reject gate (Socket.IO io.use / Phoenix connect/3 / Django connect()+close()). // Return false (or {allow=false, reason="..."}) to refuse the handshake. public any function onConnect(required struct socket) { return Len(arguments.socket.params.token ?: ""); } // Per-room join gate — Phoenix's second auth level. Optional; defaults to allow. public boolean function onJoin(required struct socket, required string room) { return arguments.socket.identity.canJoin(arguments.room); } // Inbound handler. The RETURN VALUE is the ack delivered to the sender // (Phoenix {:reply,...} / Spring @SendTo / Socket.IO emitWithAck). public any function onMessage(required struct socket, required struct message) { // Wheels-idiomatic single named-arg emit (not socket.io's io.to(r).emit() chain) arguments.socket.io.emit( room = "chat.#arguments.socket.params.roomId#", event = "message", data = {user = arguments.socket.identity.name, text = arguments.message.text} ); return {ok = true}; } public void function onDisconnect(required struct socket, string reason = "") {} public void function onError(required struct socket, required any error) {} }Lives in: the standalone package
vendor/wheels-websockets/(repowheels-dev/wheels-websockets,provides.mixins: global,wheelsVersion >= 4.0) — notvendor/wheels/. The only framework interaction is the decorator on the existing$getChannelEngine()singleton invendor/wheels/Global.cfc:1484(zero core changes). Composes with: the existingpublish()/subscribeToChannel()bus, thememory/databasechannel adapters (fan-out substrate), the shippedWheelsSSEclient (fallback rung), andPackageLoader.cfcper-package error isolation (a dead backend degrades to SSE-only, never takes the app down).Cross-engine notes
App-authored channel-CFC lifecycle methods are public and instantiated directly by the package host (
CreateObjecton Lucee) — so Cross-Engine Invariant 7 (private mixins aren't integrated) doesn't bite the app; but the package's own internal helpers, if delivered viaprovides.mixins: global, must bepublic$-prefixed. Prefer the single named-argio.emit(room=, event=, data=)form over socket.io'sio.to(room).emit(...)chain: bracket-invoke-in-closure crashes Adobe CF 2021/2023's parser (Invariant 4) andmap()-style struct-member collisions (Invariant 1) are dodged since the emitter method isemit, notmap. Any closures in the decorator-install / engine-detection code must be hoisted, never passed inline as constructor named args (Invariant 5). Engine reality: RustCFML serves channel CFCs natively (day one); Lucee 6/7 hosts the same contract overlucee/extension-websocket(emulated); Adobe/BoxLang have no WS backend, so the transport stays inactive andpublish()silently keeps SSE working — graceful degradation is the cross-engine safety net. No Lucee-only or Adobe-only API in the app-facing surface.Open questions
onMessage(socket, message)(Socket.IO-lite) vs dispatch-by-event-name toon<Event>()methods (Railsperform, Phoenixhandle_in(event,…)). The named-method form is more discoverable and testable; the catch-all matches RustCFML's shipped surface. Which wins?subscribeToChannel()); WS becomes a channel-CFC. Two mental models for "realtime." Should they converge, or is the transport split acceptable becausepublish()is the shared seam?onMessage's return value is the sender ack, how does a handler both ack the sender and broadcast to the room without ambiguity? Phoenix splits{:reply,…}(ack) frombroadcast!(fan-out); Spring splits@SendTovs@SubscribeMapping. Define this explicitly.onJoingate worth the surface, or is a singleonConnectgate + app-level room checks enough for P1?Confidence:
mediumAuto-downgrade fired on two rules: the +1 framework (Socket.IO) negotiates transport the opposite direction (long-poll→WS upgrade vs the proposed WS→SSE fallback) — a non-trivial trade-off — and the dominant pattern diverges in parts I had to adapt around (Laravel is event-driven not class-per-channel; ack-by-return and
onErrorhave no prior art in Rails/Laravel/Django). The core shape is nonetheless well-validated (class-per-channel + decoupledpublish()+ adapter backend maps cleanly onto Wheels' existing bus with zero core changes), so this is notlow— P1 (RustCFML bridge + channel-CFC contract +WheelsRealtimeclient) is a well-scoped implementable slice.Sources
wheels-bot commentedon Jul 6, 2026
Wheels Bot — Fix on hold for human review
The proposed fix touches a sensitive area (cross-engine concern — different code paths for RustCFML / Lucee / Adobe / BoxLang) and the bot's safety net requires a human in the loop before any code is written.
Specifics that fired the auto-downgrade net:
wsPublishBIF), Lucee emulation (overlucee/extension-websocket), and an inactive path for Adobe/BoxLang. That is precisely "different code paths for Lucee/Adobe/BoxLang" — the trigger the net exists to catch, and the top bug source per CLAUDE.md’s cross-engine invariants.tools/test-local.shrig is Lucee/SQLite and has nowsPublishBIF, so the RustCFML delivery path would ship unexercised.mediumconfidence with five open questions still unresolved (event-dispatch granularity, one-vs-two realtime abstractions, ack-vs-broadcast disambiguation, two-levelonConnect/onJoinauth adoption, presence). Committing the channel-CFC contract now risks locking these in prematurely.wheels-dev/wheels-websockets(a separate repo,provides.mixins: global), notvendor/wheels/. The only sanctioned core interaction is a decorator on the existing$getChannelEngine()seam (vendor/wheels/Global.cfc:1484).A human should scope P1 (which engine paths to land first, whether to stub the RustCFML adapter behind a verifiable seam, and how to test the graceful-degradation-to-SSE contract on the Lucee rig) before autonomous code is written.
bpamiri commentedon Jul 6, 2026
P1 shipped — repo live at wheels-dev/wheels-websockets
v0.1.0 (2026-07-06) implements the RustCFML backend + channels bridge:
ChannelEngineDecoratorpre-installed via the ServiceProviderboot()lifecycle (zero core changes; transport failure-isolated frompublish()callers).RustCfmlTransport: one shared wire channel (/ws/wheels), one room per Wheels channel (ch:<name>); the code-you-ownchannels/wheels.cfctemplate ships the connect/auth gate.WheelsRealtimeJS client with automaticWheelsSSEfallback.websocketsActive(),websocketsInfo(),realtimeScriptTag();set(websocketsTransport="none")kill switch.Verification:
publish()→ decorator →wsPublish→ room-isolated delivery (subscriber toordersreceived the event; subscriber tochatdid not).PackageLoader(global mixins collected,boot()logs inactive), 11/11 package specs + full demo-app suite green with the package installed — installing on a non-WS engine changes nothing.Implementation notes for P2+ (learned the hard way):
application.mappings) is NOT visible to component resolution during the boot request — sibling CFCs must be created via theGetMetadata(this).name-derived base path (vendor.wheels-websockets.*). Package specs resolve throughPackageLoaderObj.getPackage().mixins: "global"target expands into every concrete target (controller, model, ...) — there is noglobalkey ingetMixins(). Per-methodmixin="controller"metadata works for scoping view helpers.Remaining phases: P2 Lucee emulation (spike
lucee/extension-websocket's API next), P3 registry publish (two-PR flow →wheels packages add websockets), P4 demand-gated engines (#3286). Upstream ask for RustCFML (configurable channel-CFC directory) still to be filed.bpamiri commentedon Jul 7, 2026
P2 shipped — Lucee backend in wheels-websockets v0.2.0
Design spec + implementation plan live in the package repo (
docs/specs/,docs/plans/). 29 package specs green on a stock engine; TDD throughout.What landed (tag
v0.2.0)lib/LuceeExtensionTransport.cfc— broadcasts the exact P1 wire frame to aserver-scope connection registry (named-lock discipline, best-effort delivery, dead-client eviction).WheelsRealtimeJS and the RustCFML path changed zero bytes.lucee/wheels.cfc— self-contained listener the package auto-installs into the extension's websockets directory at boot (websocketInfo().mapping; never overwrites;set(websocketsListenerInstall=false)opt-out). Servesws://host/ws/wheels?channels=...— same URL as RustCFML.autoprobeswsPublish(RustCFML) beforewebsocketInfo(Lucee); forced modes still feature-check; every Lucee call guarded.Verified engine matrix (all live, 2026-07-06)
wheels start)NoSuchMethodError, Lucee 7 API break). Graceful SSE fallback verified live (one warning, zero request impact)web.webSocket.enablearms CommandBox's own WS layer which shadows/ws/wheels(documented footgun). Fallback verified liveautostill resolves the native transportExtension-version correction vs the design spec: the store's latest release is 3.0.0.18 (3.0.0.20 exists only in the extension repo's changelog). A jakarta-capable master build was compiled and used to isolate the Lucee 7 defects.
Upstream (Lucee) items identified — filing decision pending maintainer
ConfigWeb.getServletContext()API break — needs a jakarta-compatible store release (master already dual-API).startup-hookcrashes Lucee 7's loader (JSON-struct form required).Post-0.2.0 follow-ups (from final review triage)
onClose-before-onOpendefensive branchfinallyP3 registry publish: wheels-dev/wheels-packages#13 open (manifest + auto-mirror) — merge pending maintainer review;
wheels packages add wheels-websocketsgoes live on merge.feat: add wheels-websockets 0.2.0 to the registry (#13)
bpamiri commentedon Jul 7, 2026
P3 done + Lucee 7 correction (2026-07-07)
Registry publish complete: wheels-packages#13 merged, mirror filled the tarball/sha256, and
wheels packages show wheels-websocketsresolves 0.2.0 (install UX verified).Correction to yesterday's Lucee 7 verdict — before filing upstream we searched LDEV and found the hook defect was already known and FIXED: LDEV-5955 "Regression: Startup hooks not triggered in Lucee 7", fixVersion 7.0.2.7. Both engines we had tested (7.0.0.395 via LuCLI Express, 7.0.1.100 in the CommandBox image) predate the fix. Retested on Lucee 7.0.4.34 (official image):
websocketInfo()works with zero workarounds.NoSuchMethodError: ConfigWeb.getServletContext()atWebSocketEndpointFactory.register:85).startup-hookmanifest crash also reproduced on 7.0.4.34.Net: the only upstream blocker for stock Lucee 7 is the missing jakarta-compatible extension release. Upstream filings reduced from three to two (release ask + manifest-parser bug); a confirming comment on LDEV-5955 replaces the would-be duplicate regression ticket. README/CHANGELOG corrected in
8b37974.Also relevant for
wheels startusers: the CLI's bundled Lucee Express (7.0.0.395) predates the hook fix — bumping it is tracked on the LuCLI side.docs(web/guides): document wheels-websockets package; correct legacy …
bpamiri commentedon Sep 27, 2026
Closing: P1–P3 delivered, remaining work moved to the package repo
wheels-websocketsis live and installable:wheels packages add wheels-websocketsworks.Status correction: the Lucee 7 note above is out of date. The startup-hook bug was fixed upstream in
Lucee 7.0.2.7 (LDEV-5955), and full delivery passes on 7.0.4.34. The only remaining blocker for stock
Lucee 7 is a jakarta-compatible websocket extension in the Lucee store.
Remaining work now lives with the package:
We've decided not to pursue the RustCFML ask (a configurable channel-CFC directory). The webroot location works.