Repository navigation
docs(web/guides): add Channels pub/sub guide with honest WebSocket status - #3004
Conversation
…atus Channels (SSE pub/sub via publish() / subscribeToChannel() / channelSSETag()) have shipped since 4.0 with zero user-facing documentation. Add a guide page at digging-deeper/channels documenting the real surface, verified against vendor/wheels source: - publish() return shapes per adapter (memory vs database) - subscribeToChannel() args incl. Last-Event-ID auto-detection - channelSSETag() incl. the Wheels.Channel.MissingEndpoint throw and an honest caution that its generated script only relays default 'message' events (it wires onmessage only — named event types are not dispatched) - memory vs database adapters, wheels_events schema, post-#2940 bounded/ throttled publish-path cleanup numbers, scheduled-sweep recommendation - WheelsSSE JS client incl. the fact it is NOT served at any URL — it must be copied from vendor/wheels/public/assets/js/wheels-sse.js into the app - an explicit WebSocket status section: Wheels ships no WebSocket layer; channels are SSE-only by design (AdonisJS Transmit prior art); the client-to-server path is a plain POST + publish() (chat-room pattern); roadmap link to ##2962 Cross-link the SSE guide both ways (WebSockets paragraph + Related guides card), register the page in the v4-0-0 sidebar JSON (the sidebar is explicitly configured, not autogenerated from frontmatter), and fix two stale claims in the internal .ai channels doc (wheels-sse.js URL that never existed; pre-#2940 cleanup cadence). Docs-only; no changelog fragment. Refs #2962 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
This is a docs-only wave-4 slice from the #2962 roadmap: a new user-facing Channels guide, two corrections to the internal .ai/wheels/channels/channels.md, a cross-link from the SSE guide, and a sidebar entry. No runtime code is touched. The research is careful — the PR author verified retention/cleanup numbers against the #2940 source, caught the phantom JS-client URL, and correctly identified the onmessage-only limitation of channelSSETag(). Overall verdict: comment (two documentation accuracy findings and one dead-frontmatter nit, nothing blocking).
Correctness
Quick start example is self-contradictory — the wheels:sse listener shown would never fire
File: web/sites/guides/src/content/docs/v4-0-0/digging-deeper/channels.mdx, lines 72–100
The quick start shows three things together:
- A subscribe action with
events = "notification,alert" - A model callback that publishes
event = "notification" - A view calling
channelSSETag()followed by JS that listens forwheels:sseCustomEvents
The Aside immediately after correctly explains the limitation: channelSSETag() generates a script that wires only src.onmessage, which the browser fires only for the default message SSE event type (frames with no event: field, or event: message). Events framed as event: notification are named SSE events — the browser silently ignores them from onmessage and would only reach a notification-type addEventListener you never attached. Confirmed by vendor/wheels/controller/channels.cfc line 131:
src.onmessage = function(e) {
document.dispatchEvent(new CustomEvent('wheels:sse', ...));
};So the document.addEventListener('wheels:sse', ...) shown in the quick start would never be called for the notification events being published. A reader following the example sees nothing happen, then has to find the Aside buried after the code.
Suggested fix: make the quick start internally consistent. Either change the publish call to the default event type so channelSSETag() works —
publish(
channel = "user.#this.userId#",
event = "message", // works with channelSSETag()
data = SerializeJSON({title: "Order received", orderId: this.id})
);— or swap channelSSETag() for a plain WheelsSSE constructor (which wires addEventListener) so the named-event path works end to end. Move the Aside to a later section explaining the channelSSETag() + named-event gotcha in context.
Docs
$getChannelEngine() is a $-prefixed internal function; exposing it in the user-facing guide without a caveat sets a breakage trap
File: web/sites/guides/src/content/docs/v4-0-0/digging-deeper/channels.mdx, line 153
var adapter = $getChannelEngine("database");
adapter.cleanup(olderThanMinutes = 60, maxRows = 10000);By Wheels convention (CLAUDE.md section on $-prefixed mixins), functions prefixed with $ are internal implementation details not intended for direct caller use — even though $getChannelEngine happens to be declared public in Global.cfc (required by the mixin system). The internal doc (.ai/wheels/channels/channels.md) uses this pattern too, but that doc targets framework contributors, not end users.
A user following this guide and scheduling a cleanup job against $getChannelEngine would be calling an unstable API. Suggested approaches (pick one):
- Add an inline note:
$getChannelEngineis an internal helper — its signature may change across minor versions. - Or replace with a direct instantiation that does not depend on the app-scope singleton and is stable across versions:
CreateObject("component", "wheels.channel.DatabaseAdapter").init()— this has the same cleanup effect in a standalone scheduled task.
sidebar.order: 7.5 in frontmatter is dead configuration (minor nit)
File: web/sites/guides/src/content/docs/v4-0-0/digging-deeper/channels.mdx, lines 5–7
sidebar:
order: 7.5The PR body correctly notes that the v4-0-0 sidebar is explicitly configured in web/sites/guides/src/sidebars/v4-0-0.json — Starlight's autogenerated sidebar.order frontmatter is ignored when the sidebar is explicit. The v4-0-0.json entry controls the actual position; this frontmatter field is silently ignored and could mislead a future editor into thinking it does something. Safe to remove.
Wheels Bot — Reviewer B (round 1)A's review is accurate and well-evidenced. All three findings are technically correct, the verdict of SycophancyNone detected. A cites specific lines, explains reasoning, and issues False positivesNone detected. On Finding 1 (quick start / named events): A's characterization of the example as "self-contradictory" is slightly strong — the Aside is immediately adjacent (not buried) and the example appears to be a deliberate "here's the gotcha" teaching device. But A's technical claim is correct: On Finding 2 ( On Finding 3 (dead frontmatter): Confirmed by the sidebar JSON diff — the Missed issuesNone of consequence. PR is docs-only; no runtime code, no migration, no cross-engine surface. Commit type Verdict alignmentA's ConvergenceAligned, changes needed. A's three findings hold up. The concrete ask: (1) make the quick start example internally consistent so it works end-to-end without requiring an Aside to understand ( |
- Make quick start internally consistent: drop named-event filter from the subscribe action and publish with `event = "message"` so the default `onmessage` handler that channelSSETag() wires actually catches the event. Replace the trailing Aside with a forward pointer to the channelSSETag() reference for named-event handling. - Relocate the named-event caution to the channelSSETag() API reference where the gotcha is most relevant. Remove the now-broken cross-link from the helper description. - Add a stability caveat to the $getChannelEngine() cleanup example noting it's a `$`-prefixed internal helper, with a direct-instantiate alternative for callers that want a non-internal API. - Remove the dead `sidebar.order: 7.5` frontmatter — the v4-0-0 sidebar is configured explicitly in `sidebars/v4-0-0.json`, so the field is silently ignored. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Wheels Bot — Address Review (round 1)Applied consensus findings from Reviewer A and Reviewer B's convergence (round 1):
No skipped findings — all three consensus items addressed. The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
Round 2 of the channels documentation PR. The first-round findings were all addressed cleanly: dead sidebar.order: 7.5 frontmatter removed, the $getChannelEngine Aside correctly flags the internal nature and offers a stable CreateObject alternative, and the quick start's named-event mismatch is fixed — publish now uses event = "message" so channelSSETag()'s onmessage handler actually fires. One correctness issue surfaced that the round-1 edits introduced: the subscribe action in the quick start reads params.userId to reconstruct the channel name, but channelSSETag() sends the full channel string as params.channel — params.userId is never present in the SSE endpoint's URL params, so the subscription listens on "user." instead of "user.42" and no events arrive. Verdict: request changes.
Correctness
Quick start subscribe action reads params.userId but channelSSETag() sends params.channel
File: web/sites/guides/src/content/docs/v4-0-0/digging-deeper/channels.mdx, line 58
function stream() {
subscribeToChannel(channel = "user.#params.userId#");
}The view (line 88) calls channelSSETag(channel="user.#params.userId#", route="notificationStream"). channelSSETag() appends ?channel=<encoded-string> to the SSE endpoint URL (vendor/wheels/controller/channels.cfc line 123):
local.url &= (Find("?", local.url) ? "&" : "?") & "channel=" & EncodeForURL(arguments.channel);So the browser connects to /notifications/stream?channel=user.42. That is a new HTTP request; params in the subscribe action contains only channel=user.42. There is no params.userId — the action evaluates "user.#params.userId#" as "user." and subscribes to the wrong channel. Events published to "user.42" are never delivered.
The guide's own API reference (lines ~201–205) calls this out explicitly: "the channel and events arrive at your action as URL parameters; your action decides what to do with them (typically subscribeToChannel(channel=params.channel, events=params.events ?: "") after validating the client may subscribe)". The quick start contradicts that guidance.
Two correct options:
Option A — validate then pass through (matches the API reference guidance):
function stream() {
// Caller may only subscribe to their own channel
if (params.channel == "user.#session.userId#") {
subscribeToChannel(channel = params.channel);
} else {
renderNothing(status = 403);
}
}Option B — derive the channel from the session, ignore the param entirely (the pattern already shown under "Per-user notifications" later in the guide):
function stream() {
subscribeToChannel(channel = "user.#session.userId#");
}Option B is simpler and more secure; it makes the channel argument passed to channelSSETag() superfluous for the action's logic (though channelSSETag() still needs it to build the URL). Option A is what the API reference describes. Either is correct; the current code is neither.
The quick start's stream() action subscribed with channel="user.##params.userId##", but the SSE request generated by channelSSETag() carries only the channel (and optional events) URL parameters (vendor/wheels/controller/channels.cfc lines 122-126) — params.userId was undefined on every EventSource connect, so the endpoint threw and EventSource retried forever. Derive the channel server-side from session.userId in both the action and the view tag, matching the guide's own Per-user notifications pattern, and explain why the action deliberately ignores the client-supplied channel parameter (with a pointer to the validated params.channel wiring under channelSSETag()). The other half of the review finding — publishing event="notification" while channelSSETag relays only default message-type events — was already fixed in the round-1 commit (publish now uses event="message"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
Wheels Bot — Reviewer B (round 1)A's review is clean. The single correctness finding is genuine and well-evidenced: I verified Sycophancy None detected. A requests changes and cites the specific source line responsible for the behaviour. False positives None detected. A's claim that Missed issues None detected. The diff is docs-only. A's two proposed options (validate + pass through Verdict alignment
Convergence Aligned: the finding is real and the fix is unambiguous. The quick start |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
This PR delivers the user-facing Channels guide (Wave 4 of the #2962 roadmap), fixes two stale claims in the internal reference doc, and threads a Channels cross-link into the existing SSE page. Docs-only, no runtime code touched. Overall verdict: comment — one actionable nit, everything else checks out cleanly.
Docs
$getChannelEngine() in the scheduled-task example will not work as written
In web/sites/guides/src/content/docs/v4-0-0/digging-deeper/channels.mdx, the fenced code block titled illustrative — scheduled task body:
var adapter = $getChannelEngine("database");
adapter.cleanup(olderThanMinutes = 60, maxRows = 10000);$getChannelEngine() is a bare function call that resolves only through the Wheels mixin system — it is available inside a CFC that has been mixed in (a Wheels Job, a controller, etc.) but not in a standalone .cfm scheduled task page. A reader copying this into a task page would get Function $getChannelEngine is undefined at runtime.
The Aside immediately below the snippet already recommends the correct alternative:
Instantiate the adapter directly:
CreateObject("component", "wheels.channel.DatabaseAdapter").init()—cleanup()works the same on both.
The fix is to promote that form to the primary example:
var adapter = CreateObject("component", "wheels.channel.DatabaseAdapter").init();
adapter.cleanup(olderThanMinutes = 60, maxRows = 10000);If you want to keep the $getChannelEngine form, scope the title to its actual context: illustrative — inside a Wheels Job.
Everything else checked against source:
- Cleanup numbers (60-min retention, 5-min/15-s throttle, 1 000-row cap) verified against
vendor/wheels/channel/DatabaseAdapter.cfclines 27-31 and 217-222. Pass. channelSSETag()only wiresonmessage(named SSE event types not relayed) verified againstvendor/wheels/controller/channels.cfclines 131-132; the Aside caution is accurate. Pass.- JS client not served at any URL — the old phantom
/wheels/assets/js/...claim in.ai/wheels/channels/channels.mdis correctly replaced;vendor/wheels/public/assets/js/wheels-sse.jsconfirmed present. Pass. - API tables for
publish,subscribeToChannel,channelSSETag, and theWheelsSSEconstructor match the CFC source signatures. Pass. - Quick start uses
session.userIdserver-side rather than a client-suppliedparams.channel, and calls out the security reason explicitly. Pass. - Sidebar JSON slots the entry between Server-Sent Events and Internationalization as described. Pass.
- No changelog fragment — correct; the fragment requirement covers
fix/featPRs, notdocs. Pass.
Commits
All three headers use the valid docs(web/guides): prefix and are well under the 100-char limit.
| SHA | Header | Chars |
|---|---|---|
f046f3f |
docs(web/guides): add Channels pub/sub guide with honest WebSocket status |
73 |
58151f5 |
docs(web/guides): address Reviewer A/B consensus findings (round 1) |
67 |
02f24a7 |
docs(web/guides): make channels quick start work verbatim |
57 |
Wheels Bot — Address Review (round 2)The consensus findings from Reviewer B's convergence on Verified against HEAD:
Skipped (intentionally not part of the consensus this round):
No new commit this round. The convergence loop continues — the next Reviewer B run on |
Wheels Bot — Reviewer B (round 1)A's review is substantive and the Sycophancy: None detected. A verified cleanup numbers, the False positives: None detected. The Missed issues: The Per-user notifications usage pattern still publishes with Verdict alignment: A used COMMENTED instead of CHANGES_REQUESTED for a finding where the recommendation is to change the primary code example. CHANGES_REQUESTED would be more precise, but COMMENTED with an explicit fix suggestion maps to the same convergence outcome. Convergence: Aligned with A on two actionable fixes — (1) change the |
What
Wave-4 slice of the #2962 roadmap: the channels documentation item only. Channels (the SSE pub/sub layer:
publish(),subscribeToChannel(),channelSSETag(),wheels.Channel,wheels.channel.DatabaseAdapter,wheels-sse.js) have shipped since 4.0 with zero user-facing docs — only the internal.ai/wheels/channels/channels.mdexisted, and it contained two stale claims.web/sites/guides/src/content/docs/v4-0-0/digging-deeper/channels.mdx— quick start, configuration, memory vs database adapters (incl.wheels_eventsschema and the post-fix(sse): blank-line framing, DB cursor advance, channel pruning, bounded retention cleanup #2940 bounded/throttled cleanup numbers: 60-min retention, sweep at most every 5 min / 15 s while a backlog drains, max 1,000 rows per pass, scheduledcleanup()recommendation), full API tables verified againstvendor/wheelssource,WheelsSSEJS client, usage patterns, auth + deployment cross-links into the SSE guide.publish()(chat-room pattern shown); roadmap link back to roadmap: high-impact application feature gaps — auth scaffold/generator, authorization policy layer, file attachments (Active Storage), channels docs + WebSocket parity #2962.wheels-sse.jsis not served at any URL — it must be copied fromvendor/wheels/public/assets/js/wheels-sse.jsinto the app (javaScriptIncludeTag). The internal doc claimed a/wheels/assets/js/...URL that has never existed.channelSSETag()'s generated script wires onlyonmessage, so it relays only defaultmessage-type events — named event types needWheelsSSE/addEventListener. Documented as a caution (verified against$formatSSEEvent+ the generated script).server-sent-events.mdx): WebSockets paragraph now states Wheels ships no WebSocket layer and points at Channels; Channels LinkCard added to Related guides.web/sites/guides/src/sidebars/v4-0-0.jsondirectly after Server-Sent Events — the sidebar is explicitly configured there, not autogenerated from frontmatter (the verify-scout plan assumed frontmattersidebar.orderwould slot it in; it would not)..ai/wheels/channels/channels.md): fixed the phantom JS-client URL and the pre-fix(sse): blank-line framing, DB cursor advance, channel pruning, bounded retention cleanup #2940 "cleanup runs every 5 minutes" wording so internal and external docs agree.Out of scope (per the roadmap split): auth scaffold, policy layer, file attachments, any WebSocket code. No runtime code touched. Docs-only — no changelog fragment.
Test evidence
pnpm buildgreen (433 pages); Channels renders, sidebar order verified (SSE → Channels → i18n), all cross-page anchor targets verified present in built HTML.wheels cfmlexecution failures in other pages. The new page adds no{test:*}blocks (controller/publish()examples need app context, per harness rules).wheels.tests.specs.internal.testClientSpec(environment-specific, present on clean develop, untouched by this docs-only diff).Suggested issue comment (roadmap sequencing for the remaining features)
Refs #2962
🤖 Generated with Claude Code