Skip to content

channels: WebSocket parity — demand probe before any design work #3154

Closed

Description

@bpamiri

Child of #2962 (sequencing per the 2026-06-11 roadmap comment there). Wheels channels are SSE-only by design (proxy-friendly, auto-reconnect, HTTP auth reuse — the Transmit/AdonisJS call). Client→server is plain HTTP POST → publish(). Before committing to a WS stack, gather concrete demand evidence: do users need bidirectional/binary, or did SSE-only read as a deficiency? Engine-portability wall documented in the channels guide: Lucee has cfwebsocket, Adobe deprecated theirs, BoxLang has none — a first-party WS layer needs an engine-portable HTTP-upgrade path. Acceptance: a demand summary (discussions/survey) and a go/no-go recommendation; NO implementation in this issue.

Activity

  1. wheels-bot commented on Jun 12, 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.

    <!-- wheels-bot:triage:3154 -->
    <!-- wheels-bot:triage-class:framework-design -->

  2. wheels-bot commented on Jun 12, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Cross-Framework Research

    Question

    A demand probe, not an API-design request: should Wheels add a bidirectional client→server WebSocket path to its SSE-only channels, and does the cross-framework landscape treat SSE-only as a deficiency or a defensible choice? Wheels channels today are server→client over SSE (EventSource auto-reconnect, HTTP-auth reuse, proxy-friendly); client→server is plain HTTP POST → publish(). Acceptance = a demand summary + go/no-go, no implementation. Engine wall: Lucee has cfwebsocket, Adobe deprecated theirs, BoxLang has none.

    How other frameworks handle this

    Framework Naming Surface Trade-offs Edge cases
    Rails ActionCable (Channel/Connection/broadcasting); SSE = ActionController::Live WS-only ActionCable plus a separate, still-supported SSE primitive One abstraction both ways; cost is operational — pub/sub adapter (redis/solid_cable) + WS-upgrade proxy Auth via encrypted cookie on handshake; SSE path gets EventSource auto-reconnect
    Laravel Broadcasting + Echo; Reverb/Pusher/Ably; SSE = response()->eventStream() WS broadcasting and a separate first-party SSE story, kept distinct WS needs a standalone daemon (Reverb/Supervisor) or paid SaaS; SSE rides HTTP, zero infra private/presence auth via /broadcasting/auth; Redis to scale Reverb
    Django core StreamingHttpResponse (SSE); WS via Channels (consumers/groups/layers) Core ships SSE only — no WS in core; WS in a separate official-but-not-core pkg Channels forces WSGI→ASGI swap + Redis channel layer WS reconnect not free (client hand-rolls backoff); group_send decouples publishers
    Phoenix Channel/Socket/topic; join/handle_in/broadcast/push; Presence WS-first with built-in long-poll fallback; no SSE Bidirectional over one multiplexed connection; needs BEAM process model Fallback not seamless; at-most-once delivery; resume is app-level last_seen_id
    Spring Boot STOMP (@MessageMapping/SimpMessagingTemplate); SSE (SseEmitter/Flux); SockJS Both first-class; STOMP-over-WS idiomatic SockJS degrades WS→SSE/long-poll with one client API; external broker to scale No servlet disconnect callback (heartbeat timeout); proxy heartbeats ~25s
    AdonisJS (+1, contrarian, named in this issue) Transmit: transmit.broadcast/authorize; client subscription/onMessage Deliberately SSE-only, not a WS fallback SSE down + ordinary HTTP up; Redis/MQTT to fan out HTTP/1.1 6-conn cap → HTTP/2; no client→server over the stream by design

    +1 justification: AdonisJS Transmit made the exact Wheels decision — SSE down, HTTP POST up — and published its rationale (simplicity, HTTP-native auth reuse, proxy-friendliness, EventSource auto-reconnect, HTTP/2 for the conn cap). The most directly relevant precedent for a go/no-go.

    Dominant pattern

    No "WebSocket everywhere" consensus — the split tracks runtime cost. Runtimes with cheap persistent connections (Phoenix/BEAM, Spring/JVM, AdonisJS/Node) can offer WS natively; Phoenix is WS-first, Spring bridges both via SockJS, AdonisJS declines WS. The heavy request/worker frameworks most like Wheels — Rails, Laravel, Django — converge on the same shape: SSE for the common server-push case, WS as an opt-in, architecturally separate, infrastructure-bearing add-on (a separate process / a runtime swap / a mandatory pub/sub adapter). None layer broadcasting on SSE; none deprecated SSE. Convergence: SSE is the right default for one-way push; WS is a deliberate add-on for true bidirectional / presence / whisper. AdonisJS proves SSE-only is a defensible end-state, not a gap.

    Recommended path for Wheels

    Go/no-go: NO-GO on a native first-party WebSocket layer now — gather demand evidence first, as this issue scopes. Drivers:

    1. The engine wall is decisive. Every WS-shipping framework rides a runtime with a portable persistent-connection primitive. Wheels has none (cfwebsocket Lucee-only, Adobe deprecated, BoxLang absent). A portable WS layer can't use native engine WS — it needs a servlet-container HTTP-upgrade handler (Undertow/Tomcat-specific, outside CFML, breaks the cross-engine contract) or an external WS sidecar (the Reverb model) — new infra the single-process/shared-hosting model doesn't assume.
    2. Comparable frameworks validate the current split, not a deficiency. Wheels' SSE-down + HTTP-POST-up is Rails ActionController::Live and is AdonisJS Transmit — a recognized pattern, not a half-measure.
    3. So this is a product-demand question, not an API question — which is why it's a probe.

    If demand materializes, the engine-portable path is an optional WS bridge adapter that re-publishes onto the existing channel bus, leaving the core SSE API untouched. Illustrative only (no code ships here):

    // config/settings.cfm — opt-in, mirrors the existing channelAdapter switch.
    set(channelAdapter = "memory");          // unchanged default; SSE stays engine-native
    set(channelWebSocketBridge = false);     // off unless an external WS sidecar runs
    
    // publish() is unchanged — the single fan-out entry point for SSE and (if on) WS:
    publish(channel = "chat.room.1", event = "message", data = SerializeJSON(msg));
    
    // Client->server stays plain HTTP, same as today (and AdonisJS / Rails-Live):
    function send() {
        publish(channel = "chat.room.#params.roomId#", event = "message",
                data = SerializeJSON({ user = session.userName, text = params.text }));
        renderNothing();
    }

    Lives in (only on a future go): vendor/wheels/channel/WebSocketBridge.cfc beside DatabaseAdapter.cfc, selected by $getChannelEngine(). No change to publish() / subscribeToChannel() signatures. Composes with the existing channelAdapter pattern and the DB/Redis transport already modeled by DatabaseAdapter for multi-node fan-out.

    Cross-engine notes

    A native WS implementation is not portable: cfwebsocket is Lucee-only, Adobe deprecated WS, BoxLang lacks it — any core WS feature violates the Lucee 5/6/7 × Adobe 2018–2025 × BoxLang contract every vendor/wheels/** change must honor. The only portable option keeps WS outside the engine (an external sidecar speaking SSE/HTTP) — hence the bridge-adapter framing. The sketch adds no engine-specific calls, no closure-as-constructor-arg, no mixed positional/named args.

    Open questions

    • Concrete bidirectional demand? Discussions/survey evidence is the real deliverable — typing/presence/collab-editing that SSE-down + HTTP-POST-up can't serve.
    • Is a sidecar process acceptable to the Wheels deployment story? Shared-hosting/single-WAR users may reject a Reverb-style daemon — a product call for a human.
    • Would documented HTTP/2 + a "presence over the DB adapter" recipe close most of the perceived gap with no WS at all?
    • Multi-node fan-out already needs an external bus even for SSE — possibly the more pressing gap than WS.

    Confidence: low

    Two auto-downgrade rules fire: (1) the proposal would require new infrastructure (external sidecar / servlet-level upgrade) — a human-discussion trigger; (2) the issue is a go/no-go decision gate with an explicit "no implementation" scope. Evidence is clear and consistent, but the decision is a human product call — stays manual, does not auto-fire propose-fix.

    Sources

  3. bpamiri commented on Jul 6, 2026

    @bpamiri
    CollaboratorAuthor

    Demand probe is live

    The demand-gathering step this issue gates on is now posted: Do you need WebSockets in Wheels? Real-world demand check for channels (Discussions → Ideas).

    It asks the four questions from the acceptance criteria: what people are building, whether SSE + HTTP POST actually blocks it, which WS-specific capabilities (bidirectional/binary/latency/presence) are needed, and whether a documented sidecar pattern would suffice.

    Next action per this issue's own scope: collect responses, then record a go/no-go here. The bot's cross-framework research above already leans no-go (AdonisJS Transmit precedent + the engine-portability wall); concrete demand in the discussion is what would overturn that.

  4. bpamiri commented on Jul 6, 2026

    @bpamiri
    CollaboratorAuthor

    Decision: GO — as an opt-in first-party package, not core

    Maintainer decision (2026-07-06), superseding the wait-for-demand gate on new evidence:

    RustCFML shipped complete native WebSocket support (v0.299–v0.303, current v0.423): channel CFCs with convention lifecycle methods (onConnect/onMessage/onDisconnect/onError), rooms, fluent io() emitter, ack-by-return, presence, auth reject gate, on="event" routing, lastEventId resumability, multi-node fan-out, raw-WS + socket.io wires, and emit-from-anywhere BIFs (wsPublish/wsSubscribe/wsUnsubscribe/wsPresence). See docs/websockets.md in the RustCFML repo.

    That dissolves both NO-GO arguments for the package shape:

    • Engine wall: applies to core features (must run everywhere). A package only ships where it works — per-package error isolation and runtime feature-detection make partial engine coverage safe.
    • CFML-side programming model: now designed and proven by an engine. The package adopts the RustCFML channel-CFC surface as its API rather than inventing a third one.

    Scope of the GO:

    • wheels-websockets first-party package (standalone repo, registry-indexed, wheels packages add websockets).
    • Day one: RustCFML backend — thin bridge, wsPublish() maps 1:1 onto the channel bus.
    • Day one: Lucee backend — emulation over Lucee's WebSocket extension (feasibility spike first; findings go on the package's tracking issue).
    • Deferred: Adobe / BoxLang backends wait for demand evidence from the probe (Do you need WebSockets in Wheels? Real-world demand check for channels #3286, reframed accordingly); the package degrades to the existing SSE channels where no backend exists.
    • Wheels core stays SSE-only — the AdonisJS/Transmit position is unchanged; WS is an additive opt-in transport for the same publish()/subscribeToChannel() semantics.

    Correction for the record: earlier comments here said "Lucee has cfwebsocket" — Lucee core has no WS; its path is the community WebSocket extension. Adobe's cfwebsocket is deprecated; BoxLang has none.

    Closing this issue as completed — its deliverable was a go/no-go recommendation, and the demand probe it called for is live at #3286 (now informing engine-backend prioritization rather than the base decision). Implementation tracking moves to the new package issue linked below.

  5. bpamiri commented on Jul 6, 2026

    @bpamiri
    CollaboratorAuthor

    Implementation tracking for the package decision above: #3292 (wheels-websockets — RustCFML native + Lucee emulated, demand-gated elsewhere).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementroadmapRoadmap / tracking issue grouping scheduled work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions