diff --git a/server/src/engine/proxy.ts b/server/src/engine/proxy.ts index c2742c2..e72ef0a 100644 --- a/server/src/engine/proxy.ts +++ b/server/src/engine/proxy.ts @@ -63,6 +63,20 @@ const RUTA_COMPARTIDA = "/assets/"; /** Nombre de la cookie que recuerda a qué sala pertenece esta pestaña del preview. */ const ROOM_COOKIE = "multi_room"; +/** + * Cuánto esperar antes de cada reintento cuando el dev server corta la conexión. + * + * Creciente y no fijo: casi siempre basta el primero, porque la ventana en que + * Vite acepta conexiones sin poder servir es de décimas. Los siguientes son + * para arranques lentos, que los hay (un proyecto pesado tardó 18s en levantar). + * + * Y con techo, ~3.75s en total: el dev server está en la misma máquina, así que + * si a esas alturas no contesta es que está caído, no ocupado. Insistir más solo + * acumularía conexiones colgadas — un preview pide decenas de archivos, y con + * todas esperando la sala parecería congelada en vez de decir que algo falló. + */ +const ESPERAS = [250, 500, 1000, 2000]; + /** Lee el roomId de la cookie (si viene). */ function roomFromCookie(cookieHeader?: string): string | null { if (!cookieHeader) return null; @@ -139,7 +153,19 @@ function roomPreviewPort(roomId: string): number | null { * http.Server crudo (para poder transformar el body sin que Fastify lo toque). * Devuelve true si manejó la request; false si no era del proxy. */ -export function handlePreviewRequest(req: IncomingMessage, res: ServerResponse): boolean { +export function handlePreviewRequest( + req: IncomingMessage, + res: ServerResponse, + /** + * Cuántos reintentos se han hecho ya, si el dev server corta la conexión. + * + * Vite acepta conexiones un instante antes de poder servir de verdad, así que + * la primera petición del navegador (normalmente `/@vite/client`) se topa a + * veces con un `socket hang up` y el preview sale roto. Recargar lo arregla, + * que es justo la señal de que solo faltaba esperar un momento. + */ + intento = 0, +): boolean { const D = process.env.PROXY_DEBUG === "1"; const parsed = parsePreviewUrl(req.url ?? "", { referer: req.headers.referer, @@ -258,6 +284,24 @@ export function handlePreviewRequest(req: IncomingMessage, res: ServerResponse): ); upstream.on("error", (err) => { + /** + * El dev server acaba de arrancar y todavía no atiende: se espera un poco y + * se vuelve a pedir, en vez de devolver un 502 a quien llegó medio segundo + * antes de tiempo. + * + * Solo con ECONNRESET (la conexión cortada a media), solo si no se le mandó + * nada al cliente todavía, y solo en peticiones sin cuerpo: el cuerpo de la + * original ya se consumió y no se puede reenviar. Los assets del arranque, + * que es lo que falla, son todos GET. + */ + const seCorto = (err as NodeJS.ErrnoException).code === "ECONNRESET"; + if (seCorto && !res.headersSent && intento < ESPERAS.length && req.method === "GET") { + const espera = ESPERAS[intento]; + if (D) console.log(`[proxy] ${parsed.rest} se cortó, reintento en ${espera}ms`); + setTimeout(() => handlePreviewRequest(req, res, intento + 1), espera); + return; + } + console.error( `[proxy] error upstream: sala=${parsed.roomId} puerto=${port} path=${parsed.rest} método=${req.method}`, err, diff --git a/server/src/index.ts b/server/src/index.ts index 42be570..2145a3b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -19,6 +19,7 @@ import { ensureRunner, loadRoomIndex, deleteRoom, + renameRoom, type Room, type SelectedElement, } from "./rooms.js"; @@ -339,6 +340,7 @@ fastify.get<{ Params: { id: string } }>("/rooms/:id", async (req, reply) => { if (!room) return reply.code(404).send({ error: "sala no encontrada" }); return { id: room.id, + nombre: room.nombre ?? null, previewUrl: room.preview?.url ?? null, members: membersList(room), }; @@ -431,6 +433,8 @@ io.on("connection", (socket) => { const history = await storage.getMessages(roomId); socket.emit("joined", { roomId, + // Null si nadie la ha nombrado: la Sala muestra el id en ese caso. + nombre: room.nombre ?? null, you: member, members: membersList(room), previewUrl: room.preview?.url ?? null, @@ -700,6 +704,28 @@ io.on("connection", (socket) => { io.to(room.id).emit("history:changed", {}); }); + /** + * Ponerle nombre a la sala. Lo ven todos los que estén dentro. + * + * Se avisa en el chat quién fue: el nombre está en la cabecera de todos, y + * verlo cambiar solo, sin saber por quién, se siente a error. Es el mismo + * trato que se le da a regresar el proyecto o a resolver turnos huérfanos. + */ + socket.on("room:rename", async ({ nombre }: { nombre: unknown }) => { + const room = joinedRoom; + if (!room) return; + const member = room.members.get(socket.id); + const quien = member?.name ?? "alguien"; + + const puesto = await renameRoom(room, nombre); + io.to(room.id).emit("room:renamed", { nombre: puesto }); + systemMsg( + room, + puesto ? `${quien} le puso "${puesto}" a la sala` : `${quien} le quitó el nombre a la sala`, + member?.color, + ); + }); + // El humano decide qué hacer con el trabajo que quedó a medias por un crash. // Nunca se decide automáticamente: ese trabajo ya está en disco y ya lo vieron // todos en el preview (ver DESIGN.md "turnos huérfanos"). diff --git a/server/src/rooms.ts b/server/src/rooms.ts index a34c3f6..7c226b2 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -45,6 +45,11 @@ export interface SelectedElement { /** Estado de una sala en memoria (v1: no persiste; Fase 7 lo lleva a Supabase). */ export interface Room { id: string; + /** + * Cómo le dice la gente a esta sala, o null si nadie la ha nombrado (ahí se + * muestra el id). Es de la sala: lo ven todos los que entren. + */ + nombre?: string | null; workspace: Workspace; preview: Preview | null; /** Hay un arranque de preview en curso (evita dos npm install a la vez). */ @@ -96,6 +101,8 @@ export async function createRoom(): Promise { const room: Room = { id, + // Nace sin nombre: se ve el id hasta que alguien de la sala le ponga uno. + nombre: null, workspace, preview: null, members: new Map(), @@ -144,6 +151,7 @@ async function despertarSala(id: string): Promise { const room: Room = { id, + nombre: stored.nombre ?? null, // El workspace ya existe en disco: NO se re-siembra ni se limpia. workspace: await createWorkspace(id), preview: null, @@ -424,6 +432,34 @@ export async function deleteRoom(id: string): Promise { return true; } +/** Lo más largo que puede ser el nombre de una sala. */ +export const MAX_NOMBRE_SALA = 60; + +/** + * Le pone nombre a una sala. Devuelve el nombre que quedó, o null si se quitó. + * + * Gana el último que escribe, como el título de un documento compartido: es un + * campo chico, se toca poco, y quien no esté de acuerdo lo vuelve a cambiar + * viendo ya lo que puso el otro. Por eso no hay bloqueo ni resolución de + * conflictos, pero sí se avisa a la sala (en index.ts): un nombre que cambia + * solo, sin saber quién fue, se siente a error. + * + * El id NO se toca. Es la URL y lo que se dicta por teléfono, así que renombrar + * nunca rompe un link ya compartido. + */ +export async function renameRoom(room: Room, crudo: unknown): Promise { + // Vacío = quitar el nombre y volver a ver el id. Es la única forma de + // deshacer, y es más natural que un botón aparte. + const texto = typeof crudo === "string" ? crudo.trim() : ""; + // Los saltos de línea romperían la cabecera de la sala, y no aportan nada a + // un nombre. + const nombre = texto ? texto.replace(/\s+/g, " ").slice(0, MAX_NOMBRE_SALA) : null; + + room.nombre = nombre; + await (await getStorage()).renameRoom(room.id, nombre); + return nombre; +} + export function addMember(room: Room, socketId: string, name: string): Member { const member: Member = { socketId, diff --git a/server/src/storage/sqlite.ts b/server/src/storage/sqlite.ts index b69d8a4..1464449 100644 --- a/server/src/storage/sqlite.ts +++ b/server/src/storage/sqlite.ts @@ -28,7 +28,10 @@ export class SqliteStorage implements Storage { id TEXT PRIMARY KEY, workspace_dir TEXT NOT NULL, created_at INTEGER NOT NULL, - last_active_at INTEGER NOT NULL + last_active_at INTEGER NOT NULL, + -- Cómo le dice la gente a esta sala. El id no cambia nunca (es la URL y + -- lo que se dicta por teléfono); esto es solo la etiqueta que se ve. + nombre TEXT ); CREATE TABLE IF NOT EXISTS messages ( @@ -62,6 +65,14 @@ export class SqliteStorage implements Storage { } catch { // ya la tiene } + + // El nombre que la gente le pone a la sala. Nullable a propósito: sin él la + // interfaz cae al id, que es lo que se ha visto siempre. + try { + this.db.exec(`ALTER TABLE rooms ADD COLUMN nombre TEXT`); + } catch { + // ya la tiene + } } async createRoom(room: StoredRoom): Promise { @@ -91,6 +102,10 @@ export class SqliteStorage implements Storage { this.db.prepare(`UPDATE rooms SET last_active_at = ? WHERE id = ?`).run(Date.now(), id); } + async renameRoom(id: string, nombre: string | null): Promise { + this.db.prepare(`UPDATE rooms SET nombre = ? WHERE id = ?`).run(nombre, id); + } + /** * Los mensajes y los historiales se van solos: sus tablas declaran * `ON DELETE CASCADE` contra `rooms(id)` y el PRAGMA de claves foráneas está @@ -201,5 +216,8 @@ function toRoom(r: Record): StoredRoom { workspaceDir: String(r.workspace_dir), createdAt: Number(r.created_at), lastActiveAt: Number(r.last_active_at), + // Las salas de antes de esta columna la traen en null, y ahí la interfaz + // cae al id. + nombre: r.nombre == null ? null : String(r.nombre), }; } diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts index c6f5bc3..a50015b 100644 --- a/server/src/storage/types.ts +++ b/server/src/storage/types.ts @@ -13,6 +13,15 @@ export interface StoredRoom { workspaceDir: string; createdAt: number; lastActiveAt: number; + /** + * Cómo le dice la gente a esta sala. Null hasta que alguien la nombre, y + * entonces la interfaz cae al id. + * + * Es de la SALA, no de quien la nombró: la ven todos los que entren. El id + * sigue siendo lo que va en la URL y lo que se dicta por teléfono, así que + * renombrar no rompe ningún link ya compartido. + */ + nombre?: string | null; } export interface StoredMessage { @@ -36,6 +45,8 @@ export interface Storage { listRooms(): Promise; /** Marca actividad reciente (para saber qué salas siguen vivas). */ touchRoom(id: string): Promise; + /** Le pone nombre a la sala. `null` lo quita y se vuelve a ver el id. */ + renameRoom(id: string, nombre: string | null): Promise; /** * Borra la sala y todo lo suyo: mensajes e historiales de agentes. * diff --git a/web/src/App.tsx b/web/src/App.tsx index 3c2037f..89e196e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,7 +20,7 @@ import { BackCanvas, type Endpoint } from "./BackCanvas.js"; import { KeyPanel, loadStoredCredencial, type Credencial } from "./KeyPanel.js"; import { useTextos } from "./i18n.js"; import { MenuSalas } from "./MenuSalas.js"; -import { recordarSala, olvidarSala } from "./historial-salas.js"; +import { recordarSala, olvidarSala, recordarNombre } from "./historial-salas.js"; import { prepararImagen, imagenesDe, @@ -241,6 +241,12 @@ function Sala({ roomId, name }: { roomId: string; name: string }) { /** Por dónde va el arranque del preview. null = no está arrancando. */ const [arrancando, setArrancando] = useState<"contenedor" | "dependencias" | "servidor" | null>(null); const [draft, setDraft] = useState(""); + /** + * El nombre de la sala, o null si nadie la ha nombrado (ahí se ve el id). + * Es de la sala, así que llega en el `joined` y cambia para todos a la vez. + */ + const [nombre, setNombre] = useState(null); + const [editandoNombre, setEditandoNombre] = useState(false); /** Qué dice el botón de descargar ahora mismo. null = su texto normal. */ const [zipAviso, setZipAviso] = useState(null); /** @@ -339,6 +345,8 @@ function Sala({ roomId, name }: { roomId: string; name: string }) { socket.on("joined", (p: JoinedPayload) => { setMembers(p.members); + setNombre(p.nombre ?? null); + recordarNombre(roomId, p.nombre ?? null); if (p.previewUrl) setPreviewReady(true); if (p.agents) setAgents(p.agents); if (p.orphanTurns?.length) setOrphans(p.orphanTurns); @@ -353,6 +361,13 @@ function Sala({ roomId, name }: { roomId: string; name: string }) { if (p.previewArrancando && !p.previewUrl) setArrancando("servidor"); }); socket.on("presence", ({ members }: { members: Member[] }) => setMembers(members)); + + // Alguien de la sala le cambió el nombre: se ve al momento en la cabecera + // de todos, sin recargar. Quién fue sale en el chat. + socket.on("room:renamed", ({ nombre }: { nombre: string | null }) => { + setNombre(nombre); + recordarNombre(roomId, nombre); + }); socket.on("preview:ready", () => { setPreviewReady(true); setArrancando(null); @@ -583,6 +598,19 @@ function Sala({ roomId, name }: { roomId: string; name: string }) { const copyLink = () => navigator.clipboard.writeText(window.location.href); + /** + * Guardar el nombre nuevo de la sala. + * + * No se pinta aquí lo que quedó: se manda al server y se espera su + * `room:renamed`, que llega igual para todos. Así el que renombra ve + * exactamente lo mismo que sus compas, recortes y espacios incluidos. + */ + const guardarNombre = (valor: string) => { + setEditandoNombre(false); + if (valor.trim() === (nombre ?? "")) return; // no cambió: nada que mandar + socketRef.current?.emit("room:rename", { nombre: valor }); + }; + /** * Bajar el proyecto de la sala como .zip. * @@ -653,7 +681,32 @@ function Sala({ roomId, name }: { roomId: string; name: string }) {
-
{roomId}
+ {editandoNombre ? ( + guardarNombre(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + // Escape descarta: se sale sin guardar lo que se llevaba escrito. + if (e.key === "Escape") { + e.currentTarget.value = nombre ?? ""; + e.currentTarget.blur(); + } + }} + /> + ) : ( + + )}
{t.enLaSala(members.length)}
diff --git a/web/src/MenuSalas.tsx b/web/src/MenuSalas.tsx index ac198c9..2ec2911 100644 --- a/web/src/MenuSalas.tsx +++ b/web/src/MenuSalas.tsx @@ -130,8 +130,14 @@ export function MenuSalas({ actual }: { actual?: string }) { {otras.map((s) => (
  • setAbierto(false)}> - {s.id} - {cuando(s.visitadaEn, idioma)} + {/* El nombre manda, porque es lo que hace reconocible la + sala. El id se queda debajo: sigue siendo lo que va en + el link y lo que se dicta por teléfono. */} + {s.nombre || s.id} + + {s.nombre ? `${s.id} · ` : ""} + {cuando(s.visitadaEn, idioma)} +