From 28bc4ac96195235b4ef6782b42c589cd29247f34 Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Fri, 14 Aug 2026 23:09:31 -0600 Subject: [PATCH] Publicar la app de la sala con un boton Multi ya dejaba construir algo entre varios y llevarselo en .zip, pero para que alguien lo VIERA habia que descargarlo y levantarlo. Ahora hay un boton que lo compila, lo sube a Cloudflare Pages y deja un link que se le puede pasar a cualquiera. La credencial es de quien corre este Multi, no de quien publica. Es la misma decision que la key de respaldo y por la misma razon: pedirle a alguien que abra cuenta en otro servicio para ver su propia app es justo la friccion que este producto existe para quitar. El plan gratis de Cloudflare da 100 proyectos, uno por sala, asi que da para rato. El proyecto de Cloudflare se llama como la sala, asi que republicar pisa lo anterior y el link que alguien ya compartio sigue sirviendo. Que compilar sale de lo que el proyecto DECLARA, igual que el arranque: se busca un script de build por convencion, sin asumir el stack. Y la carpeta que se sube se busca DESPUES de compilar, porque antes no hay nada que mirar y deducirla del stack seria lo que el motor no hace. `public` va al final de la lista: en muchos proyectos es la fuente y no la salida, y si ganara se subiria el logo sin la app. El token entra COMO VARIABLE del comando, no interpolado en el: asi no queda en la lista de procesos de adentro, no se queda en el contenedor, no toca el .env del proyecto (que cualquiera de la sala puede leer) y el agente no lo encuentra aunque lo busque. Comprobado con docker exec. El texto crudo de wrangler NUNCA llega a la sala: lleva el account id y, cuando falla la autenticacion, fragmentos de lo que se le paso. Se traduce a algo accionable, como ya se hace con los errores de los proveedores de modelo. Publicar tarda minutos, asi que el endpoint responde al arrancar y lo demas va por socket: el boton dice por donde va, quien entra a media publicacion tambien lo ve, y el link acaba en el chat para que quede en el historial. Sin las variables en el .env, el boton dice que este Multi no tiene la publicacion configurada. Nada de fallar en silencio. Co-Authored-By: Claude Opus 5 --- package.json | 3 +- server/.env.example | 17 ++++ server/package.json | 3 +- server/src/demos/publicar.ts | 94 ++++++++++++++++++ server/src/engine/container.ts | 20 +++- server/src/engine/preview.ts | 72 ++++++++++++++ server/src/engine/publicar.ts | 169 +++++++++++++++++++++++++++++++++ server/src/engine/runner.ts | 19 +++- server/src/index.ts | 63 ++++++++++++ server/src/rooms.ts | 8 ++ web/src/App.tsx | 49 ++++++++++ web/src/i18n.tsx | 14 +++ web/src/socket.ts | 2 + 13 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 server/src/demos/publicar.ts create mode 100644 server/src/engine/publicar.ts diff --git a/package.json b/package.json index ae2b8c7..b771f59 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "demo:turno-cortado": "npm run demo:turno-cortado -w server", "demo:exportar": "npm run demo:exportar -w server", "uso": "npm run uso -w server --", - "demo:variables": "npm run demo:variables -w server" + "demo:variables": "npm run demo:variables -w server", + "demo:publicar": "npm run demo:publicar -w server" }, "engines": { "node": ">=22" diff --git a/server/.env.example b/server/.env.example index b0809e1..fa29996 100644 --- a/server/.env.example +++ b/server/.env.example @@ -29,3 +29,20 @@ # Cuánta memoria y CPU puede usar el contenedor de una sala. # MULTI_ROOM_MEMORY=2g # MULTI_ROOM_CPUS=2 + +# ── Publicar las apps (opcional) ───────────────────────────────────────────── +# Con esto aparece el botón de Publicar: la sala compila su app y la sube a +# Cloudflare Pages, y sale un link que se puede compartir con cualquiera. +# +# La cuenta es TUYA, no de quien publica: es la misma decisión que la key de +# respaldo de arriba. Quien entra a una sala solo quiere enseñar lo que hizo, no +# darse de alta en otro servicio. El plan gratis de Cloudflare da 100 proyectos, +# uno por sala, así que da para rato. +# +# El token se saca en el panel de Cloudflare, con el permiso "Cloudflare Pages — +# Edit". El account id sale en la misma página. +# +# Sin esto Multi corre igual; el botón dice que la publicación no está +# configurada, que es algo que solo tú puedes resolver. +# CLOUDFLARE_API_TOKEN= +# CLOUDFLARE_ACCOUNT_ID= diff --git a/server/package.json b/server/package.json index 85be21e..bc641c4 100644 --- a/server/package.json +++ b/server/package.json @@ -27,7 +27,8 @@ "demo:proxy-rutas": "tsx src/demos/proxy-rutas.ts", "demo:exportar": "tsx src/demos/exportar.ts", "uso": "tsx --no-warnings src/demos/uso.ts", - "demo:variables": "tsx src/demos/variables.ts" + "demo:variables": "tsx src/demos/variables.ts", + "demo:publicar": "tsx src/demos/publicar.ts" }, "dependencies": { "@fastify/cors": "^11.3.0", diff --git a/server/src/demos/publicar.ts b/server/src/demos/publicar.ts new file mode 100644 index 0000000..0ea6b59 --- /dev/null +++ b/server/src/demos/publicar.ts @@ -0,0 +1,94 @@ +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { createWorkspace } from "../engine/workspace.js"; +import { detectBuild, buscarSalida } from "../engine/preview.js"; +import { credencialDeDeploy } from "../engine/publicar.js"; + +/** + * Demo: qué se compila y qué se sube al publicar la app de una sala. + * Uso: npm run demo:publicar + * + * Lo que se prueba es la parte que decide, no el deploy: subir de verdad + * necesitaría la credencial del dueño y dejaría una app publicada cada vez que + * alguien corre el demo. + * + * Y esa parte es justo la que puede equivocarse en silencio: si elige mal la + * carpeta, se sube el código fuente en vez de la app compilada, y el link que le + * pasas a alguien muestra un directorio de archivos. + * + * No necesita red ni servidor. + */ + +let pass = 0; +let fail = 0; +function check(name: string, ok: boolean, detail = ""): void { + if (ok) { + pass++; + console.log(` [ok] ${name}`); + } else { + fail++; + console.log(` [X] ${name} ${detail}`); + } +} + +/** Escribe un package.json con los scripts que se le den. */ +async function conScripts(dir: string, scripts: Record): Promise { + await writeFile(join(dir, "package.json"), JSON.stringify({ name: "x", scripts }, null, 2), "utf8"); +} + +async function main() { + console.log("\n=== publicar la app de la sala ===\n"); + + const ws = await createWorkspace("demo-publicar", { clean: true }); + + console.log("1. Una sala vacía no tiene nada que compilar"); + check("sin proyecto no hay build", (await detectBuild(ws.dir)) === null); + + console.log("\n2. Se lee lo que el proyecto declara, sin asumir el stack"); + await conScripts(ws.dir, { dev: "vite", build: "vite build" }); + const build = await detectBuild(ws.dir); + check("encontró el script", build?.args.join(" ") === "run build", JSON.stringify(build)); + + console.log("\n3. Un proyecto que no compila no es un error"); + // Hay apps que se sirven tal cual: un index.html suelto no tiene build, y eso + // es normal, no una falla. + await conScripts(ws.dir, { dev: "vite" }); + check("sin script de build, no hay build", (await detectBuild(ws.dir)) === null); + + console.log("\n4. También se acepta el nombre que usa cada stack"); + await conScripts(ws.dir, { generate: "nuxt generate" }); + check("nuxt generate", (await detectBuild(ws.dir))?.args.join(" ") === "run generate"); + + console.log("\n5. La carpeta de salida se busca DESPUÉS de compilar"); + check("antes del build no hay ninguna", buscarSalida(ws.dir) === null); + + await mkdir(join(ws.dir, "dist"), { recursive: true }); + check("después sí", buscarSalida(ws.dir) === "dist"); + + console.log("\n6. Y se elige la de verdad cuando hay varias"); + // `public/` suele ser la FUENTE de los archivos estáticos, no la salida: si + // ganara, se subiría el logo y nada más, sin la app compilada. + await mkdir(join(ws.dir, "public"), { recursive: true }); + check("dist le gana a public", buscarSalida(ws.dir) === "dist"); + + console.log("\n7. Sin credencial configurada no se publica en silencio"); + const antes = { ...process.env }; + delete process.env.CLOUDFLARE_API_TOKEN; + delete process.env.CLOUDFLARE_ACCOUNT_ID; + check("no hay credencial", credencialDeDeploy() === null); + + process.env.CLOUDFLARE_API_TOKEN = "falsa"; + check("con una sola tampoco alcanza", credencialDeDeploy() === null); + + process.env.CLOUDFLARE_ACCOUNT_ID = "falsa"; + check("con las dos ya sirve", credencialDeDeploy() !== null); + process.env = antes; + + console.log(`\n${pass} pasaron, ${fail} fallaron\n`); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error("\ndemo falló:", err); + process.exit(1); +}); diff --git a/server/src/engine/container.ts b/server/src/engine/container.ts index a87c265..a85d46b 100644 --- a/server/src/engine/container.ts +++ b/server/src/engine/container.ts @@ -246,12 +246,28 @@ export interface ExecResult { export function execInContainer( roomId: string, command: string, - opts: { timeoutMs: number; maxOutput: number }, + opts: { + timeoutMs: number; + maxOutput: number; + /** + * Variables solo para ESTE comando, no para el contenedor. + * + * Es el camino por el que el token de la plataforma de deploy llega a + * `wrangler`: vive en el `.env` del server, entra con el comando y se va con + * él. No queda en el `docker run`, no queda en el `.env` del proyecto, y el + * agente no lo encuentra aunque vaya a buscarlo. + * + * Por aquí y no interpolado en el comando: ahí quedaría a la vista en la + * lista de procesos de adentro. + */ + env?: Record; + }, ): Promise { const name = CONTAINER_PREFIX + roomId; + const envArgs = Object.entries(opts.env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`]); return new Promise((resolve, reject) => { - const child = spawn("docker", ["exec", "-i", name, "sh", "-c", command]); + const child = spawn("docker", ["exec", "-i", ...envArgs, name, "sh", "-c", command]); let stdout = ""; let stderr = ""; diff --git a/server/src/engine/preview.ts b/server/src/engine/preview.ts index 1ff6467..d415bc7 100644 --- a/server/src/engine/preview.ts +++ b/server/src/engine/preview.ts @@ -366,3 +366,75 @@ async function killDevServersIn(roomId: string, puerto: number): Promise { // Sin contenedor vivo: no hay nada que limpiar. } } + +// ── Compilar para publicar ────────────────────────────────────────────────── + +/** + * Cómo compilar el proyecto, si es que se puede. + * + * Hermano de `detectLaunch`: lee lo que el proyecto DECLARA en vez de asumir el + * stack. Un proyecto sin script de build no es un error — hay apps que se sirven + * tal cual y no compilan nada. + */ +export async function detectBuild(dir: string): Promise { + const enRaiz = await leerBuild(dir); + if (enRaiz) return enRaiz; + return buscarBuildEnSubcarpetas(dir); +} + +async function leerBuild(dir: string): Promise { + const pkgPath = join(dir, "package.json"); + if (!existsSync(pkgPath)) return null; + + let scripts: Record = {}; + try { + const pkg = JSON.parse(await readFile(pkgPath, "utf8")); + scripts = pkg?.scripts ?? {}; + } catch { + return null; // package.json a medio escribir (el agente sigue trabajando) + } + + // Orden por convención, igual que el arranque: "build" es el universal, y + // "generate"/"export" los usan los que producen sitio estático (Nuxt, Next). + const script = ["build", "generate", "export"].find((s) => typeof scripts[s] === "string"); + if (!script) return null; + + return { command: "npm", args: ["run", script], cwd: dir }; +} + +/** Mismo rodeo que el arranque: el generador pudo dejar el proyecto anidado. */ +async function buscarBuildEnSubcarpetas(dir: string): Promise { + let entradas; + try { + entradas = await readdir(dir, { withFileTypes: true }); + } catch { + return null; + } + for (const e of entradas) { + if (!e.isDirectory()) continue; + if (e.name.startsWith(".") || e.name === "node_modules") continue; + const encontrado = await leerBuild(join(dir, e.name)); + if (encontrado) return encontrado; + } + return null; +} + +/** + * Dónde dejó el build los archivos que se pueden publicar. + * + * Se busca DESPUÉS de compilar, a propósito: antes no hay nada que mirar, y + * deducirla del stack sería justo lo que el motor no hace. Después del build la + * carpeta existe, y con eso basta para saber cuál es. + * + * El orden importa: `public` va al final porque muchos proyectos la tienen como + * fuente (los archivos que el dev server sirve tal cual), no como salida. Si hay + * un `dist` recién hecho, ese gana. + */ +const CARPETAS_DE_SALIDA = ["dist", "build", "out", ".output/public", ".svelte-kit/output/client", "public"]; + +export function buscarSalida(dir: string): string | null { + for (const c of CARPETAS_DE_SALIDA) { + if (existsSync(join(dir, c))) return c; + } + return null; +} diff --git a/server/src/engine/publicar.ts b/server/src/engine/publicar.ts new file mode 100644 index 0000000..82349bc --- /dev/null +++ b/server/src/engine/publicar.ts @@ -0,0 +1,169 @@ +import { detectBuild, buscarSalida } from "./preview.js"; +import type { Runner } from "./runner.js"; +import type { Workspace } from "./workspace.js"; + +/** + * Publicar la app de una sala en internet. + * + * El último tramo del recorrido: la sala construye algo, lo ve en el preview, y + * con esto queda en una URL que se le puede pasar a alguien que no entró nunca. + * + * Va a Cloudflare Pages por `wrangler`, que sube una carpeta de archivos y no + * pregunta más. La credencial es de QUIEN CORRE MULTI, no de quien publica: es + * la misma decisión que la API key de respaldo, y por la misma razón — pedirle + * a alguien que abra una cuenta en otro servicio para ver su propia app es la + * fricción que este producto existe para quitar. + * + * Lo que NO hace: sacar de aquí una app con servidor. Pages sirve archivos, así + * que una API en Go o un Django con base de datos no caben. Eso se dice cuando + * pasa, en vez de fallar con un error de wrangler que nadie entiende. + */ + +/** Por dónde va la publicación. Es lo que ve la sala mientras espera. */ +export type EtapaDeploy = "compilando" | "subiendo"; + +export interface Credencial { + token: string; + accountId: string; +} + +export class NoSePudoPublicar extends Error {} + +/** + * Compila el proyecto y lo sube. Devuelve la URL pública. + * + * `onEtapa` avisa por dónde va: un build de verdad tarda minutos, y sin eso la + * sala mira un botón quieto sin saber si sigue vivo. + */ +export async function publicarSala( + workspace: Workspace, + runner: Runner, + cred: Credencial, + onEtapa?: (etapa: EtapaDeploy) => void, +): Promise { + const build = await detectBuild(workspace.dir); + if (!build) { + throw new NoSePudoPublicar( + "este proyecto no declara cómo compilarse, así que no hay nada que subir", + ); + } + + // Si el proyecto quedó en una subcarpeta, todo ocurre ahí dentro. Mismo + // cálculo que hace el arranque del dev server. + const sub = + build.cwd && build.cwd !== workspace.dir + ? build.cwd.slice(workspace.dir.length).replace(/^[/\\]/, "") + : ""; + const cd = sub ? `cd ${JSON.stringify(sub)} && ` : ""; + + onEtapa?.("compilando"); + const compilado = await runner.exec(cd + [build.command, ...build.args].join(" "), { + timeoutMs: 600_000, + maxOutput: 4000, + }); + if (compilado.code !== 0) { + throw new NoSePudoPublicar( + compilado.timedOut + ? "el proyecto tardó demasiado en compilar" + : `el proyecto no compila: ${ultimaLinea(compilado.stderr || compilado.stdout)}`, + ); + } + + const salida = buscarSalida(build.cwd ?? workspace.dir); + if (!salida) { + throw new NoSePudoPublicar( + "el proyecto compiló pero no dejó archivos para servir. Publicar sirve para " + + "apps que se ven en el navegador; una que necesita servidor propio no cabe aquí", + ); + } + + /** + * El nombre del proyecto en Cloudflare es el id de la sala. + * + * Así republicar pisa lo anterior y el link que alguien ya compartió sigue + * sirviendo, en vez de acumular una URL nueva por cada vez que se publica. + * + * Se filtra aunque los ids ya vengan limpios: acaba en un dominio público y en + * un argumento de línea de comandos. + */ + const proyecto = workspace.roomId.replace(/[^a-z0-9-]/gi, "").toLowerCase().slice(0, 58); + + onEtapa?.("subiendo"); + const subido = await runner.exec( + `${cd}npx --yes wrangler@latest pages deploy ${JSON.stringify(salida)} ` + + `--project-name=${JSON.stringify(proyecto)} --commit-dirty=true`, + { + timeoutMs: 600_000, + maxOutput: 8000, + // Por aquí y no interpolado en el comando: así no queda a la vista en la + // lista de procesos de adentro. + env: { + CLOUDFLARE_API_TOKEN: cred.token, + CLOUDFLARE_ACCOUNT_ID: cred.accountId, + }, + }, + ); + + const url = sacarUrl(subido.stdout + "\n" + subido.stderr); + if (subido.code !== 0 || !url) { + // El texto crudo de wrangler NO se propaga: lleva el account id y, cuando + // falla la autenticación, fragmentos de lo que se le pasó. Se traduce a lo + // que la persona puede hacer al respecto. + throw new NoSePudoPublicar(explicarWrangler(subido.stderr + subido.stdout)); + } + return url; +} + +/** + * La URL que wrangler imprime al terminar. + * + * Se busca en la salida en vez de armarla como `${proyecto}.pages.dev` porque el + * primer deploy de un proyecto responde con una URL por rama, y adivinarla daría + * un link que no carga. + */ +function sacarUrl(salida: string): string | null { + const m = salida.match(/https:\/\/[a-z0-9.-]*\.pages\.dev[^\s]*/i); + return m ? m[0] : null; +} + +/** Lo último que dijo un comando que falló, que es donde suele estar el motivo. */ +function ultimaLinea(texto: string): string { + const lineas = texto.trim().split("\n").filter(Boolean); + return (lineas[lineas.length - 1] ?? "sin detalle").slice(0, 150); +} + +/** + * Traduce el error de wrangler a algo accionable. + * + * Mismo criterio que `explicarFalla` para los proveedores de modelo: el volcado + * crudo no le dice a nadie si el problema es la cuenta, el token o el proyecto, + * y sin saberlo no puede hacer nada. El detalle técnico se queda en el log del + * server. + */ +function explicarWrangler(texto: string): string { + if (/authentication|unauthorized|10000|invalid.*token/i.test(texto)) { + return "la credencial de publicación no sirve. Quien administra este Multi tiene que revisarla"; + } + if (/account.*not found|10001/i.test(texto)) { + return "la cuenta configurada para publicar no existe o el token no la alcanza"; + } + if (/limit|quota|too many/i.test(texto)) { + return "la cuenta de publicación llegó a su límite de proyectos"; + } + if (/ENOTFOUND|ETIMEDOUT|network|fetch failed/i.test(texto)) { + return "no se pudo hablar con el servicio de publicación. Vuelve a intentar"; + } + return `no se pudo publicar — ${ultimaLinea(texto)}`; +} + +/** + * La credencial del `.env` del server, o null si quien lo corre no la configuró. + * + * Se lee cada vez y no al arrancar para que agregarla no obligue a reiniciar. + */ +export function credencialDeDeploy(): Credencial | null { + const token = process.env.CLOUDFLARE_API_TOKEN; + const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; + if (!token || !accountId) return null; + return { token, accountId }; +} diff --git a/server/src/engine/runner.ts b/server/src/engine/runner.ts index fb5f553..58b65e3 100644 --- a/server/src/engine/runner.ts +++ b/server/src/engine/runner.ts @@ -10,7 +10,18 @@ import { execInContainer, type ExecResult } from "./container.js"; */ export interface Runner { readonly isolated: boolean; - exec(command: string, opts: { timeoutMs: number; maxOutput: number }): Promise; + exec( + command: string, + opts: { + timeoutMs: number; + maxOutput: number; + /** + * Variables solo para este comando. Las usa el deploy para pasarle el + * token de la plataforma a `wrangler` sin dejarlo en ningún lado. + */ + env?: Record; + }, + ): Promise; } /** El normal: el comando corre encerrado en el contenedor de la sala. */ @@ -34,7 +45,11 @@ export function localRunner(workspaceDir: string): Runner { isolated: false, exec(command, opts) { return new Promise((resolve, reject) => { - const child = spawn(command, { cwd: workspaceDir, shell: true, env: process.env }); + const child = spawn(command, { + cwd: workspaceDir, + shell: true, + env: { ...process.env, ...opts.env }, + }); let stdout = ""; let stderr = ""; diff --git a/server/src/index.ts b/server/src/index.ts index c28570e..09c3ecb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -38,6 +38,13 @@ import { import { MAX_AGENTS_PER_ROOM, resumenDeOtros } from "./engine/agents.js"; import { fileMutation } from "./engine/file-mutation.js"; import { leerVariables, guardarVariables } from "./engine/env.js"; +import { + publicarSala, + credencialDeDeploy, + NoSePudoPublicar, + type Credencial, + type EtapaDeploy, +} from "./engine/publicar.js"; import { startTurn, commitTurn, failTurnConCommit } from "./engine/turns.js"; import { commitAll, @@ -348,6 +355,59 @@ fastify.put<{ Params: { id: string }; Body: { variables?: unknown } }>( }, ); +/** + * Publicar la app de la sala en internet. + * + * Compila el proyecto y lo sube; devuelve la URL. La credencial es de quien + * corre este Multi, no de quien aprieta el botón: pedirle una cuenta en otro + * servicio a alguien que solo quiere enseñar su app es la fricción que este + * producto existe para quitar. + * + * Responde en cuanto arranca, no al terminar: un build tarda minutos y dejar la + * petición colgada ese rato la mataría por timeout. Lo que pasa después viaja + * por socket, que además llega a TODA la sala y no solo a quien lo pidió. + */ +fastify.post<{ Params: { id: string } }>("/rooms/:id/publicar", async (req, reply) => { + const room = getRoom(req.params.id) ?? (await wakeRoom(req.params.id)); + if (!room) return reply.code(404).send({ error: "sala no encontrada" }); + + const cred = credencialDeDeploy(); + if (!cred) { + // Sin credencial no se degrada en silencio: se dice que este Multi no tiene + // publicación configurada, que es algo que solo su dueño puede resolver. + return reply.code(409).send({ error: "este Multi no tiene configurada la publicación" }); + } + if (room.publicando) { + return reply.code(409).send({ error: "ya se está publicando", etapa: room.publicando }); + } + + void publicarEnSegundoPlano(room, cred); + return { ok: true }; +}); + +async function publicarEnSegundoPlano(room: Room, cred: Credencial): Promise { + const avisar = (etapa: EtapaDeploy) => { + room.publicando = etapa; + io.to(room.id).emit("deploy:progreso", { etapa }); + }; + + try { + avisar("compilando"); + const url = await publicarSala(room.workspace, await ensureRunner(room), cred, avisar); + io.to(room.id).emit("deploy:listo", { url }); + // Y al chat, para que el link quede en el historial de la sala y no solo en + // la pantalla de quien estaba mirando cuando terminó. + systemMsg(room, `la app está publicada: ${url}`); + } catch (err) { + console.error(`[sala ${room.id}] falló la publicación:`, err); + const mensaje = err instanceof NoSePudoPublicar ? err.message : "no se pudo publicar"; + io.to(room.id).emit("deploy:fallo", { mensaje }); + systemMsg(room, `no se pudo publicar: ${mensaje}`, "#d95d63"); + } finally { + room.publicando = null; + } +} + /** * Borrar una sala: su proyecto, su contenedor y su historial. * @@ -476,6 +536,9 @@ io.on("connection", (socket) => { // Quien llega a media cuesta no recibió el "preview:arrancando" (ya pasó), // y sin esto vería "la sala está vacía" mientras el proyecto se levanta. previewArrancando: room.previewBooting === true, + // Igual que el preview: quien llega a media publicación no recibió el + // evento de progreso (ya pasó) y vería el botón como si no hubiera nada. + publicando: room.publicando ?? null, agents: room.agents.list(), orphanTurns: room.orphanTurns ?? [], messages: history.map((m) => ({ diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 7c226b2..d02655d 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -54,6 +54,14 @@ export interface Room { preview: Preview | null; /** Hay un arranque de preview en curso (evita dos npm install a la vez). */ previewBooting?: boolean; + /** + * Por dónde va la publicación, o null si no hay ninguna en curso. + * + * Vive en la sala y no en quien apretó el botón: publicar tarda minutos, y + * quien entre a media publicación tiene que ver el progreso en vez de un botón + * quieto. También impide que dos personas suban a la vez. + */ + publicando?: "compilando" | "subiendo" | null; /** El contenedor que aísla esta sala. null si se está corriendo sin Docker. */ container?: Container | null; /** Dónde se ejecutan los comandos del agente (contenedor o local). */ diff --git a/web/src/App.tsx b/web/src/App.tsx index cc423ef..caecf3f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -175,6 +175,13 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { const [creandoSala, setCreandoSala] = useState(false); /** Qué dice el botón de descargar ahora mismo. null = su texto normal. */ const [zipAviso, setZipAviso] = useState(null); + /** + * Por dónde va la publicación, o null si no hay ninguna. + * + * Es de la SALA, no de quien apretó: llega en el `joined` y por socket, así + * que quien entre a media publicación ve el progreso igual que los demás. + */ + const [publicando, setPublicando] = useState<"compilando" | "subiendo" | null>(null); /** * Si la sala tiene algo guardado que llevarse. * @@ -277,6 +284,7 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { setMembers(p.members); setNombre(p.nombre ?? null); recordarNombre(roomId, p.nombre ?? null); + setPublicando(p.publicando ?? null); if (p.previewUrl) setPreviewReady(true); if (p.agents) setAgents(p.agents); if (p.orphanTurns?.length) setOrphans(p.orphanTurns); @@ -298,6 +306,14 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { setNombre(nombre); recordarNombre(roomId, nombre); }); + + // La publicación la ve toda la sala. El link y los fallos llegan además al + // chat, así que aquí solo se mueve el estado del botón. + socket.on("deploy:progreso", ({ etapa }: { etapa: "compilando" | "subiendo" }) => + setPublicando(etapa), + ); + socket.on("deploy:listo", () => setPublicando(null)); + socket.on("deploy:fallo", () => setPublicando(null)); socket.on("preview:ready", () => { setPreviewReady(true); setArrancando(null); @@ -528,6 +544,29 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { const copyLink = () => navigator.clipboard.writeText(window.location.href); + /** + * Publicar la app de la sala. + * + * No espera a que termine: el server responde en cuanto arranca y lo demás + * llega por socket. Un build tarda minutos, y una petición colgada ese rato se + * moriría por timeout antes de contar nada. + */ + const publicar = async () => { + if (!roomId) return; + setPublicando("compilando"); // respuesta inmediata; el server confirma en seguida + try { + const r = await fetch(`${SERVER_URL}/rooms/${roomId}/publicar`, { method: "POST" }); + if (!r.ok) { + const d = (await r.json().catch(() => null)) as { error?: string } | null; + setPublicando(null); + alert(d?.error ?? t.publicarFallo); + } + } catch { + setPublicando(null); + alert(t.publicarFallo); + } + }; + /** Crear otra sala y entrar a ella. El hash es lo que cambia de sala. */ const crearSala = async () => { setCreandoSala(true); @@ -893,6 +932,16 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { > {zipAviso ?? t.descargarZip} + {/* Publicar tarda minutos, así que el botón dice por dónde va en + lugar de quedarse quieto: mismo trato que el de descargar. */} + {/* Las variables son del proyecto de la sala, así que sin sala no hay dónde escribirlas. */} {roomId && } diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index f814449..4cc4ce4 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -54,6 +54,13 @@ const TEXTOS = { copiarLink: "Copiar link", copiado: "Copiado", descargarZip: "Descargar .zip", + publicar: "Publicar", + publicarTitulo: "Poner la app en internet, con un link para compartir", + etapaDeploy: { + compilando: "Compilando…", + subiendo: "Subiendo…", + } as Record, + publicarFallo: "no se pudo publicar", preparandoZip: "Preparando…", zipSalaVacia: "todavía no hay nada que descargar", zipTrabajoSinGuardar: "hay trabajo sin guardar, el .zip lleva el último punto guardado", @@ -217,6 +224,13 @@ const TEXTOS = { copiarLink: "Copy link", copiado: "Copied", descargarZip: "Download .zip", + publicar: "Publish", + publicarTitulo: "Put the app online, with a link you can share", + etapaDeploy: { + compilando: "Building…", + subiendo: "Uploading…", + } as Record, + publicarFallo: "couldn't publish it", preparandoZip: "Preparing…", zipSalaVacia: "nothing to download yet", zipTrabajoSinGuardar: "there's unsaved work, the .zip has the last saved point", diff --git a/web/src/socket.ts b/web/src/socket.ts index 75e6c65..5cc545c 100644 --- a/web/src/socket.ts +++ b/web/src/socket.ts @@ -96,6 +96,8 @@ export interface JoinedPayload { previewUrl: string | null; /** El preview se está levantando ahora mismo (llegaste a media cuesta). */ previewArrancando?: boolean; + /** Se está publicando la app ahora mismo, y por dónde va. */ + publicando?: "compilando" | "subiendo" | null; agents?: Agent[]; /** Turnos que quedaron a medias por un crash; el humano decide qué hacer. */ orphanTurns?: OrphanTurn[];