Edição colaborativa em tempo real (Yjs + Reverb)
Esta página descreve como habilitar edição colaborativa multi-usuário em campos de Resources do Arqel — ao estilo Google Docs / Notion — usando o pacote arqel-dev/realtime no servidor e @arqel-dev/realtime-collab no cliente. A solução combina:
- Yjs (CRDT) para resolver merges concorrentes sem servidor de coordenação central.
- Laravel Reverb como WebSocket broadcaster oficial.
- Echo (cliente) para subscrição ao canal privado.
- Persistência de snapshot via REST com optimistic concurrency.
Como funciona (TL;DR)
- Cada
(modelType, modelId, field)colaborativo tem umY.Doclocal em cada cliente. - Toda mudança local é encodada como
Uint8Array(state vector) e propagada para os outros clientes via canal privadoarqel.collab.{modelType}.{modelId}.{field}(Reverb). - Periodicamente (debounce 2s default), o cliente envia um snapshot consolidado em base64 ao endpoint
POST /admin/{resource}/{id}/collab/{field}. O servidor persiste emarqel_yjs_documents(longBlob) e disparaEvents\YjsUpdateReceivedpara sincronizar clientes que perderam updates por reconnect. - CRDT garante que ordem de chegada não importa: dois usuários digitando simultaneamente convergem para o mesmo state.
Pré-requisitos
- Laravel 12+ (testado em 12.x e 13.x).
arqel-dev/realtimeinstalado e bootado (já vem em qualquer projeto que instalou o meta-packagearqel-dev/framework).- Setup mínimo de auth e policies — o canal aplica Gate
viewno record.
Instalação
Lado servidor
composer require laravel/reverb
php artisan reverb:install
php artisan migrateA migration 2026_05_06_000000_create_yjs_documents (já no arqel-dev/realtime) cria a tabela arqel_yjs_documents com unique (model_type, model_id, field) e blob para state.
.env:
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=local
REVERB_APP_KEY=local
REVERB_APP_SECRET=local
REVERB_HOST=localhost
REVERB_PORT=8080Inicie o worker:
php artisan reverb:startLado cliente
pnpm add @arqel-dev/realtime @arqel-dev/realtime-collab yjsSetup global de Echo (uma vez no bootstrap do Inertia):
import { setupEcho } from '@arqel-dev/realtime';
setupEcho({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: Number(import.meta.env.VITE_REVERB_PORT),
forceTLS: false,
});Usando <CollabRichTextField>
import { CollabRichTextField } from '@arqel-dev/realtime-collab';
export function PostEditor({ post }: { post: { id: number } }) {
return (
<CollabRichTextField
modelType="App\\Models\\Post"
modelId={post.id}
field="body"
persistUrl={`/admin/posts/${post.id}/collab/body`}
debounceMs={2000}
placeholder="Escreva aqui…"
/>
);
}O componente:
- Cria um
Y.Doclocal + subscreve ao canal Echoarqel.collab.App\Models\Post.{id}.body. - Hidrata o state inicial via
GET persistUrl(snapshot anterior, se houver). - Aplica updates remotos via
Y.applyUpdateautomaticamente. - Faz debounce do
POST persistUrlenquanto o usuário digita. - Renderiza um
<textarea>controlled. Para integração com ProseMirror/TipTap, use o hook diretamente.
Hook useYjsCollab
import { useYjsCollab } from '@arqel-dev/realtime-collab';
const { doc, text, status, applyRemote } = useYjsCollab({
modelType: 'App\\Models\\Post',
modelId: 42,
field: 'body',
persistUrl: '/admin/posts/42/collab/body',
});status transita por syncing → synced → offline (quando window.Echo não está disponível). applyRemote(update) aceita Uint8Array ou string base64 e despacha para o Y.Doc.
Channel authorization
O canal é registrado em arqel-dev/realtime (routes/channels.php):
Broadcast::channel(
'arqel.collab.{modelType}.{modelId}.{field}',
fn ($user, string $modelType, $modelId, string $field) =>
app(AwarenessChannelAuthorizer::class)->authorize($user, $modelType, $modelId, $field),
);AwarenessChannelAuthorizer:
- Resolve
$modelTypepara uma classe Eloquent — direto via FQCN ou viaResourceRegistry::all()matching porgetModel(). - Carrega o record com
Model::query()->find($modelId). - Verifica o Gate
view(quando registrado pela app); senão allow. - Defensive: qualquer
Throwableou registry unbound retornafalse(deny).
Para policies finas, defina view na sua PostPolicy e o realtime herda automaticamente.
Optimistic concurrency
O endpoint POST aceita {state, version}. Lógica:
- Se
incoming.version >= server.version→ grava + incrementaversion+ disparaYjsUpdateReceived. - Se
incoming.version < server.version→ retorna409 {message, serverVersion}. O cliente fazGETfresco, aplica seu state local viaY.mergeUpdatese re-tenta.
Performance
- Debounce de snapshot (default 2s) evita martelar o disco. Para edits raros, suba para 5s.
- broadcastWith envia state completo — em documentos muito grandes (>200KB) considere migrar para deltas via
y-protocols/sync+ WebSocket Reverb dedicado por documento. - Tabela
arqel_yjs_documentsusa unique(model_type, model_id, field). Garbage-collect snapshots antigos com job dedicado se a tabela crescer. - Reverb scaling: 1 worker Reverb suporta ~1000 conexões. Para mais, escale horizontalmente atrás de Redis (ver
laravel/reverbdocs). - Cliente: a integração textarea reescreve o
Y.Textinteiro em cada keystroke (simple). Para editores grandes, integrey-prosemirrorque preserva incrementalidade.
Eventos disponíveis
Arqel\Realtime\Events\YjsUpdateReceived— disparado em cada snapshot persistido.broadcastAs=collab.update. Use para integrações server-side (ex.: notificar no Slack quando um doc é editado).
Tests + mocking
Os testes do arqel-dev/realtime rodam com BROADCAST_CONNECTION=null + Event::fake() — você não precisa de Reverb para testar policies/handlers. No frontend, o hook fica em status offline quando window.Echo é undefined, permitindo testes em jsdom sem mocking pesado.
Limitações conhecidas
- A integração textarea atual rebinda o
Y.Textinteiro a cada keystroke. Para editores ricos (ProseMirror/TipTap), use o hook diretamente +y-prosemirror. - Não há ainda awareness (cursores remotos, selection highlighting). Roadmap: RT-006.
- Reconnects cobertos via snapshot resync — pode haver janela de 2s onde updates são "perdidos" no canal mas reaparecerão no próximo snapshot.
- O
modelTypeno canal é o FQCN da Eloquent — encode-o no client (ex.:App\\Models\\Post) para bater com o que oResourceRegistryregistra.
Próximos passos (roadmap)
- RT-006 — awareness (cursores remotos + presence colaborativa).
- RT-006.1 —
y-prosemirroradapter pré-configurado para integrar com<RichTextField />. - RT-006.2 — garbage collection automático de
arqel_yjs_documentsantigos.