sequenceDiagram
participant A as Ana
participant S as Servidor
participant B as Beto
A->>S: WS connect (cookie auth) + join doc 7
B->>S: WS connect + join doc 7
S->>A: presence: [ana, beto]
A->>S: edit {element, patch}
S->>S: apply + broadcast
S-->>B: edit {element, patch} — Beto lo ve <300ms
B->>S: edit (otro elemento)
S-->>A: edit — LWW si colisionan
52 38. Colaboración: equipos, invitaciones y tiempo real
«Que todos vean qué está pasando» exige realtime + roles
Nexus deja de ser un CRUD bonito: ahora varias personas editan el mismo documento y se ven hacerlo. Eso exige tres piezas que el libro ya preparó: membresías con rol en la relación, invitaciones como tokens de un solo uso, y un canal WebSocket autenticado donde el servidor — por primera vez — empuja datos sin que le pregunten.
52.1 El problema
El brief: “Ana invita a Beto al equipo; ambos abren el mismo documento; cuando Ana mueve un elemento, Beto lo ve moverse”. Tres problemas en uno: (1) ¿cómo entra Beto — un link mágico que expira, no una contraseña que Ana le dicta?; (2) ¿cómo viaja el cambio de Ana a Beto — HTTP es preguntar, esto es empujar?; (3) ¿qué pasa cuando ambos editan lo mismo a la vez?
52.2 Cómo lo resuelve un equipo
Un equipo serio separa las tres preguntas:
- Membresía:
team_members(team_id, user_id, role)— el rol vive en la relación (cap. 17), no en el usuario. - Invitación:
invitations(token, team_id, email, role, expires_at, used_at)— un opaco de un solo uso que expira; aceptar crea la membresía. Otro flujo con estado — el mismo patrón del refresh token (cap. 18). - Tiempo real: WebSocket autenticado con la misma cookie; el servidor mantiene
documento → conexionesy hace broadcast. El protocolo de mensajes es un contrato como cualquier ruta REST. - Conflictos: last-writer-wins — documentado en el ADR-03 (cap. 36); CRDT queda fuera a propósito.
Una cookie es un dato que el servidor pone en tu navegador y el navegador devuelve en cada request. httpOnly = JavaScript no puede leerla (el XSS no la roba); Secure = solo viaja por HTTPS.
Un token es una credencial portable: una cadena que dice quién eres y hasta cuándo. El servidor la emite tras el login; el cliente la presenta en cada request en vez de la contraseña.
52.3 Conceptos nuevos
- D Modelo de membresías:
teams,members— roles en la relación - U Invitaciones: tokens de un solo uso, expiración, aceptación — otro flujo con estado
- U WebSockets: endpoint autenticado, presencia, broadcast — qué mantiene el servidor ahora
- TS
ws· Py FastAPI WebSocket · Gocoder/websocket— conexiones vivas por runtime - U Conflictos en vivo: last-writer-wins pragmático — cuándo alcanza y cuándo CRDT
52.4 La explicación visual
El servidor ahora tiene estado de conexiones: un mapa documento → [sockets] en memoria. Lo que el REST no tenía que recordar, el WS sí — por eso escalar WS a múltiples instancias es otra conversación (pub/sub entre ellas; para Nexus, una instancia basta).
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
52.5 Implementación
El endpoint WS autenticado + broadcast por documento:
import { WebSocketServer } from "ws";
const rooms = new Map<string, Set<WebSocket>>(); // docId → live sockets
wss.on("connection", async (socket, req) => {
const user = await userFromCookie(req.headers.cookie); // SAME auth as REST
const docId = getDocId(req.url);
if (!user || !(await isMember(user.id, docId))) return socket.close();
joinRoom(rooms, docId, socket);
broadcast(docId, { type: "presence", users: roomUsers(rooms, docId) });
socket.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "edit") {
broadcast(docId, { type: "edit", by: user.id, patch: msg.patch },
socket); // to the OTHERS
}
});
socket.on("close", () => leaveRoom(rooms, docId, socket));
});rooms: dict[str, set[WebSocket]] = {} # docId → live sockets
@app.websocket("/documents/{doc_id}/live")
async def live(websocket: WebSocket, doc_id: str):
user = await user_from_cookie(websocket.cookies) # SAME auth as REST
if not user or not await is_member(user.id, doc_id):
return await websocket.close()
await websocket.accept()
rooms.setdefault(doc_id, set()).add(websocket)
await broadcast(doc_id, {"type": "presence", "users": room_users(doc_id)})
try:
async for raw in websocket.iter_text():
msg = json.loads(raw)
if msg["type"] == "edit":
await broadcast(doc_id,
{"type": "edit", "by": user.id, "patch": msg["patch"]},
exclude=websocket) # to the OTHERS
finally:
rooms[doc_id].discard(websocket)var rooms = NewRoomMap() // docID → set of *websocket.Conn (mutex-guarded)
func live(w http.ResponseWriter, r *http.Request) {
user := userFromCtx(r.Context()) // SAME auth middleware as REST
docID := r.PathValue("id")
if user == nil || !isMember(r.Context(), user.ID, docID) {
w.WriteHeader(403); return
}
conn, err := websocket.Accept(w, r, nil)
if err != nil { return }
rooms.Join(docID, conn)
defer rooms.Leave(docID, conn)
broadcast(docID, Message{Type: "presence", Users: rooms.Users(docID)})
for {
var msg Message
if err := wsjson.Read(r.Context(), conn, &msg); err != nil { return }
if msg.Type == "edit" {
broadcastExcept(docID, Message{Type: "edit", By: user.ID, Patch: msg.Patch}, conn)
}
}
}Implementa el canal realtime de Nexus en [ws/FastAPI/coder-websocket]:
WS /documents/{id}/live autenticado con la misma cookie httpOnly del
REST (rechazar sin sesión o sin membresía), mapa documento→conexiones
thread-safe, mensajes con contrato {type: presence|edit, ...}, broadcast
de edits a las OTRAS conexiones del documento, limpieza al desconectar,
y last-writer-wins documentado. Tests/manual: dos clientes, uno edita
→ el otro recibe en <300ms; desconectar limpia la presencia.
52.6 ¿Por qué cada stack lo hace así?
Lo único que necesitas llevarte: el WS es una ruta más — autenticada igual, autorizada igual — solo que la conexión queda viva y el servidor guarda el mapa de quién escucha qué documento. Los tres hacen lo mismo: aceptar → join → loop de mensajes → broadcast → limpiar al cerrar.
Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.
| Decisión | Nexus elige | La alternativa y su precio |
|---|---|---|
| Auth del WS | Cookie httpOnly en el upgrade — misma auth | Token en query param: queda en logs/URLs |
| Conflictos | LWW (ADR-03) | CRDT/OT: merge por carácter — semanas de trabajo |
| Presencia | Mapa en memoria del proceso | Redis pub/sub: necesario solo con N instancias |
| El servidor empuja | WebSocket | SSE (solo servidor→cliente) o polling (latencia y carga) |
LWW honesto: si Ana y Beto tocan el mismo elemento en el mismo instante, gana el último en llegar — el documento queda consistente (mismo estado en todos) aunque un cambio se pierda. Aceptable porque: las ediciones suelen ser en elementos distintos, el historial del cap. 37 lo recupera todo, y la alternativa es un proyecto de meses.
52.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| WS sin autenticación/autorización | “Es un socket, no una ruta” | Mismo middleware: cookie → user → isMember, antes del accept |
| Broadcast a todos incluido el emisor | rooms.forEach(send) |
El emisor ya aplicó su cambio — eco = doble render |
| Mapa de rooms sin mutex (Go) / sin limpieza | Sockets muertos en el mapa | on close → leave; en Go el mapa va con sync.Mutex |
| Confiar el mensaje del cliente | msg.user_id declarado por el cliente |
El by lo pone el servidor — el cliente solo manda el patch |
| Asumir que WS escala como HTTP | “Levanto 3 réplicas” | Las conexiones viven en UNA instancia — pub/sub entre ellas o sticky sessions |
52.8 Buenas prácticas
- El protocolo WS es contrato:
{type, ...}con tipos cerrados (presence, edit, cursor, error) — validado como cualquier body REST. - Presencia derivada del mapa, no de mensajes “estoy aquí” del cliente — el servidor sabe quién está porque sabe quién está conectado.
- El estado efímero en memoria, el durable en BD: presencia = memoria (muere con el proceso, se reconstruye al reconectar); ediciones finales = revisions del cap. 37.
Un proceso es un programa en ejecución: el sistema operativo le da memoria propia y un lugar en la fila del CPU. Tu API es un proceso; Postgres es otro; el navegador es varios. Que “se caiga el proceso” = el programa murió.
- Heartbeat/ping: conexiones zombie (wifi que murió sin close) se purgan con ping periódico + timeout — el mapa no miente para siempre.
52.9 Ejercicio
- ¿Por qué el
bydel mensaje edit lo pone el servidor y no viene en el mensaje del cliente? - Beto abre dos pestañas del mismo documento. ¿Cuántas entradas tiene en
rooms[doc]? ¿Cómo aparece en presencia? - El equipo quiere “que Ana vea el cursor de Beto moviéndose”. ¿Qué cambia respecto al broadcast de edits — y qué NO debe guardar el servidor de ese tráfico?
52.10 Mini reto
Dos navegadores deben verse cambios en <300ms: diseñar cómo verificarlo.
Ejercicio.
- Porque el cliente miente gratis:
{"by": 42}declarado por Beto es un claim, no un hecho. El servidor sabe quién es por la cookie del upgrade — elbyse deriva de la autenticación, no del payload. Misma regla que eluser_iddel body (cap. 16). - Dos entradas (dos sockets) — la presencia por usuario deduplica: Beto aparece una vez aunque tenga N conexiones. El mapa guarda conexiones; la presencia muestra usuarios — dos vistas del mismo estado.
- Los cursores son tráfico efímero y de alta frecuencia: broadcast sin persistir, sin rate-limit de mensaje pero con throttle en el cliente (~30/seg), y tipo
cursordistinto deedit— el servidor solo reenvía, jamás guarda posiciones de cursor en BD (ruido puro).
Mini reto. La verificación honesta: dos clientes WS reales (o un test con dos conexiones), timestamp en el mensaje edit — el receptor mide Date.now() - msg.ts. Local será ~5ms; el número honesto se mide en staging con latencia real. Y el criterio: <300ms es “se siente en vivo” — el humano no distingue 50ms de 200ms en un movimiento.
Repaso de entrevista: las preguntas de este tema viven en el Apéndice I (frontend y tiempo real).
52.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.
Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.
Un log es el diario del programa: qué pasó, cuándo, con qué request. Estructurado = en JSON con campos (request_id, user_id), para filtrar por máquina y no con los ojos.
El runtime es el motor que ejecuta tu código: Node.js es el runtime de JavaScript fuera del navegador; CPython es el de Python; Go compila a binario y el runtime va empaquetado dentro. Es “quien corre” lo que escribiste.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
La autenticación responde “¿quién eres?” — login, contraseña, token. Se confunde con autorización (“¿qué puedes hacer?”), que es la pregunta siguiente. Primero te identificas, luego te dejan o no pasar.
La autorización responde “¿qué puedes hacer?”: eres usuario válido (autenticado), pero ¿puedes borrar esta tarea? Se decide por rol o por ownership — y se verifica en cada request, no se recuerda.
Un middleware es un filtro en la cadena del request: pasa por él antes de llegar a tu handler. Auth es middleware — “verifica el token” vive una vez y protege todas las rutas, no se copia en cada una.