@jun/soul/lib
Lib
Utilidades sin estado: escapado, slugs, cache, hash, email y captcha.
Que es#
Funciones puras y sin dependencias, extraidas de proyectos donde ya estuvieron en produccion. No hay configuracion: se importan y se usan.
HTML y texto#
escapeHtml(s)- Escapa
&,<,>y". Obligatorio para cualquier dato de usuario que armes a mano. robotsMeta(noindex)- Devuelve la meta
noindex, nofollowo cadena vacia. slugify(s)- Texto a slug: quita acentos, deja
[a-z0-9-]. generateSlug(s, fallback = "item")- Slug unico: base legible + 6 caracteres hex aleatorios.
Cache#
cacheKeyFor(url)- Arma la clave quitando query y hash.
invalidateCache(appUrl, path)- Borra una pagina del cache del edge.
Secretos#
sha256Hex(s)- SHA-256 en hexadecimal. Se guarda el hash de un secreto, nunca el secreto.
timingSafeEqualHex(a, b)- Comparacion en tiempo constante. Evita que el tiempo de respuesta filtre cuantos caracteres acerto un atacante.
randomCode(groups = 4, groupSize = 4)- Codigo legible tipo
A1B2-C3D4-…, con alfabeto sin caracteres ambiguos (nada de 0/O ni 1/I/L). randomToken(bytes = 32)- Token hexadecimal opaco para maquinas. 32 bytes son 256 bits.
Email y captcha#
sendEmail(env, { to, subject, text })- Envia por Resend. Sin
RESEND_API_KEYsolo haceconsole.log, asi que el flujo se prueba en local sin cuenta. verifyTurnstile(secretKey, token, ip?)- Valida el captcha de Cloudflare contra siteverify.
Slug unico para un recurso publico#
import { generateSlug } from "@jun/soul/lib";
memoriales.post("/", async (c) => {
const nombre = String((await c.req.parseBody()).nombre ?? "").trim();
// "Maria Perez" -> "maria-perez-a3f9c1": legible y sin colisiones
const slug = generateSlug(nombre, "memorial");
await c.env.DB.prepare(
"INSERT INTO memoriales (org_id, slug, nombre) VALUES (?, ?, ?)",
).bind(c.var.org.id, slug, nombre).run();
return c.redirect(`/m/${slug}`);
});
Pagina publica cache-first#
El patron completo: leer del cache, y si no esta, construir, guardar y devolver.
import { cacheKeyFor, invalidateCache } from "@jun/soul/lib";
app.get("/m/:slug", async (c) => {
const key = cacheKeyFor(c.req.url);
const hit = await caches.default.match(key);
if (hit) return hit; // 0 consultas a D1
const row = await c.env.DB.prepare(
"SELECT nombre, biografia FROM memoriales WHERE slug = ? AND publicado = 1",
).bind(c.req.param("slug")).first<{ nombre: string; biografia: string }>();
if (!row) return c.html(errorPage("No encontrado", "Esta pagina no existe."), 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;
});
// Al editar, se invalida: la publicacion es instantanea.
app.post("/memoriales/:id", async (c) => {
// ...guardar
await invalidateCache(c.env.APP_URL, `/m/${slug}`);
return c.redirect(`/m/${slug}`);
});
Guardar un secreto por hash#
import { sha256Hex, timingSafeEqualHex, randomToken } from "@jun/soul/lib";
// Al crear: se guarda el hash y se muestra el secreto una sola vez.
const token = `sk_${randomToken(32)}`;
await c.env.DB.prepare("UPDATE integraciones SET token_hash = ? WHERE id = ?")
.bind(await sha256Hex(token), id)
.run();
// Al verificar: se compara hash contra hash, en tiempo constante.
const guardado = await c.env.DB.prepare(
"SELECT token_hash FROM integraciones WHERE id = ?",
).bind(id).first<{ token_hash: string }>();
const valido = guardado
? timingSafeEqualHex(await sha256Hex(entrante), guardado.token_hash)
: false;
Codigo legible para un humano#
import { randomCode } from "@jun/soul/lib";
// LICET-A1B2-C3D4-E5F6-G7H8 — sin 0/O ni 1/I/L, para que se pueda dictar
export const generateLicenseKey = () => `LICET-${randomCode(4, 4)}`;
// Los ultimos 4 son lo unico mostrable despues de guardarla hasheada
export const keyHint = (key: string) => `LICET-….-${key.slice(-4)}`;
Email transaccional#
import { sendEmail } from "@jun/soul/lib";
// waitUntil: no hacemos esperar la respuesta por el email.
c.executionCtx.waitUntil(
sendEmail(c.env, {
to: usuario.email,
subject: "Tu licencia de Mi App",
text: [
`Hola,`,
``,
`Tu clave es: ${key}`,
`Actívala desde Ajustes → Licencia.`,
].join("\n"),
}).catch((err) => console.log("Email no enviado:", err)),
);
Captcha en un formulario publico#
import { verifyTurnstile } from "@jun/soul/lib";
contacto.post("/", async (c) => {
const body = await c.req.parseBody();
const secret = c.env.TURNSTILE_SECRET_KEY;
// Si no hay clave configurada, se salta: asi el formulario funciona en dev.
if (secret) {
const token = String(body["cf-turnstile-response"] ?? "");
const ok = token && (await verifyTurnstile(secret, token, c.req.header("cf-connecting-ip")));
if (!ok) return c.html(errorPage("Verificacion fallida", "Completa el captcha."), 400);
}
// ...guardar el mensaje
});
<!-- En el formulario, mas securityHeaders({ turnstile: true }) -->
<div class="cf-turnstile" data-sitekey="${c.env.TURNSTILE_SITE_KEY}"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" defer></script>
Escapar siempre que armes HTML a mano#
import { escapeHtml } from "@jun/soul/lib";
// MAL: el nombre puede traer <script>
const malo = `<h1>${cliente.nombre}</h1>`;
// BIEN
const bueno = `<h1>${escapeHtml(cliente.nombre)}</h1>`;