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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
94 changes: 94 additions & 0 deletions server/src/demos/publicar.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<void> {
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);
});
20 changes: 18 additions & 2 deletions server/src/engine/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
},
): Promise<ExecResult> {
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 = "";
Expand Down
72 changes: 72 additions & 0 deletions server/src/engine/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,75 @@ async function killDevServersIn(roomId: string, puerto: number): Promise<void> {
// 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<Launch | null> {
const enRaiz = await leerBuild(dir);
if (enRaiz) return enRaiz;
return buscarBuildEnSubcarpetas(dir);
}

async function leerBuild(dir: string): Promise<Launch | null> {
const pkgPath = join(dir, "package.json");
if (!existsSync(pkgPath)) return null;

let scripts: Record<string, string> = {};
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<Launch | null> {
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;
}
Loading
Loading