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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
- run: npx playwright-core install --with-deps chromium
- run: npm run demo:ver-preview
- run: npm run demo:internet
- run: npm run demo:publicar
- run: npm run demo:documento
- run: npm run demo:doc-sync
- run: npm run demo:comentarios
47 changes: 45 additions & 2 deletions server/src/demos/publicar.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { writeFile, mkdir } from "node:fs/promises";
import { writeFile, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createWorkspace } from "../engine/workspace.js";
import { detectBuild, buscarSalida } from "../engine/preview.js";
import { credencialDeDeploy, motivoDeFallo, sacarUrl } from "../engine/publicar.js";
import { credencialDeDeploy, motivoDeFallo, sacarUrl, nombreDeApp } from "../engine/publicar.js";
import { SqliteStorage } from "../storage/sqlite.js";

/**
* Demo: qué se compila y qué se sube al publicar la app de una sala.
Expand Down Expand Up @@ -127,6 +129,47 @@ async function main() {
check("sin URL en la salida, no se inventa una", sacarUrl("todo mal", "una-sala") === null);
}

console.log("\n9b. El nombre lo elige quien publica");
{
check("Página tipo Uber!! → pagina-tipo-uber", nombreDeApp("Página tipo Uber!!") === "pagina-tipo-uber", String(nombreDeApp("Página tipo Uber!!")));
check("ñ y espacios de sobra", nombreDeApp(" Año Ñandú ") === "ano-nandu", String(nombreDeApp(" Año Ñandú ")));
check("solo símbolos no da nombre", nombreDeApp("!!! ???") === null);
const largo = nombreDeApp("a".repeat(57) + "-bcd") ?? "";
check("se corta a 58 y sin guion al final", largo.length <= 58 && !largo.endsWith("-"), largo);
check("un id de sala se queda igual", nombreDeApp("vibe-fiesta-45") === "vibe-fiesta-45");

// Los subdominios de pages.dev son de todo Cloudflare: si otra cuenta ya
// tiene el nombre, el proyecto queda con un sufijo.
const conSufijo = "✨ Deployment complete! Take a peek over at https://9f1c2a3b.mi-banco-3xk.pages.dev";
check(
"con sufijo de Cloudflare devuelve la estable con sufijo",
sacarUrl(conSufijo, "mi-banco") === "https://mi-banco-3xk.pages.dev",
String(sacarUrl(conSufijo, "mi-banco")),
);
check("sin hash ni sufijo", sacarUrl("https://mi-banco.pages.dev/", "mi-banco") === "https://mi-banco.pages.dev");

const dir = await mkdtemp(join(tmpdir(), "multi-nombres-"));
try {
const db = new SqliteStorage(join(dir, "multi.db"));
await db.init();
const sala = (id: string) => ({ id, workspaceDir: join(dir, id), createdAt: 1, lastActiveAt: 1 });
await db.createRoom(sala("vieja-sala-1"));
await db.createRoom(sala("nueva-sala-2"));
await db.createRoom(sala("otra-sala-3"));
// Una que publicó antes de que se pudiera elegir: su proyecto se llama como su id.
await db.setPublicacion("vieja-sala-1", "https://vieja-sala-1.pages.dev", "vieja-sala-1");
await db.setPublicacion("nueva-sala-2", "https://mi-banco.pages.dev", "mi-banco");
check("un nombre que usa otra sala está tomado", await db.nombreEnUso("mi-banco", "otra-sala-3"));
check("pero no para la sala que lo tiene", !(await db.nombreEnUso("mi-banco", "nueva-sala-2")));
check("el id de una sala vieja publicada está tomado", await db.nombreEnUso("vieja-sala-1", "otra-sala-3"));
check("un nombre libre no", !(await db.nombreEnUso("mi-tienda", "otra-sala-3")));
const leida = await db.getRoom("nueva-sala-2");
check("se guarda y se lee el nombre", leida?.nombrePublicado === "mi-banco", JSON.stringify(leida));
} finally {
await rm(dir, { recursive: true, force: true });
}
}

console.log("\n10. Una app con servidor propio no cabe, y se dice antes de subir");
{
/**
Expand Down
61 changes: 45 additions & 16 deletions server/src/engine/publicar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,20 @@ export async function publicarSala(
workspace: Workspace,
runner: Runner,
cred: Credencial,
/**
* El nombre del proyecto en Cloudflare, que es la dirección de la app.
*
* Siempre el mismo para la misma sala: así republicar pisa lo anterior y el
* link que alguien ya compartió sigue sirviendo, en vez de acumular una URL
* nueva cada vez. Lo decide quien llama (ver /rooms/:id/publicar).
*/
nombre: string,
onEtapa?: (etapa: EtapaDeploy) => void,
): Promise<string> {
// Se filtra aunque ya venga limpio: acaba en un dominio y en un comando.
const proyecto = nombreDeApp(nombre);
if (!proyecto) throw new NoSePudoPublicar("ese nombre no sirve para una dirección web");

const build = await detectBuild(workspace.dir);
if (!build) {
throw new NoSePudoPublicar(
Expand Down Expand Up @@ -90,17 +102,6 @@ export async function publicarSala(
);
}

/**
* 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");

/**
Expand Down Expand Up @@ -163,11 +164,39 @@ export async function publicarSala(
* lo esperado y es mejor no inventar una URL que quizá no carga.
*/
export function sacarUrl(salida: string, proyecto: string): string | null {
const conPrefijo = salida.match(/https:\/\/[a-z0-9.-]*\.pages\.dev[^\s]*/i)?.[0];
if (!conPrefijo) return null;
return conPrefijo.includes(`${proyecto}.pages.dev`)
? `https://${proyecto}.pages.dev`
: conPrefijo;
const urls = salida.match(/https:\/\/[a-z0-9.-]*\.pages\.dev[^\s]*/gi);
if (!urls) return null;
for (const url of urls) {
const etiquetas = new URL(url).hostname.split(".");
// `hash.sub.pages.dev` o `sub.pages.dev`.
const sub = etiquetas.length === 4 ? etiquetas[1]! : etiquetas[0]!;
// Los subdominios de pages.dev son de todo Cloudflare, no de esta cuenta:
// si el nombre ya lo tiene otra cuenta, el proyecto queda en
// `nombre-abc.pages.dev`. Esa es la estable, aunque no sea la que se pidió.
if (sub === proyecto || sub.startsWith(`${proyecto}-`)) return `https://${sub}.pages.dev`;
}
return urls[0]!;
}

/**
* El nombre de una app para su dirección: `nombre.pages.dev`.
*
* Lo escribe la persona ("Página tipo Uber!!"), así que se pasa a lo que acepta
* un subdominio: minúsculas sin acentos, guiones en lugar de lo demás, y el tope
* de 58 que pone Cloudflare. Acaba en un dominio público y en un argumento de
* línea de comandos, así que se filtra siempre, venga de donde venga.
* `null` si no quedó nada.
*/
export function nombreDeApp(texto: string): string | null {
const limpio = texto
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 58)
.replace(/-+$/g, "");
return limpio || null;
}

/**
Expand Down
62 changes: 56 additions & 6 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ import {
} from "./supabase.js";
import {
publicarSala,
nombreDeApp,
credencialDeDeploy,
NoSePudoPublicar,
type Credencial,
Expand Down Expand Up @@ -1456,7 +1457,7 @@ fastify.delete<{ Params: { id: string } }>("/rooms/:id/supabase", async (req) =>
* 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) => {
fastify.post<{ Params: { id: string }; Body: { nombre?: unknown } | undefined }>("/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" });

Expand All @@ -1470,22 +1471,69 @@ fastify.post<{ Params: { id: string } }>("/rooms/:id/publicar", async (req, repl
return reply.code(409).send({ error: "ya se está publicando", etapa: room.publicando });
}

void publicarEnSegundoPlano(room, cred);
const nombre = await nombreParaPublicar(room, req.body?.nombre);
if ("error" in nombre) return reply.code(nombre.codigo).send({ error: nombre.error });

void publicarEnSegundoPlano(room, cred, nombre.nombre);
return { ok: true };
});

async function publicarEnSegundoPlano(room: Room, cred: Credencial): Promise<void> {
/**
* Con qué nombre se publica: la dirección `nombre.pages.dev`.
*
* Se elige una vez, antes de la primera publicación. Cloudflare no deja
* renombrar un proyecto: cambiarlo después dejaría el link que ya se compartió
* apuntando a una versión vieja, o roto. Así que una sala que ya publicó sigue
* con el suyo y lo que llegue en el pedido se ignora; una que publicó antes de
* que se pudiera elegir se llama como su id, que era la regla de entonces.
*
* Para la primera vez: lo que escribió la persona, o el nombre de la sala, o el
* id, en ese orden.
*/
async function nombreParaPublicar(
room: Room,
pedido: unknown,
): Promise<{ nombre: string } | { error: string; codigo: 400 | 409 }> {
if (room.nombrePublicado) return { nombre: room.nombrePublicado };
if (room.urlPublicada) return { nombre: nombreDeApp(room.id) ?? room.id };

const escrito = typeof pedido === "string" && pedido.trim() ? pedido : null;
const nombre = escrito
? nombreDeApp(escrito)
: (room.nombre ? nombreDeApp(room.nombre) : null) ?? nombreDeApp(room.id);
if (!nombre) return { error: "ese nombre no sirve: usa letras o números", codigo: 400 };
const otraLoEstaPublicando = [...nombresPublicandose].some(([sala, n]) => n === nombre && sala !== room.id);
if (otraLoEstaPublicando || (await (await getStorage()).nombreEnUso(nombre, room.id))) {
return { error: "ese nombre ya lo usa otra app de Multti", codigo: 409 };
}
return { nombre };
}

/**
* Los nombres que se están publicando ahorita, por sala.
*
* El nombre se guarda en la base hasta que la publicación sale, y un build tarda
* minutos: sin esto, dos salas que publican al mismo tiempo con el mismo nombre
* pasarían las dos la revisión y la segunda pisaría a la primera.
*/
const nombresPublicandose = new Map<string, string>();

async function publicarEnSegundoPlano(room: Room, cred: Credencial, nombre: string): Promise<void> {
nombresPublicandose.set(room.id, nombre);
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);
const url = await publicarSala(room.workspace, await ensureRunner(room), cred, nombre, avisar);
// El nombre se guarda hasta aquí, cuando ya salió: un intento que falló no
// se queda con él, y la persona lo puede cambiar antes de volver a probar.
room.urlPublicada = url;
await (await getStorage()).setUrlPublicada(room.id, url);
io.to(room.id).emit("deploy:listo", { url });
room.nombrePublicado = nombre;
await (await getStorage()).setPublicacion(room.id, url, nombre);
io.to(room.id).emit("deploy:listo", { url, nombre });
// 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}`);
Expand All @@ -1501,6 +1549,7 @@ async function publicarEnSegundoPlano(room: Room, cred: Credencial): Promise<voi
systemMsg(room, `no se pudo publicar: ${mensaje}`, "#d95d63");
} finally {
room.publicando = null;
nombresPublicandose.delete(room.id);
}
}

