Skip to content

package: wheels-websockets — opt-in realtime WS transport (RustCFML native, Lucee emulated) #3292

Closed

Description

@bpamiri

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 creates application.wheels.channelEngine; the package (loaded at app start) pre-installs a decorator that forwards every publish() to the wrapped engine AND the WS transport. Same for the database adapter singleton.
  • Engine detection at package load: feature-detect (wsPublish BIF → RustCFML; WS extension present → Lucee; else inactive with a one-time wheels.log notice). Per-package error isolation means a broken backend can never take the app down.
  • JS client: WheelsRealtime — attempts WS, falls back to the existing WheelsSSE transparently, one subscription API.
  • Package manifest: provides.mixins: global for 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.engines manifest field proposal if gating should become declarative
  • Upstream ask (RustCFML): configurable channel-CFC directory (Wheels apps would prefer app/websockets/ over webroot public/websockets/)

Evidence

  • RustCFML spike (2026-07-06): WS handshake 101 → welcome frame; then plain HTTP .cfm calling wsPublish(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.md in the RustCFML repo (rooms, presence, acks, lastEventId resumability, multi-node fan-out via memberlist, raw-WS + socket.io wires, socket.io-lucee compat).

Activity

  1. wheels-bot commented on Jul 6, 2026

    @wheels-bot
    Contributor

    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.

  2. wheels-bot commented on Jul 6, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Cross-Framework Research

    Question

    wheels-websockets is a proposed opt-in, first-party package that layers a realtime WebSocket transport on top of Wheels' existing SSE channel bus, keeping the same publish() / 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, an io() emitter, ack-by-return), plus a WheelsRealtime JS client that tries WS and falls back to the shipped WheelsSSE. 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

    Framework Naming Surface Trade-offs Edge cases
    Rails (Action Cable) subscribed/unsubscribed (channel), connect/disconnect (Connection); client received One class per channel; stream_from; ActionCable.server.broadcast; adapters async/redis/pg Class-per-channel + decoupled broadcast, but no ack-by-return (return discarded, must transmit) Native WS only, no fallback; async adapter is single-process
    Laravel (Broadcasting/Echo/Reverb) broadcastOn/broadcastAs; channel class join(); client .listen/.here Event-driven, not class-per-channel; broadcast(new E)->toOthers(); drivers reverb/pusher/ably Driver-swappable; auth centralized in channels.php; presence + whisper first-class Echo is WS-only; its eventStream() SSE primitive is separate, not a fallback
    Django (Channels) connect/receive/disconnect (consumer) One consumer class per endpoint; groups = rooms; channel_layer.group_send from outside Strong class-per-endpoint match; no onError, no ack-by-return, no bundled JS client Native WS over ASGI only; no SSE fallback; needs Daphne/uvicorn runtime
    Phoenix (Channels/Presence) join/handle_in/handle_out/terminate; topics "room:*" Module per topic namespace; Endpoint.broadcast from outside; PubSub substrate Ack-by-return first-class ({:reply,{:ok,map},socket}); two-level auth (Socket.connect + Channel.join); CRDT Presence WS↔longpoll fallback built into phoenix.js, one wire envelope both transports
    Spring (STOMP/SockJS) raw: afterConnectionEstablished/handleTextMessage/handleTransportError/afterConnectionClosed; STOMP: @MessageMapping/@SendTo Destination-routed (STOMP) or one handler class (raw); SimpMessagingTemplate.convertAndSend Ack-by-return via method return; broker hides session bookkeeping SockJS ordered fallback: WS → xhr-streaming/eventsource (SSE) → xhr-polling — SSE is a real rung
    Socket.IO (+1, justified) io.on('connection'), socket.on(event); io.use gate Namespace = channel handler; rooms via join/to; emitWithAck/return acks Direct conceptual ancestor — RustCFML adopted a socket.io-compatible wire (rooms, io(), ack-by-return) Transport goes long-poll → upgrade to WS — the reverse of the WS→SSE-fallback proposal

    +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.use connect 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' existing publish() + 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 onConnect connect-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 chained io.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 and publish().

    // 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) {}
    }
    // Broadcasting from a controller / model callback / job — UNCHANGED Wheels global.
    // The package pre-installs a decorator on application.wheels.channelEngine that
    // forwards every publish() to the wrapped SSE engine AND connected WS clients.
    publish(channel = "chat.#roomId#", event = "message", data = SerializeJSON(msg));
    // WheelsRealtime — one subscription API, WS with transparent SSE fallback.
    const rt = new WheelsRealtime('/chat', {
        params: { roomId: 42, token },
        transports: ['ws', 'sse'],            // try WS, fall back to existing WheelsSSE
        onMessage: (data, event) => render(data)
    });
    rt.emit('message', { text: 'hi' }, ack => console.log(ack.ok));   // ack-by-return

    Lives in: the standalone package vendor/wheels-websockets/ (repo wheels-dev/wheels-websockets, provides.mixins: global, wheelsVersion >= 4.0) — not vendor/wheels/. The only framework interaction is the decorator on the existing $getChannelEngine() singleton in vendor/wheels/Global.cfc:1484 (zero core changes). Composes with: the existing publish() / subscribeToChannel() bus, the memory/database channel adapters (fan-out substrate), the shipped WheelsSSE client (fallback rung), and PackageLoader.cfc per-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 (CreateObject on 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 via provides.mixins: global, must be public $-prefixed. Prefer the single named-arg io.emit(room=, event=, data=) form over socket.io's io.to(room).emit(...) chain: bracket-invoke-in-closure crashes Adobe CF 2021/2023's parser (Invariant 4) and map()-style struct-member collisions (Invariant 1) are dodged since the emitter method is emit, not map. 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 over lucee/extension-websocket (emulated); Adobe/BoxLang have no WS backend, so the transport stays inactive and publish() 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

    • Event dispatch granularity: one catch-all onMessage(socket, message) (Socket.IO-lite) vs dispatch-by-event-name to on<Event>() methods (Rails perform, Phoenix handle_in(event,…)). The named-method form is more discoverable and testable; the catch-all matches RustCFML's shipped surface. Which wins?
    • One abstraction or two? SSE stays a controller-action (subscribeToChannel()); WS becomes a channel-CFC. Two mental models for "realtime." Should they converge, or is the transport split acceptable because publish() is the shared seam?
    • Ack + broadcast in one handler: if 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) from broadcast! (fan-out); Spring splits @SendTo vs @SubscribeMapping. Define this explicitly.
    • Two-level auth adoption: is the added onJoin gate worth the surface, or is a single onConnect gate + app-level room checks enough for P1?
    • Presence: first-class (Phoenix/Laravel) or DIY-on-rooms (Rails/Django/Socket.IO)? Out of P1 scope, but the naming should leave room.

    Confidence: medium

    Auto-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 onError have no prior art in Rails/Laravel/Django). The core shape is nonetheless well-validated (class-per-channel + decoupled publish() + adapter backend maps cleanly onto Wheels' existing bus with zero core changes), so this is not low — P1 (RustCFML bridge + channel-CFC contract + WheelsRealtime client) is a well-scoped implementable slice.

    Sources

  3. wheels-bot commented on Jul 6, 2026

    @wheels-bot
    Contributor

    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:

    • Per-engine backends are the feature's core premise. Engine detection branches to RustCFML native (wsPublish BIF), Lucee emulation (over lucee/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.
    • The primary target backend cannot be verified here. P1 verification requires "a Wheels app served by RustCFML (ladder rig)"; the local tools/test-local.sh rig is Lucee/SQLite and has no wsPublish BIF, so the RustCFML delivery path would ship unexercised.
    • Design is not yet settled. The cross-framework research lands at medium confidence with five open questions still unresolved (event-dispatch granularity, one-vs-two realtime abstractions, ack-vs-broadcast disambiguation, two-level onConnect/onJoin auth adoption, presence). Committing the channel-CFC contract now risks locking these in prematurely.
    • Placement is a standalone-repo decision. The research explicitly homes this in wheels-dev/wheels-websockets (a separate repo, provides.mixins: global), not vendor/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.

  4. bpamiri commented on Jul 6, 2026

    @bpamiri
    CollaboratorAuthor

    P1 shipped — repo live at wheels-dev/wheels-websockets

    v0.1.0 (2026-07-06) implements the RustCFML backend + channels bridge:

    • ChannelEngineDecorator pre-installed via the ServiceProvider boot() lifecycle (zero core changes; transport failure-isolated from publish() callers).
    • RustCfmlTransport: one shared wire channel (/ws/wheels), one room per Wheels channel (ch:<name>); the code-you-own channels/wheels.cfc template ships the connect/auth gate.
    • WheelsRealtime JS client with automatic WheelsSSE fallback.
    • Helpers: websocketsActive(), websocketsInfo(), realtimeScriptTag(); set(websocketsTransport="none") kill switch.

    Verification:

    • RustCFML v0.423 end-to-end with the real package code: HTTP request → publish() → decorator → wsPublish → room-isolated delivery (subscriber to orders received the event; subscriber to chat did not).
    • Lucee 7: package loads through 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):

    • The package alias mapping (application.mappings) is NOT visible to component resolution during the boot request — sibling CFCs must be created via the GetMetadata(this).name-derived base path (vendor.wheels-websockets.*). Package specs resolve through PackageLoaderObj.getPackage().
    • The manifest's mixins: "global" target expands into every concrete target (controller, model, ...) — there is no global key in getMixins(). Per-method mixin="controller" metadata works for scoping view helpers.
    • Mixin copies execute in the target's variables scope — all shared state must live on the decorator, not the package instance.

    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.

  5. bpamiri commented on Jul 7, 2026

    @bpamiri
    CollaboratorAuthor

    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 a server-scope connection registry (named-lock discipline, best-effort delivery, dead-client eviction). WheelsRealtime JS 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). Serves ws://host/ws/wheels?channels=... — same URL as RustCFML.
    • Detection: auto probes wsPublish (RustCFML) before websocketInfo (Lucee); forced modes still feature-check; every Lucee call guarded.

    Verified engine matrix (all live, 2026-07-06)

    Stack Result
    Lucee 6.2.7.16 + store extension 3.0.0.18 ✅ Full live delivery: handshake/welcome, publish → frame, channel isolation, eviction resilience
    Lucee 7.0.0.395 (Tomcat 11, wheels start) ⏳ Pending upstream — extension startup hook never fires; released builds fail to load (NoSuchMethodError, Lucee 7 API break). Graceful SSE fallback verified live (one warning, zero request impact)
    Lucee 7.0.1.100 (CommandBox/undertow) ⏳ Same hook regression. Undertow does expose the JSR-356 ServerContainer, but web.webSocket.enable arms CommandBox's own WS layer which shadows /ws/wheels (documented footgun). Fallback verified live
    RustCFML v0.423 ✅ No regression — auto still resolves the native transport

    Extension-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

    1. Lucee 7.x never fires extension startup hooks (root cause blocking the WS extension on 7).
    2. Released websocket-extension builds crash on Lucee 7's ConfigWeb.getServletContext() API break — needs a jakarta-compatible store release (master already dual-API).
    3. Extension manifest parser: plain-form startup-hook crashes Lucee 7's loader (JSON-struct form required).

    Post-0.2.0 follow-ups (from final review triage)

    • debug-level eviction logging in the transport
    • spec for the onClose-before-onOpen defensive branch
    • temp-dir spec cleanup in finally

    P3 registry publish: wheels-dev/wheels-packages#13 open (manifest + auto-mirror) — merge pending maintainer review; wheels packages add wheels-websockets goes live on merge.

  6. bpamiri commented on Jul 7, 2026

    @bpamiri
    CollaboratorAuthor

    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-websockets resolves 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):

    • Startup hook fires — websocketInfo() works with zero workarounds.
    • Full delivery bar passes on Lucee 7 with an extension master build: welcome, exact-wire-frame delivery, channel isolation, eviction resilience.
    • Store extension 3.0.0.18 still fails to load on Lucee 7 (NoSuchMethodError: ConfigWeb.getServletContext() at WebSocketEndpointFactory.register:85).
    • Plain-form startup-hook manifest 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 start users: the CLI's bundled Lucee Express (7.0.0.395) predates the hook fix — bumping it is tracked on the LuCLI side.

  7. bpamiri commented on Sep 27, 2026

    @bpamiri
    CollaboratorAuthor

    Closing: P1–P3 delivered, remaining work moved to the package repo

    wheels-websockets is live and installable:

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions