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[];