@jun/soul/static
Static
Paginas prerenderizadas que se sirven sin invocar el Worker.
Que resuelve#
Una landing o una pagina de precios cambian poco y reciben mucho trafico. Servirlas desde el Worker gasta una request por visita sin ganar nada. Este modulo las prerenderiza en tiempo de build y las deja como assets de Cloudflare: 0 requests de Worker, 0 D1.
Como funciona#
El proyecto declara sus paginas estaticas en src/static.ts. Cada una es una funcion
pura: recibe nada y devuelve HTML.
import { defineStatic } from "@jun/soul/static";
import { shell } from "./core";
export const staticPages = defineStatic([
{
path: "/",
render: () => shell({ title: "Mi SaaS", body: landing() }),
},
{
path: "/precios",
render: () => shell({ title: "Precios", body: precios() }),
},
]);
Construir#
npx soul build # prerenderiza a public/<path>/index.html y copia ./assets
path: "/" se escribe en public/index.html; /precios en
public/precios/index.html. El template ya llama a soul build dentro de
deploy:production.
Servirlas#
Hace falta el binding de assets en wrangler.jsonc:
{
"assets": { "directory": "./public" },
"env": {
"production": {
"name": "mi-saas",
"assets": { "directory": "./public" } // los envs NO heredan: repetir
}
}
}
Generarlas con el CLI#
npx soul g page landing --static # nombre index/home/landing => path "/"
npx soul g page precios --static
A diferencia de las otras paginas, --static no toca src/index.ts: no hay
ruta que registrar.
Nav consciente de la sesion#
El problema evidente de una landing estatica: no sabe si el visitante tiene sesion, asi que no puede elegir entre "Entrar" e "Ir al panel". La solucion es una cookie pista:
export const auth = createAuth({ errorPage, sessionHintCookie: "mi_hint" });
Junto al JWT HttpOnly, soul emite una cookie sin HttpOnly con valor "1" (y la
limpia al salir). La landing la lee desde JavaScript y decide que boton mostrar.
Cuando NO usarla#
- Si la pagina depende de datos de D1 que cambian seguido.
- Si es por-usuario o por-organizacion (eso es portal).
- Si publicar un cambio no puede esperar a un despliegue.
src/static.ts completo#
import { defineStatic } from "@jun/soul/static";
import { shell } from "./core";
import { PLANS } from "./config";
import { formatLimit } from "@jun/soul/plans";
// Los valores de build llegan por variables de entorno del proceso, no del Worker.
declare const process: { env: Record<string, string | undefined> };
const SITE_NAME = process.env.SITE_NAME ?? "Mi SaaS";
function landing() {
return `<main class="soul-page">
<section class="soul-hero">
<h1 class="soul-hero-title">${SITE_NAME}</h1>
<p class="soul-hero-sub">La forma simple de llevar tus facturas.</p>
<div class="soul-hero-cta">
<a class="soul-btn soul-btn--primary" href="/auth/login" data-cta="anon">Entrar con Google</a>
<a class="soul-btn soul-btn--primary" href="/app" data-cta="auth" hidden>Ir al panel</a>
</div>
</section>
</main>`;
}
function precios() {
const filas = Object.entries(PLANS).map(([nombre, limites]) =>
`<div class="soul-card">
<h3>${nombre}</h3>
<p>${formatLimit(limites.proyectos)} proyectos</p>
<p>${formatLimit(limites.miembros)} miembros</p>
</div>`,
).join("");
return `<main class="soul-page"><h1>Precios</h1><div class="soul-features-grid">${filas}</div></main>`;
}
export const staticPages = defineStatic([
{ path: "/", render: () => shell({ title: SITE_NAME, body: landing() }) },
{ path: "/precios", render: () => shell({ title: `Precios — ${SITE_NAME}`, body: precios() }) },
{ path: "/legal/terminos", render: () => shell({ title: "Terminos", body: terminos() }) },
]);
Nav sin parpadeo#
Si decides el boton despues de pintar, se ve el cambio. El truco es decidirlo en el
<head>, antes del primer pintado, con una clase en <html> y CSS que oculte
el que sobra.
const HINT_SCRIPT = `<script>
if (document.cookie.includes("mi_hint=1")) document.documentElement.classList.add("has-session");
</script>
<style>
.has-session [data-cta="anon"] { display: none; }
.has-session [data-cta="auth"] { display: inline-flex !important; }
</style>`;
export const staticPages = defineStatic([
{
path: "/",
render: () => shell({ title: SITE_NAME, body: landing(), head: HINT_SCRIPT }),
},
]);
Una marca por entorno, desde el mismo codigo#
Memorial despliega dos sitios (personas y mascotas) desde un solo repositorio, cambiando variables en el build:
{
"scripts": {
"build": "SITE_KIND=person SITE_NAME=Memorial soul build",
"build:pets": "SITE_KIND=pet SITE_NAME='Memorial de Mascotas' soul build",
"deploy:production": "npm run build && wrangler deploy --env production",
"deploy:pets": "npm run build:pets && wrangler deploy --env pets"
}
}
declare const process: { env: Record<string, string | undefined> };
const ES_MASCOTA = process.env.SITE_KIND === "pet";
const SITE_NAME = process.env.SITE_NAME ?? "Memorial";
export const staticPages = defineStatic([
{
path: "/",
render: () => shell({
title: SITE_NAME,
body: renderLanding({ siteName: SITE_NAME, isPet: ES_MASCOTA }),
}),
},
]);
Verificar que de verdad son 0 requests#
npm run build
ls public/ # index.html, precios/index.html, legal/terminos/index.html
npm run deploy:production
# La landing responde sin invocar el Worker: no aparece en los logs.
curl -o /dev/null -w "%{http_code}\n" https://mi-saas.workers.dev/
npx wrangler tail --env production # y no se ve la request
Convivencia con las rutas del Worker#
// La landing "/" es un asset y sombrea esta ruta: nunca se ejecuta.
app.get("/", (c) => c.html(shell({ title: "…", body: "…" })));
// Estas si viven en el Worker.
app.route("/auth", auth.routes);
app.route("/app", portal);
app.get("/m/:slug", paginaPublicaCacheFirst);