Edición colaborativa en tiempo real (Yjs + Reverb)
Esta página describe cómo habilitar edición colaborativa multi-usuario en los fields de los Resources de Arqel — al estilo Google Docs / Notion — usando el paquete arqel-dev/realtime en el servidor y @arqel-dev/realtime-collab en el cliente. La solución combina:
- Yjs (CRDT) para resolver merges concurrentes sin un servidor central de coordinación.
- Laravel Reverb como el broadcaster oficial de WebSocket.
- Echo (cliente) para suscribirse al canal privado.
- Persistencia de snapshots vía REST con concurrencia optimista.
Cómo funciona (TL;DR)
- Cada
(modelType, modelId, field)colaborativo tiene unY.Doclocal en cada cliente. - Cada cambio local se codifica como
Uint8Array(state vector) y se propaga a los otros clientes vía el canal privadoarqel.collab.{modelType}.{modelId}.{field}(Reverb). - Periódicamente (debounce 2s por defecto), el cliente envía un snapshot consolidado en base64 al endpoint
POST /admin/{resource}/{id}/collab/{field}. El servidor lo persiste enarqel_yjs_documents(longBlob) y dispatchaEvents\YjsUpdateReceivedpara sincronizar clientes que perdieron updates por reconexión. - CRDT garantiza que el orden de llegada no importa: dos usuarios escribiendo simultáneamente convergen al mismo estado.
Requisitos previos
- Laravel 12+ (testeado en 12.x y 13.x).
arqel-dev/realtimeinstalado y booteado (ya incluido en cualquier proyecto que instaló el meta-paquetearqel-dev/framework).- Setup mínimo de auth y policies — el canal aplica el Gate
viewsobre el registro.
Instalación
Lado servidor
composer require laravel/reverb
php artisan reverb:install
php artisan migrateLa migración 2026_05_06_000000_create_yjs_documents (ya en arqel-dev/realtime) crea la tabla arqel_yjs_documents con un único (model_type, model_id, field) y un blob para el state.
.env:
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=local
REVERB_APP_KEY=local
REVERB_APP_SECRET=local
REVERB_HOST=localhost
REVERB_PORT=8080Inicia el worker:
php artisan reverb:startLado cliente
pnpm add @arqel-dev/realtime @arqel-dev/realtime-collab yjsSetup global de Echo (una vez en el bootstrap de 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="Write here…"
/>
);
}El componente:
- Crea un
Y.Doclocal + se suscribe al canal Echoarqel.collab.App\Models\Post.{id}.body. - Hidrata el estado inicial vía
GET persistUrl(snapshot anterior, si existe). - Aplica updates remotas vía
Y.applyUpdateautomáticamente. - Hace debounce de
POST persistUrlmientras el usuario escribe. - Renderiza un
<textarea>controlado. Para integración con ProseMirror/TipTap, usa el hook directamente.
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 (cuando window.Echo no está disponible). applyRemote(update) acepta Uint8Array o un string base64 y lo dispatcha al Y.Doc.
Autorización del canal
El canal está registrado en 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:
- Resuelve
$modelTypea una clase Eloquent — directamente vía FQCN o víaResourceRegistry::all()haciendo match porgetModel(). - Carga el registro con
Model::query()->find($modelId). - Chequea el Gate
view(cuando lo registra la app); si no, permite. - Defensivo: cualquier
Throwableo registry no bound devuelvefalse(deny).
Para policies finas, define view en tu PostPolicy y realtime lo hereda automáticamente.
Concurrencia optimista
El endpoint POST acepta {state, version}. Lógica:
- Si
incoming.version >= server.version→ guarda + incrementaversion+ dispatchaYjsUpdateReceived. - Si
incoming.version < server.version→ devuelve409 {message, serverVersion}. El cliente hace unGETfresco, aplica su estado local víaY.mergeUpdatesy reintenta.
Performance
- Debounce de snapshot (por defecto 2s) evita martillar el disco. Para edits raros, sube a 5s.
- broadcastWith envía el state completo — para documentos muy grandes (>200KB) considera cambiar a deltas vía
y-protocols/sync+ un WebSocket Reverb dedicado por documento. - Tabla
arqel_yjs_documentsusa único(model_type, model_id, field). Garbage-collect snapshots viejos vía un job dedicado si la tabla crece. - Escalado de Reverb: 1 worker Reverb soporta ~1000 conexiones. Para más, escala horizontalmente detrás de Redis (ver docs de
laravel/reverb). - Cliente: la integración con textarea reescribe todo el
Y.Texten cada tecla (simple). Para editores grandes, integray-prosemirror, que preserva la incrementalidad.
Eventos disponibles
Arqel\Realtime\Events\YjsUpdateReceived— disparado en cada snapshot persistido.broadcastAs=collab.update. Úsalo para integraciones del lado servidor (e.g. notificar a Slack cuando un doc se edita).
Tests + mocking
Los tests de arqel-dev/realtime corren con BROADCAST_CONNECTION=null + Event::fake() — no necesitas Reverb para testear policies/handlers. En el frontend, el hook se queda en estado offline cuando window.Echo es undefined, permitiendo tests en jsdom sin mocking pesado.
Limitaciones conocidas
- La integración actual con textarea reBindea todo el
Y.Texten cada tecla. Para editores ricos (ProseMirror/TipTap), usa el hook directamente +y-prosemirror. - Aún no hay awareness (cursores remotos, highlighting de selección). Roadmap: RT-006.
- Reconexiones cubiertas vía resync de snapshot — puede haber una ventana de 2s donde updates se "pierden" en el canal pero reaparecen en el siguiente snapshot.
- El
modelTypeen el canal es el FQCN Eloquent — codifícalo en el cliente (e.g.App\\Models\\Post) para que matchee con lo queResourceRegistryregistra.
Próximos pasos (roadmap)
- RT-006 — awareness (cursores remotos + presencia colaborativa).
- RT-006.1 — adaptador
y-prosemirrorpre-configurado para integrar con<RichTextField />. - RT-006.2 — garbage collection automática de
arqel_yjs_documentsviejos.