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
24 changes: 23 additions & 1 deletion server/src/demos/proxy-rutas.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down
50 changes: 50 additions & 0 deletions server/src/engine/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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));
Expand Down
43 changes: 42 additions & 1 deletion server/src/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -120,7 +121,22 @@ export async function createRoom(): Promise<Room> {
* estado de la sala y se vuelve a arrancar su preview.
*/
export async function wakeRoom(id: string): Promise<Room | null> {
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<Room | null> {
// 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);
Expand Down Expand Up @@ -205,9 +221,34 @@ async function bootPreview(room: Room): Promise<void> {
*/
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();

/** Un despertar a la vez por sala, por lo mismo. */
const despertares = new KeyedMutex();

export async function maybeStartPreview(
room: Room,
onEtapa?: (etapa: EtapaPreview) => void,
): Promise<string | null> {
// 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<string | null> {
if (room.preview) return room.preview.url;
if (room.previewBooting) return null; // ya hay un arranque en curso
Expand Down
Loading