convex-multiplayer-cursors
v0.2.0
Published
Figma-style real-time multiplayer cursors for Convex: a reusable component (room presence with heartbeat/TTL, batched cursor trails) plus a React overlay with interpolated replay.
Maintainers
Readme
convex-multiplayer-cursors
Cursores multiplayer em tempo real (estilo Figma) para Convex:
um component reutilizável (tabelas e funções isoladas, montado com app.use)
mais um cliente React com replay interpolado. Presença por sala com heartbeat e
TTL, posições em lotes de amostras, ripple de clique e chat de cursor (tecla
/), tudo em TypeScript puro.
Por que é barato
O custo de multiplayer no Convex é definido por QUEM cada escrita invalida. O component separa três tabelas por perfil de invalidação:
| Tabela | Escrita | Quem subscreve |
| ----------- | ----------------------------- | ------------------------- |
| sessions | join/leave/troca de nome | roster (frio) |
| beats | heartbeat (a cada ~20s) | NINGUÉM |
| positions | flush de lote (~5/s movendo) | posições da sala (quente) |
| bubbles | enquanto alguém digita | chat da sala (morno) |
Ripple de clique não ganha tabela NENHUMA: o clique viaja como amostra marcada
(k: "click") dentro do lote de positions que já existia, e dispara no
receptor exatamente quando o playhead interpolado o cruza.
Consequências: heartbeat não re-executa query nenhuma; movimento invalida só a
query de posições; o roster React só re-renderiza em join/leave. No cliente, as
posições chegam por watchQuery fora do React e vão direto para o DOM num rAF
(zero re-render por movimento). A saída é single-flight: no máximo uma mutation
no ar, lote de amostras a cada ~200ms só ENQUANTO o ponteiro se move.
A reprodução atrasa lagMs (~260ms) de propósito: sempre existe a próxima
amostra para interpolar, então o traço do cursor remoto é contínuo, não
teleporte. Limpeza é dupla: um expire agendado por beat (releem o deadline e
viram no-op se a sessão rebateu) e poda oportunista pelos beats vivos da sala.
Instalação
npm i convex-multiplayer-cursorsO pacote é TS puro, sem build: o esbuild do Convex, o Vite e o tsc com
moduleResolution: "bundler" consomem a fonte direto. No Next, adicione o
pacote a transpilePackages.
Integração (3 passos)
- Montar o component em
convex/convex.config.ts:
import { defineApp } from "convex/server";
import cursors from "convex-multiplayer-cursors/convex.config";
const app = defineApp();
app.use(cursors);
export default app;- Wrappers autenticados no seu app. O component nunca enxerga
ctx.auth: quem autentica, decide a SALA e passauserIdé você (regra de ouro de components). Exemplo mínimo:
// convex/cursors.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { Cursors, cursorSampleValidator, roomKey } from "convex-multiplayer-cursors/client";
const cursors = new Cursors(components.cursors);
async function requireUser(ctx: { auth: { getUserIdentity: () => Promise<any> } }) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("não autenticado");
return identity;
}
export const sessions = query({
args: { path: v.string() },
handler: async (ctx, args) => {
const user = await requireUser(ctx);
// A sala nasce no SERVIDOR: escopo seu (org, doc, board) + path normalizado.
return await cursors.listSessions(ctx, roomKey(user.orgId ?? "global", args.path));
},
});
export const beat = mutation({
args: { path: v.string(), sessionId: v.string() },
returns: v.boolean(),
handler: async (ctx, args) => {
const user = await requireUser(ctx);
return await cursors.beat(ctx, {
room: roomKey(user.orgId ?? "global", args.path),
sessionId: args.sessionId,
userId: user.subject,
name: typeof user.name === "string" ? user.name : undefined,
});
},
});
// positions (query), flush/clear/leave (mutations): mesmo formato,
// sempre passando userId do servidor. Veja os tipos em ./react (CursorsApi).- Overlay na UI dentro de um elemento com
position: relative:
import { CursorsOverlay } from "convex-multiplayer-cursors/react";
import { api } from "../convex/_generated/api";
<main style={{ position: "relative" }}>
<CursorsOverlay
api={{
sessions: api.cursors.sessions,
positions: api.cursors.positions,
beat: api.cursors.beat,
flush: api.cursors.flush,
clear: api.cursors.clear,
leave: api.cursors.leave,
}}
args={{ path: currentPath }}
broadcast={canWrite}
/>
</main>broadcast={false} assiste sem transmitir. getLabel, palette, zIndex e
knobs ajustam rótulo, cores e cadências. Para desenhar o próprio overlay, use
o hook useCursors e a matemática pura exportada (timeline, sampler).
Ripple de clique
Ligado por padrão (clicks={false} desliga). Clique esquerdo dentro da área
mostra um anel local na hora e transmite uma amostra k: "click" no lote
normal; os overlays remotos reproduzem o anel na cor de quem clicou quando o
playhead cruza o clique. Zero query extra, zero tabela extra. Clique cujo
gesto o app reivindicou (preventDefault no pointerdown) nunca vira
ripple, e um cap por lote rebaixa rajada de cliques a movimento comum no
servidor.
Chat de cursor
Aperte /, digite, os outros veem a bolha colada no seu cursor ao vivo.
Enter "envia" (a mensagem fica ~4s esmaecendo para os outros e o input
limpa para a próxima), Esc com texto aborta o pensamento (a bolha some para
todo mundo), Esc com input vazio só fecha. Texto enviado esmaece sozinho;
ninguém precisa fechar nada.
Para ligar, exponha dois wrappers extras (mesmo formato dos outros) e o overlay acende sozinho:
export const chats = query({ /* auth */ ...cursors.listChats(ctx, room) });
export const sendChat = mutation({ /* auth */ ...cursors.sendChat(ctx, { room, sessionId, userId, text }) });Host sem esses wrappers continua funcionando exatamente como antes: a feature
só fica desligada. chat={false} força desligado mesmo com wrappers.
Dividir o / com o seu app foi desenhado a favor, não contra:
- A hotkey só abre quando ninguém mais é dono da tecla: nenhum
input,textarea,select,contenteditableourole="textbox"focado (a checagem sobe a árvore, então editor rico conta), nenhuma composição de IME no meio, nenhum acorde comCtrl/Meta, nenhuma tecla segurada. - O listener roda em BUBBLE e respeita
defaultPrevented: se o seu app reivindica o/primeiro (command palette, busca), o chat cede. - Layouts com AltGr (onde
/chega como ctrl+alt no Windows) são reconhecidos viagetModifierState("AltGraph"). chat={{ hotkey: "c" }}troca a tecla;chat={{ hotkey: false }}desliga só o atalho, mantendo as bolhas remotas visíveis.- Com o input do chat aberto, as teclas não propagam: atalho document-level do app não dispara no meio da frase.
A escrita do chat é throttled leading+trailing no cliente (~150ms, o último
estado sempre chega) com piso server-side atrás, o texto é sanitizado e
capado no servidor (200 caracteres, controle removido, surrogate nunca
partido), renderizado via textContent (nunca HTML), e cada sessão tem UMA
bolha, apagada em leave/expire e podada se abandonada.
chat={{ maxLength, lingerMs, placeholder }} ajusta o resto.
Guard-rails server-side
A cadência do cliente é sistema de honra; o contrato mora no component:
- Lotação da sala imposta na ESCRITA (join numa sala cheia devolve
false, então roster e trilhas nunca divergem por truncagem); - Flushes da mesma sessão abaixo de 66ms são descartados;
- Toda sessão pertence ao
userIdque a criou (beat alheio devolvefalse, flush/clear/leave alheios são no-op); - Amostras sanitizadas (finitas, t crescente, no máximo 32 por lote,
kdesconhecido rebaixado a movimento, no máximo 8 cliques-evento por lote) enamecapado em 120 caracteres; - Bolha de chat tem dono (escrita alheia devolve
false), texto capado no servidor, reescrita abaixo de 50ms descartada (apagar nunca é), bolha velha filtrada da leitura e podada pelo próprio heartbeat do dono.
No cliente: beat recusado re-chaveia o sessionId sozinho (cobre sala lotada
e id expirado tomado), o heartbeat pula abas ocultas (a sessão expira UMA vez e
renasce no retorno; timers estrangulados não flapam) e flush não sai sem sessão
confirmada.
Testes
109 testes no pacote: npm test (component como mini-backend, via
convex-test, incluindo scheduler
com fake timers) e npm run test:pure (timeline, sampler, replay de eventos
do ripple, compatibilidade da hotkey, throttle do chat e apresentação de
bolhas em node:test; Node 24). Consumidores registram o component nos
próprios testes:
import cursorsTest from "convex-multiplayer-cursors/test";
cursorsTest.register(t, "cursors");Defaults
TTL 45s, heartbeat 20s, flush 200ms, lag 260ms, 32 amostras por lote, 128
sessões por sala. Chat: hotkey /, throttle 150ms, linger 4s, 200
caracteres, piso do servidor 50ms, TTL de bolha abandonada 30s. Coordenadas
são pixels no referencial do elemento host.
Subindo de 0.1.x: schema e API aditivos (tabela bubbles, k opcional nas
amostras). Publique o component novo antes ou junto do bundle novo do
cliente, que é o que npx convex deploy já faz numa tacada.
Honestidade de custo: se o seu wrapper lê identidade no guard (deve), cada usuário re-executa a própria cópia das queries quando a sala acorda; o que a arquitetura garante é o mínimo de ACORDARES (heartbeat zero, movimento só na query quente), não uma execução única compartilhada.
Créditos
Nasceu no monorepo da Amage (primeiro consumidor: um módulo interno em produção). O desenho de invalidação é inspirado no @convex-dev/presence e o lote de amostras + single-flight no exemplo oficial multiplayer-cursors da Convex.
MIT.
