From 74ee422d70c098cd167b034a63b67d5507cc15ad Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Wed, 12 Aug 2026 19:22:25 -0600 Subject: [PATCH 1/3] El preview deja de mostrar el proyecto de otra sala Al volver a una sala visitada antes, el preview salia en negro con un "Invalid hook call" y dos copias de React. No era el proyecto: eran modulos de OTRA sala. La cookie que dice de que sala son los modulos es una sola para todo el navegador, asi que entrar a una segunda sala la sobrescribe. Al volver a la primera, su HTML salia del cache del navegador (un 304 que ni llegaba al proxy), la cookie se quedaba apuntando a la otra, y los modulos que Vite pide desde la raiz (`/src/main.tsx`, cuyo Referer encadena a otro modulo y ya no trae el roomId) se resolvian contra el proyecto equivocado. Por eso solo pasaba con dos o mas salas, y de forma intermitente: dependia de que hubiera en cache. Ahora la pagina de entrada de la sala se pide siempre fresca (se le quitan las cabeceras de revalidacion al ir, y el cache y el ETag al volver), asi la cookie queda correcta ANTES de que se pidan los modulos. Los archivos del proyecto siguen cacheando normal: ahi el 304 es lo que se quiere. Es agnostico: no toca como se levanta ningun stack, solo el HTML que el proxy ya servia. El demo sube a 39 comprobaciones. Co-Authored-By: Claude Opus 5 --- server/src/demos/proxy-rutas.ts | 24 +++++++++++++++- server/src/engine/proxy.ts | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/server/src/demos/proxy-rutas.ts b/server/src/demos/proxy-rutas.ts index daec515..cca68b3 100644 --- a/server/src/demos/proxy-rutas.ts +++ b/server/src/demos/proxy-rutas.ts @@ -1,4 +1,4 @@ -import { parsePreviewUrl } from "../engine/proxy.js"; +import { parsePreviewUrl, esLaPaginaDeLaSala } from "../engine/proxy.js"; /** * Demo: qué peticiones van al preview y cuáles se quedan en la Sala. @@ -124,6 +124,28 @@ function main() { check("un módulo sin cookie ni referer", aLaSala("/src/main.tsx", {})); } + console.log("\n7. La página de la sala se distingue de sus archivos"); + { + /** + * De qué sirve: esa página es la que trae la cookie que dice a qué sala + * pertenecen los módulos siguientes, así que se pide siempre fresca. Los + * archivos del proyecto sí pueden cachearse, y deben poder. + * + * El bug que cubre: al volver a una sala visitada antes, su HTML salía del + * caché del navegador (304, sin llegar al proxy), la cookie se quedaba + * apuntando a la ÚLTIMA sala abierta, y los módulos de esta se resolvían + * contra el proyecto de aquella. El preview quedaba negro con un "Invalid + * hook call" por dos copias de React. + */ + check("la entrada de la sala", esLaPaginaDeLaSala("/")); + check("la entrada, sin barra", esLaPaginaDeLaSala("")); + check("index.html explícito", esLaPaginaDeLaSala("/index.html")); + check("la entrada con query", esLaPaginaDeLaSala("/?x=1")); + check("un módulo NO es la entrada", !esLaPaginaDeLaSala("/src/main.tsx")); + check("una imagen NO es la entrada", !esLaPaginaDeLaSala("/logo.png")); + check("el cliente de Vite NO es la entrada", !esLaPaginaDeLaSala("/@vite/client")); + } + console.log(`\n${pass} pasaron, ${fail} fallaron\n`); process.exit(fail > 0 ? 1 : 0); } diff --git a/server/src/engine/proxy.ts b/server/src/engine/proxy.ts index b554520..c2742c2 100644 --- a/server/src/engine/proxy.ts +++ b/server/src/engine/proxy.ts @@ -117,6 +117,17 @@ export function parsePreviewUrl( return { roomId, rest: url }; } +/** + * Si lo que se pide es la página de entrada de la sala, y no un asset suyo. + * + * `parsed.rest` ya viene sin el `/preview/:roomId`, así que la entrada es "/" + * (o vacío). Cualquier otra cosa es un archivo del proyecto. + */ +export function esLaPaginaDeLaSala(rest: string): boolean { + const soloRuta = rest.split("?")[0]; + return soloRuta === "/" || soloRuta === "" || soloRuta === "/index.html"; +} + /** Puerto del dev server de una sala, o null si aún no arrancó. */ function roomPreviewPort(roomId: string): number | null { const room = getRoom(roomId); @@ -156,6 +167,22 @@ export function handlePreviewRequest(req: IncomingMessage, res: ServerResponse): delete fwdHeaders["connection"]; fwdHeaders["host"] = `127.0.0.1:${port}`; + /** + * Pedir la PÁGINA de la sala siempre fresca, nunca revalidada. + * + * Es la que trae la cookie que dice de qué sala son los módulos siguientes. + * Si el navegador la revalida, Vite contesta 304 sin cuerpo, el proxy no + * llega a poner la cookie, y queda la de la última sala visitada. Quitar las + * cabeceras de revalidación obliga a un 200 con HTML de verdad. + * + * Solo para la entrada del preview (`/preview/:roomId` o `/preview/:roomId/`). + * Los assets siguen cacheando normal: ahí el 304 es justo lo que se quiere. + */ + if (esLaPaginaDeLaSala(parsed.rest)) { + delete fwdHeaders["if-none-match"]; + delete fwdHeaders["if-modified-since"]; + } + const upstream = httpRequest( { host: "127.0.0.1", @@ -191,6 +218,29 @@ export function handlePreviewRequest(req: IncomingMessage, res: ServerResponse): // ninguna sala y el preview queda en blanco. headers["set-cookie"] = `${ROOM_COOKIE}=${parsed.roomId}; Path=/; SameSite=Lax`; + /** + * Y que ese HTML NO se cachee, porque la cookie viaja con él. + * + * La cookie es una sola para todo el navegador, así que entrar a otra + * sala la sobrescribe. Al volver a la primera, su HTML salía del caché + * del navegador (un 304 seco, sin llegar aquí), la cookie se quedaba + * apuntando a la OTRA sala, y los módulos que Vite pide desde la raíz + * (`/src/main.tsx`, sin Referer útil) se resolvían contra el proyecto + * equivocado: React duplicado, "Invalid hook call", preview en negro. + * + * Sin caché, cada vez que se abre una sala su HTML se vuelve a pedir y la + * cookie queda correcta ANTES de que se pidan los módulos. Cuesta una + * petición de HTML por entrada, que al lado de un dev server no es nada. + * + * Se quita también el ETag: con él, el navegador revalida y Vite + * responde 304, que es justo el camino que dejaba la cookie vieja. + */ + headers["cache-control"] = "no-store, must-revalidate"; + delete headers["etag"]; + delete headers["Etag"]; + delete headers["ETag"]; + delete headers["last-modified"]; + // HTML: bufferizar, transformar, reenviar. const chunks: Buffer[] = []; up.on("data", (c) => chunks.push(c)); From c14a3ac6d7f117524bb45aa5bb247c0f3149ed13 Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Wed, 12 Aug 2026 19:28:29 -0600 Subject: [PATCH 2/3] Un solo dev server por sala, no dos En el log de una sala salia Vite avisando "Port 5173 is in use, trying another one" y quedaban DOS dev servers, uno en cada puerto. El proxy apunta a uno solo, asi que la mitad de las peticiones le pegaban al que no era: preview en blanco, o modulos servidos por un Vite distinto al que sirvio el HTML. Dos caminos piden el preview casi a la vez: `wakeRoom` al despertar la sala y el `join` de quien entra. La bandera `previewBooting` no los frenaba porque entre leerla y ponerla hay un `await detectLaunch` que toca disco: los dos la leian en false y los dos arrancaban. Ahora se serializa por sala con el mismo KeyedMutex que ya protege los contenedores. Salas distintas siguen arrancando en paralelo. Co-Authored-By: Claude Opus 5 --- server/src/rooms.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 8e271c4..acb70fe 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -13,6 +13,7 @@ import { import { containerRunner, localRunner, type Runner } from "./engine/runner.js"; import type { Message } from "./agent/providers/types.js"; import { AgentRegistry } from "./engine/agents.js"; +import { KeyedMutex } from "./engine/keyed-mutex.js"; import { RunCoordinator } from "./engine/coordinator.js"; import { sweepOrphans, type Turn } from "./engine/turns.js"; import { getStorage } from "./storage/index.js"; @@ -205,9 +206,31 @@ async function bootPreview(room: Room): Promise { */ export type EtapaPreview = "contenedor" | "dependencias" | "servidor"; +/** + * Un arranque de preview a la vez por sala. Salas distintas van en paralelo, + * que es lo normal: es el mismo KeyedMutex que ya serializa los contenedores. + */ +const arranquesDePreview = new KeyedMutex(); + export async function maybeStartPreview( room: Room, onEtapa?: (etapa: EtapaPreview) => void, +): Promise { + // Serializado por sala: dos caminos piden el preview casi a la vez (wakeRoom + // al despertarla, y el `join` de quien entra). La bandera de abajo no bastaba + // porque entre leerla y ponerla hay un `await detectLaunch` que toca disco: + // los dos la leían en false y los dos arrancaban un dev server. + // + // El síntoma era Vite avisando "Port 5173 is in use, trying another one" y + // quedando DOS por sala, uno en cada puerto. El proxy apunta a uno solo, así + // que la mitad de las peticiones le pegaban al que no era y el preview salía + // en blanco o con módulos de un servidor distinto. + return arranquesDePreview.run(room.id, () => arrancarPreview(room, onEtapa)); +} + +async function arrancarPreview( + room: Room, + onEtapa?: (etapa: EtapaPreview) => void, ): Promise { if (room.preview) return room.preview.url; if (room.previewBooting) return null; // ya hay un arranque en curso From 37a75f8434552b47ae00cb7e08202e40594127d5 Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Wed, 12 Aug 2026 19:33:00 -0600 Subject: [PATCH 3/3] Despertar una sala una sola vez, no tres En el log salia la misma sala arrancando tres dev servers seguidos (5173, 5174, 5175), y entre uno y otro un "contenedor listo" nuevo: no era el preview repitiendose, era la SALA despertando tres veces. Entrar dispara varias peticiones casi a la vez (el join por socket, el historial, el mapa del back). `wakeRoom` comprobaba el Map y luego hacia varios `await` a disco y a la BD antes de registrar la sala, asi que las tres la veian ausente, las tres construian una sala nueva, y cada una arrancaba su preview. Cada `rooms.set` pisaba al anterior, y la sala que quedaba en memoria no era la del dev server al que apuntaba el proxy: preview en blanco. Mismo patron que ya se uso para los contenedores y para el arranque del preview: KeyedMutex por sala, y volver a mirar el Map con el turno ya tomado. Co-Authored-By: Claude Opus 5 --- server/src/rooms.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/server/src/rooms.ts b/server/src/rooms.ts index acb70fe..a34c3f6 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -121,7 +121,22 @@ export async function createRoom(): Promise { * estado de la sala y se vuelve a arrancar su preview. */ export async function wakeRoom(id: string): Promise { - if (rooms.has(id)) return rooms.get(id)!; + const enMemoria = rooms.get(id); + if (enMemoria) return enMemoria; + // Serializado por sala: entrar dispara varias peticiones casi a la vez (el + // join, el historial, el mapa del back). Entre comprobar el Map y registrar + // la sala hay varios `await` a disco y a la BD, así que las tres la veían + // ausente, las tres construían una sala nueva y cada una arrancaba SU + // preview. Cada `rooms.set` pisaba al anterior, y la sala que quedaba en + // memoria no era la del dev server al que apuntaba el proxy. + return despertares.run(id, () => despertarSala(id)); +} + +async function despertarSala(id: string): Promise { + // Se vuelve a mirar YA con el turno tomado: si otra petición despertó la + // sala mientras esta hacía fila, no hay nada que hacer. + const yaEsta = rooms.get(id); + if (yaEsta) return yaEsta; const storage = await getStorage(); const stored = await storage.getRoom(id); @@ -212,6 +227,9 @@ export type EtapaPreview = "contenedor" | "dependencias" | "servidor"; */ const arranquesDePreview = new KeyedMutex(); +/** Un despertar a la vez por sala, por lo mismo. */ +const despertares = new KeyedMutex(); + export async function maybeStartPreview( room: Room, onEtapa?: (etapa: EtapaPreview) => void,