Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion server/src/engine/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ensureRunner,
loadRoomIndex,
deleteRoom,
renameRoom,
type Room,
type SelectedElement,
} from "./rooms.js";
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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").
Expand Down
36 changes: 36 additions & 0 deletions server/src/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -96,6 +101,8 @@ export async function createRoom(): Promise<Room> {

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(),
Expand Down Expand Up @@ -144,6 +151,7 @@ async function despertarSala(id: string): Promise<Room | null> {

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,
Expand Down Expand Up @@ -424,6 +432,34 @@ export async function deleteRoom(id: string): Promise<boolean> {
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<string | null> {
// 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,
Expand Down
20 changes: 19 additions & 1 deletion server/src/storage/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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<void> {
Expand Down Expand Up @@ -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<void> {
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á
Expand Down Expand Up @@ -201,5 +216,8 @@ function toRoom(r: Record<string, unknown>): 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),
};
}
11 changes: 11 additions & 0 deletions server/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -36,6 +45,8 @@ export interface Storage {
listRooms(): Promise<StoredRoom[]>;
/** Marca actividad reciente (para saber qué salas siguen vivas). */
touchRoom(id: string): Promise<void>;
/** Le pone nombre a la sala. `null` lo quita y se vuelve a ver el id. */
renameRoom(id: string, nombre: string | null): Promise<void>;
/**
* Borra la sala y todo lo suyo: mensajes e historiales de agentes.
*
Expand Down
57 changes: 55 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(null);
const [editandoNombre, setEditandoNombre] = useState(false);
/** Qué dice el botón de descargar ahora mismo. null = su texto normal. */
const [zipAviso, setZipAviso] = useState<string | null>(null);
/**
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -653,7 +681,32 @@ function Sala({ roomId, name }: { roomId: string; name: string }) {
<div className="sala-cab">
<MenuSalas actual={roomId} />
<div className="sala-titulo">
<div className="sala-nombre">{roomId}</div>
{editandoNombre ? (
<input
className="sala-nombre-input"
defaultValue={nombre ?? ""}
placeholder={roomId}
maxLength={60}
autoFocus
onBlur={(e) => 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();
}
}}
/>
) : (
<button
className="sala-nombre"
onClick={() => setEditandoNombre(true)}
title={nombre ? `${roomId} — ${t.renombrarSala}` : t.renombrarSala}
>
{nombre ?? roomId}
</button>
)}
<div className="sala-meta">{t.enLaSala(members.length)}</div>
</div>
</div>
Expand Down
10 changes: 8 additions & 2 deletions web/src/MenuSalas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,14 @@ export function MenuSalas({ actual }: { actual?: string }) {
{otras.map((s) => (
<li key={s.id} className="menu-item">
<a className="menu-link" href={`#/sala/${s.id}`} onClick={() => setAbierto(false)}>
<span className="menu-sala-id">{s.id}</span>
<span className="menu-sala-fecha">{cuando(s.visitadaEn, idioma)}</span>
{/* 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. */}
<span className="menu-sala-id">{s.nombre || s.id}</span>
<span className="menu-sala-fecha">
{s.nombre ? `${s.id} · ` : ""}
{cuando(s.visitadaEn, idioma)}
</span>
</a>
<button
className="menu-olvidar"
Expand Down
Loading
Loading