Expand Down Expand Up @@ -1697,6 +1746,7 @@ io.on("connection", (socket) => {
// Dónde está publicada, si lo está: es un dato de la sala, no una noticia
// de hace rato. Quien entra mañana tiene que poder abrirla igual.
urlPublicada: room.urlPublicada ?? null,
nombrePublicado: room.nombrePublicado ?? null,
agents: room.agents.list(),
actividad: Object.fromEntries(ultimaAccion.get(room.id) ?? []),
orphanTurns: room.orphanTurns ?? [],
Expand Down
8 changes: 8 additions & 0 deletions server/src/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ export interface Room {
* estaba enterrado entre los demás.
*/
urlPublicada?: string | null;
/**
* Con qué nombre se publicó: `nombre.pages.dev`. Se elige una sola vez, antes
* de la primera publicación, porque Cloudflare no deja renombrar un proyecto.
* Null en una sala sin publicar, o en una que publicó antes de que se pudiera
* elegir (esa se llama como su id).
*/
nombrePublicado?: string | null;
/**
* Si en esta sala hay que mencionar al agente para despertarlo.
*
Expand Down Expand Up @@ -277,6 +284,7 @@ async function despertarSala(id: string): Promise<Room | null> {
id,
nombre: stored.nombre ?? null,
urlPublicada: stored.urlPublicada ?? null,
nombrePublicado: stored.nombrePublicado ?? null,
// Las salas anteriores a esta columna vuelven en multi, que es como se
// comportaron siempre: nada cambia debajo de quien ya las usaba.
modo: stored.modo ?? "multi",
Expand Down
Loading
Loading