npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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

O 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)

  1. 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;
  1. Wrappers autenticados no seu app. O component nunca enxerga ctx.auth: quem autentica, decide a SALA e passa userId é 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).
  1. 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, contenteditable ou role="textbox" focado (a checagem sobe a árvore, então editor rico conta), nenhuma composição de IME no meio, nenhum acorde com Ctrl/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 via getModifierState("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 userId que a criou (beat alheio devolve false, flush/clear/leave alheios são no-op);
  • Amostras sanitizadas (finitas, t crescente, no máximo 32 por lote, k desconhecido rebaixado a movimento, no máximo 8 cliques-evento por lote) e name capado 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.