Tres niveles de pagina
Elegir cuanto cuesta servir cada pagina, y por que casi nunca conviene 0.
La decision#
Cada pagina de un proyecto elige como se sirve, y eso determina su costo. Son tres niveles:
| Nivel | Como se sirve | Worker | D1 | Para que |
|---|---|---|---|---|
| Estatica | Asset prerenderizado | 0 | 0 | Landing, precios, terminos |
| Publica dinamica | Worker + cache-first | 1 | 0 en hit | Perfil publico, recurso por slug |
| Portal | Worker tras el login | 1 | N | Todo lo que es por usuario u organizacion |
El mito que hay que desarmar#
Y aun asi, casi nunca persigas el 0#
Los Workers son el recurso abundante: 100.000 requests diarias en el plan gratuito. D1 es el escaso. Optimizar requests a cero solo vale la pena en paginas fijas de mucho trafico — la landing, poco mas. En el resto, gastar una request para tener contenido fresco es el negocio correcto.
Nivel 1: estatica#
npx soul g page landing --static
import { defineStatic } from "@jun/soul/static";
import { shell } from "./core";
export const staticPages = defineStatic([
{ path: "/", render: () => shell({ title: "Mi SaaS", body: landing() }) },
]);
Se prerenderiza con soul build y se sirve desde public/. Ver
Static.
Nivel 2: publica dinamica#
npx soul g page perfil --public
import { cacheKeyFor } from "@jun/soul/lib";
app.get("/u/:slug", async (c) => {
const key = cacheKeyFor(c.req.url);
const hit = await caches.default.match(key);
if (hit) return hit; // el Worker corre, D1 no
const row = await c.env.DB.prepare(
"SELECT nombre, bio FROM perfiles WHERE slug = ?",
).bind(c.req.param("slug")).first();
if (!row) return c.html(errorPage("No encontrado", "…"), 404);
const res = c.html(shell({ title: row.nombre, body: render(row) }));
res.headers.set("Cache-Control", "public, max-age=300");
c.executionCtx.waitUntil(caches.default.put(key, res.clone()));
return res;
});
Nivel 3: portal#
npx soul g page ajustes
Detras de tenancy.requireSessionWithOrg(). No se cachea nunca: el contenido depende de
quien mira.
Como elegir#
- ¿Depende de quien mira? → portal.
- ¿Es publica pero sale de la base? → publica dinamica.
- ¿Es publica, fija, y publicar un cambio puede esperar a un deploy? → estatica.
Los tres en un mismo proyecto#
Asi esta montado Memorial, que usa los tres a la vez:
// src/index.ts
const app = new Hono<{ Bindings: Env }>();
app.use("*", securityHeaders({ tailwindCdn: true }));
// NIVEL 3 — portal: crear y editar memoriales
app.route("/auth", auth.routes);
app.route("/dashboard", dashboard); // requireSessionWithOrg
app.route("/admin", adminPanel); // requireAdmin
// NIVEL 2 — publica dinamica: la pagina de cada memorial, cache-first
app.route("/", memorial); // GET /m/:slug
// NIVEL 1 — estatica: la landing "/" es un asset y sombrea cualquier ruta "/"
// que registremos aqui (queda como red de seguridad si no corrio el build).
export default app;
Medir en cual estas#
# ¿Se ejecuta el Worker? Si la request no aparece en el tail, es un asset.
npx wrangler tail --env production
# En otra terminal:
curl -s -o /dev/null https://mi-saas.workers.dev/ # landing: no aparece
curl -s -o /dev/null https://mi-saas.workers.dev/m/ana # publica: aparece
Y para confirmar que el cache esta funcionando, la segunda visita debe ser mas rapida y no tocar D1:
curl -s -o /dev/null -w "1a: %{time_total}s\n" https://mi-saas.workers.dev/m/ana
curl -s -o /dev/null -w "2a: %{time_total}s\n" https://mi-saas.workers.dev/m/ana
Convertir una publica en estatica#
Si una pagina dinamica resulta que casi nunca cambia, el cambio es mecanico: la funcion de render
tiene que dejar de recibir el Context.
// ANTES — ruta del Worker, lee la sesion para decidir el CTA
app.get("/", async (c) => {
const session = await readSession(c, c.env.SESSION_SECRET);
return c.html(shell({ title: SITE, body: landing({ autenticado: Boolean(session) }) }));
});
// DESPUES — funcion pura: sin Context, sin sesion
export function renderLanding(opts: { siteName: string; isPet: boolean }) {
return `<main>…</main>`; // el CTA lo decide la cookie pista en el navegador
}
// src/static.ts
export const staticPages = defineStatic([
{ path: "/", render: () => shell({ title: SITE, body: renderLanding({ siteName: SITE, isPet: false }) }) },
]);
Invalidar bien#
import { invalidateCache } from "@jun/soul/lib";
memoriales.post("/:id", async (c) => {
const row = await tenancy.requireTenantResource(c, "memoriales", c.req.param("id"));
if (row instanceof Response) return row;
await c.env.DB.prepare("UPDATE memoriales SET bio = ? WHERE id = ?")
.bind(nuevaBio, row.id).run();
// Si el slug cambio, hay que invalidar la URL VIEJA tambien: ahi quedo
// contenido obsoleto que nadie va a limpiar solo.
await invalidateCache(c.env.APP_URL, `/m/${row.slug}`);
if (nuevoSlug !== row.slug) await invalidateCache(c.env.APP_URL, `/m/${nuevoSlug}`);
return c.redirect(`/m/${nuevoSlug}`);
});