Pointers Channel — Ephemeral Live Cursors
Live WS channel
pointers: subscribe per document path, send your own pointer/mouse position, and receive the positions of other active participants. Pure fan-out — no persistent state, no history, best-effort. The framework transports opaque coordinates; interpretation (zoom, pan, canvas transform) is up to the application. Cross-Pod via Redis Pub/Sub.Status: v1 in production. First consumer: the Canvas app (
app-canvasbook.md/doc-kind-canvas.md).See also
live-ws.md(envelope framework, channel inventory) anddocuments-channel.md(the subscribe/identity pattern reused here). Refactor history:planning/pointers-channel.md.
1. Purpose
Awareness on spatial surfaces: multiple participants (or multiple tabs of the same user) see where others are currently working — one live cursor per participant. Purely additive, opt-in per application.
The channel is deliberately separated from the two documents channel
exclusions (documents-channel.md §11: “Cursor-Position-Sharing — deliberately
out”; live-ws.md §9: CRDT deliberately out). pointers does not implement
a collab editor, no edit sync, no CRDT — only ephemeral pointer awareness on a
surface. The documents channel remains cursor-free.
2. Wire Format
Each frame is in a LiveEnvelope with
channel: "pointers":
{ "channel": "pointers",
"payload": { "type": "<frame-type>", "data": { ... } } }
type values are collected in MessageType:
Type (type) |
Direction | Payload |
|---|---|---|
subscribe |
C → S | { path } |
unsubscribe |
C → S | { path } |
unsubscribe-all |
C → S | empty |
pointer-move |
C → S | { path, x, y, data? } |
pointer |
S → C | { path, editorId, userId, displayName, x, y, data? } |
pointer-leave |
S → C | { path, editorId } |
x/y—double, opaque app-space coordinates. The server does not interpret them (no clamping, no normalization, no transform).data— optional, typed additional map (Map<String, Object>) for app extras (e.g., pointer color, pointer type). The server passes it through 1:1 without reading it.- Identity is server-derived, not client-asserted. The sender sends
pointer-movewithout its own identity; the server appendseditorId/userId/displayNamefrom the authenticatedConnectionContextto the outgoingpointerframe — analogous to thedocumentschannel. This makes the display name unspoofable. A freely selectable display label per surface belongs in the optionaldata, not in a second name field.
2.1 Self-Filter
pointer and pointer-leave frames are not delivered to the triggering
WS connection (filter in the broadcaster by editorId). The sender never sees
their own pointer echoed back. Two tabs of the same user have different
editorIds and therefore see each other as separate cursors.
2.2 Subscribe Limit + Rate Cap
- 100 subscriptions per connection (like
documents). A Canvas app typically needs one. - 60
pointer-move/s per connection (Fixed-Window). Excess moves are silently dropped (no error frame — an error per drop would itself cause flooding). The client coalesces anyway to ~30 Hz (§6).
3. Server Architecture
Mirrored to ws.documents, but without Roster state:
| Class | Responsibility |
|---|---|
PointerChannelHandler |
Frame demux (subscribe/unsubscribe/unsubscribe-all/pointer-move), subscribe cap + rate cap |
PointerBroadcaster |
Per-connection subscription set (RAM), Redis Pub/Sub + local fan-out + self-filter, leave-on-disconnect |
No DocumentSubscriberRegistry equivalent with Redis-HASH. The only
server-side state is the per-connection subscription set in RAM (for
leave-on-disconnect + local fan-out addressing). “Who is pointing now” is
derived purely from the frame stream.
3.1 Redis Pub/Sub (Cross-Pod only)
Topic: vance:{tenantId}:pointers
Payload: "{podId}|{base64(path)}|{editorId}|{x}|{y}|{userId}|{base64(displayName)?}|{base64(dataJson)?}"
Topic: vance:{tenantId}:pointers.leave
Payload: "{podId}|{base64(path)}|{editorId}"
Self-echo via podId (Per-Process-UUID) discarded — like
documents.changed. Receiver pods fan out to their local subscribers of the
path.
3.2 No Redis → local-only
vance.redis.enabled=false: the channel works locally (fan-out between
subscribers on the same pod), but not cross-pod — exactly the failure mode
pattern of the documents channel. Sufficient for single-pod dev.
4. Delimitation from the documents Channel
documents |
pointers |
|
|---|---|---|
| Trigger | Document write (Save) | Pointer movement (~30/s) |
| Server State | Redis-HASH-Roster + 90s-TTL + Heartbeat | none (pure fan-out) |
| Redis | HASH (Roster) + Pub/Sub | only Pub/Sub |
| Delivery | reliable-ish (Roster reconstructible) | best-effort, fire-and-forget |
| Lifetime | survives reconnect via TTL | ends with the frame stream |
Separate channels keep both delivery semantics clean — pointer positions would pollute a roster with high-frequency writes.
5. Lifecycle — Cursors Appear & Disappear
Without a persistent roster, “who is pointing” is derived purely from the frame stream. Three cleanup paths:
- Explicit
pointer-leave— Client sendsunsubscribeonpointerleave/tab blur/editor unmount. Server broadcastspointer-leaveto others. - WS-Disconnect — the local pod broadcasts a
pointer-leavefor each subscribed path combination of the connection (from the per-connection subscription set). Cross-pod is covered by path 3. - Client-TTL-Fade (Primary Safeguard) — the receiver client fades out a foreign pointer if no update has arrived for ~5 s. Robustly catches lost leaves, cross-pod gaps, and network disconnections without server state.
6. Client Architecture
usePointers({ path, throttleHz?, ttlMs? }) —
Composable in @vance/shared.
Located in @vance/shared (not in vance-face) so that host editors and
Module Federation addons (Canvas) share it; WS access is via the
__VANCE_WS__ bridge.
- Sender:
report(x, y, data?)— coalesces onrequestAnimationFrameatthrottleHz(default 30), sendspointer-move. The app calls it from itspointermovehandler with app-space coordinates (after its own zoom/pan inverse). - Listener: reactive
Map<editorId, RemotePointer>withlastSeen, TTL-fade (ttlMs, default 5000) via a 1s interval. - Rendering is app-specific: The composable only provides app-space coordinates. The application applies its current transform to position the pointer overlays (zoom-correct).
On reconnect (new editorId), the wsConnectionStore replays the desired
subscriptions; old foreign pointers fade out via TTL.
6.1 Canvas Integration
CanvasEditor.vue calls usePointers({ path }), calculates screen → flow
coordinates in the pointermove handler via the VueFlow viewport inverse
((client - rect - viewport) / zoom), and renders a cursor overlay for each
RemotePointer at viewport + flow * zoom with displayName label and
data.color. Applies to the editable app view and the read-only
kind: canvas view.
6.2 Workbook Integration
WorkbookAppKind.vue uses the same channel, scoped to the path of the active
page. A Workpage is scrolling, wrapping flowing text — not a 2D plane like
Canvas. Therefore, the two signals are deliberately modeled
differently:
- Mouse Pointer — opaque
x/yin the coordinate system of the editor content element (getContentEl()= ProseMirror-view.dom). Sender measures relative to its livegetBoundingClientRect(), receiver resolves against its content element. Because the content element moves with scrolling (itsrect.topshifts), scrolling is automatically covered — noscrollTopcalculation. Reflow with a narrower window makes the mouse slightly inaccurate; for a mouse pointer, this is acceptable. - Active Node — never pixels and no character caret. What is shared
is the logical position of the block where the peer is currently working
(
data.node.pos, fromgetActiveBlockPos()= start of the top-level block). The receiver resolves it viablockRectAtPos(pos)against its own layout to the block element rectangle and highlights this block. Block awareness (“what is the person working on”) is calmer and more stable than a character-accurate caret (no jitter per keypress) and remains correct with different window widths / wrapping / scrolling.
The block editor (@vance/block-editor WorkPageEditor) exposes
getActiveBlockPos(), blockRectAtPos(pos), getContentEl(), and an
@selection event (ProseMirror-onSelectionUpdate) for this purpose.
Keyboard navigation (block change without mouse) re-reports with the last
known mouse position. The receiver recalculates everything per frame against
its layout (reactive to scroll + resize) and renders the mouse cursor and
a block highlight (colored bar on the left + name tag). data.node is a pure
app convention — the framework only knows “opaque data”. A character-accurate
caret or selection range would be a later, purely additive refinement
(co-editing); v1 deliberately remains at the block level.
7. Failure Modes
| Scenario | Behavior |
|---|---|
vance.redis.enabled=false |
Channel operates pod-locally; no cross-pod cursors. Brain continues normally. |
| WS reconnect | New editorId; desired subscriptions are replayed; old foreign pointers fade via TTL. |
WS-Close without unsubscribe |
Leave-on-Disconnect broadcasts pointer-leave locally; cross-pod is covered by client TTL. |
Lost pointer-move/pointer-leave |
Best-effort — the next move replaces the position, TTL cleans up dead cursors. |
| Flooding client | Server rate cap (60/s) drops silently; client coalesces to 30 Hz. |
8. Code Anchors
| Aspect | File |
|---|---|
| Channel Handler | vance-brain/.../ws/pointers/PointerChannelHandler.java |
| Broadcaster + State | vance-brain/.../ws/pointers/PointerBroadcaster.java |
| Channel Demux (Routing) | vance-brain/.../ws/LiveWebSocketHandler.java (pointers-Case + Leave-on-Close) |
| Wire DTOs | vance-api/.../ws/PointerMoveRequest.java, PointerNotification.java, PointerLeaveNotification.java, PointerSubscribeRequest.java |
| MessageType | vance-api/.../ws/MessageType.java (POINTER*-Konstanten) |
| Client Composable | @vance/shared/ws/usePointers.ts |
| Bridge API | @vance/shared/ws/bridge.ts (VanceWsApi Pointer methods) |
| Host Wiring | vance-face/src/ws/wsConnectionStore.ts (pointers-Section) + vance-face/src/platform/bootWeb.ts (configureVanceWs) |
| Canvas Consumer | vance-addon-brain-canvas/client/src/CanvasEditor.vue |
| Workbook Consumer | vance-addon-brain-workbook/client/src/WorkbookAppKind.vue (Mouse + data.node) |
| Tests | vance-brain/.../ws/pointers/PointerBroadcasterTest.java |
9. Not in Scope (Deliberately)
- Roster / Presence list — who is subscribed without moving. Awareness
comes solely from the pointer stream; for “who is there” without movement,
the
documentschannel is responsible. - Persistence / History / Replay of pointer positions.
- Reliable delivery / Buffering — fire-and-forget.
- Selection / Editing Sync, CRDT — only pointer position (+ opaque
data). - Coordinate interpretation in the framework — Zoom/Pan/Transform is done by the application.