Frontend
Tu app en el teléfono (PWA)
Un sitio Synsema se instala en un teléfono o en un escritorio como una app: un ícono en la pantalla de inicio, pantalla completa sin el chrome del navegador, una shell que abre sin red, y notificaciones push — con nada más que la web que el servidor ya renderiza. Sin cliente nativo, sin tienda, sin framework. El cliente es el navegador que el usuario ya tiene; el backend, los agentes y los secretos se quedan en el servidor, que es donde van.
synsema init --pwa miapp && cd miapp
synsema serve app.syn # http://localhost:8080 — Chrome y Edge la instalan desde localhost
Apretá Run para la parte doctesteada — el par VAPID nace sellado, la clave privada sólo se acepta como secret, y el envío se rechaza antes de abrir ningún socket si no declaraste el push service:
-- Doc example: Web Push (installable apps). The VAPID pair is born SEALED, the private
-- key is accepted only as a secret, and delivering is refused before any socket opens
-- unless the push service's host was declared (deny-by-default). Runs anywhere: no
-- network is touched.
intent: "doc example: web push — VAPID keys + the gates"
require random -- push_vapid_keys() creates new secret material
let keys be push_vapid_keys() -- {public: text, private: secret}
print("public key: " + text(length(keys["public"])) + " chars · private: " + text(keys["private"]))
task subscription()
-- the shape the browser gives you: PushSubscription.toJSON() (keys from RFC 8291 here)
give {"endpoint": "https://web.push.apple.com/QAbc123", "keys": {"p256dh": "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", "auth": "BTBZMqHH6r4Tts7J_aSIgg"}}
task send_without_net()
-- no `require net("web.push.apple.com")` → refused at the capability check
give push_send(subscription(), {"title": "hi"}, {"vapid": {"public": keys["public"], "private": keys["private"], "subject": "mailto:ops@example.com"}})
task send_with_a_plain_text_key()
-- a private key as plain text is never accepted (doctrine: private keys are secrets)
give push_send(subscription(), "hi", {"vapid": {"public": keys["public"], "private": "not-a-secret", "subject": "mailto:ops@example.com"}})
test "the pair: a base64url public key and a SEALED private key"
assert_eq(length(keys["public"]), 87) -- 65-byte uncompressed P-256 point
assert_eq(text(keys["private"]), "secret(vapid_private)") -- redacted wherever it goes
test "delivering needs net(<push service host>) — refused before any socket opens"
assert_error(send_without_net)
test "the VAPID private key is only accepted as a secret"
assert_error(send_with_a_plain_text_key)Qué escribe synsema init --pwa (engine v0.6.15+)§
| Archivo | Qué es |
|---|---|
app.syn | el server: static "/" from "./public", la página y mount api.api (engine v0.6.19+; antes las rutas iban inline) |
api.syn | (engine v0.6.19+) el API como grupo export routes api — /api/ping y las rutas de push (/api/push/config, /api/push/subscribe a 10/min, /api/push/test a 2/min), así las mismas rutas sirven también a la entrada de escritorio. Un módulo no lleva require: las capabilities las declara la entrada |
push_keys.syn | una sola vez: imprime VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT para .env |
index.html | la página — <link rel="manifest">, theme-color, apple-touch-icon, los metas de pantalla completa, <script src="/app.js"> |
public/manifest.webmanifest | id: "/", nombre, start_url: "/", scope: "/", display: "standalone", íconos 192 + 512 (any) y un 512 maskable aparte (Lighthouse rechaza un any maskable combinado). Sumá screenshots (y una description más larga) para la hoja de instalación rica y las tiendas — son tuyas, init no las inventa |
public/sw.js | el service worker: shell cacheada stale-while-revalidate (responde del caché, refresca por detrás — un cambio se ve en la siguiente apertura; subí CACHE para forzarlo ya), /api/ jamás cacheado, push → notificación con badge + tag/renotify, toque → enfoca la ventana abierta (y le avisa por postMessage) en vez de abrir otra |
public/app.js | registra el worker, el botón Install (beforeinstallprompt), el aviso para iPhone, un estado honesto (navigator.onLine y una prueba no-store de /api/ping — un server caído no es "online"), Notify me (espera al worker activado, reusa la suscripción existente, reintenta el primer subscribe con backoff, muestra nombre: mensaje si falla) y el demo Send a test push |
public/icon.svg, public/icon-maskable.svg, public/badge.svg | los íconos fuente: icon-192.png / icon-512.png / apple-touch-icon.png del primero, icon-maskable-512.png (fondo a lienzo completo, dibujo en el 80% seguro) del segundo, badge-96.png (el ícono monocromo de la barra de estado que Android necesita — sin él sale uno genérico) del tercero. Todos generados por init: editá un SVG, volvé a correr init --pwa, sus PNG siguen; los otros no se tocan |
Todo lo demás que escribe init (.env.example, .gitignore, .mcp.json) viene también; hello.syn no — el starter es app.syn. Volver a correrlo es seguro: un archivo que editaste se conserva y la versión de fábrica queda al lado como <archivo>.new. --pwa y --synfide son starters distintos — elegí uno (exit 2 con los dos).
Las piezas, para sumarlas a una app que ya existe§
1. Montá public/ en la raíz — el service worker tiene que servirse desde /sw.js para controlar todo el origen:
require serve(8080)
require file.read("index.html") -- render() lee la página del disco (engine v0.6.14+)
serve on 8080
static "/" from "./public" cache "1h"
route "GET /"
give render("index.html", {"title": "Mi app"})
.webmanifest se sirve como application/manifest+json (pinneado — el navegador rechaza el manifest con cualquier otro tipo). Las rutas declaradas ganan sobre el estático, así que / sigue siendo tuya.
2. El <head> de tu layout necesita las líneas del scaffold: <link rel="manifest" href="/manifest.webmanifest">, <meta name="theme-color">, <link rel="apple-touch-icon" href="/apple-touch-icon.png">, <meta name="mobile-web-app-capable" content="yes"> y su gemelo apple- (sin ellos iOS no abre a pantalla completa ni usa tu ícono), viewport-fit=cover.
3. HTTPS en producción: synsema serve app.syn --domain app.example.com --tls-auto vos@example.com (Deploy). localhost y 127.0.0.1 cuentan como seguros, así que desarrollar no necesita nada. iOS exige un certificado confiable para el service worker y la instalación.
4. Push es opcional — la app funciona sin claves; la página sólo dice que push no está configurado.
Dónde se instala, con honestidad§
| Android (Chrome, Edge, Samsung) | iPhone / iPad (Safari 16.4+) | Escritorio (Edge, Chrome) | |
|---|---|---|---|
| Instalar | el botón Install (beforeinstallprompt) o el menú del navegador | Compartir → Añadir a pantalla de inicio (sin API; la página muestra el aviso) | menú → Instalar: ventana propia, entrada en Menú Inicio / Dock |
| Ícono, pantalla completa | manifest | apple-touch-icon + los metas apple- | manifest |
| Shell offline | service worker | service worker | service worker |
| Push | sí | sólo desde la app instalada, permiso con un toque | sí (Windows entrega por *.notify.windows.com) |
| Origen | HTTPS o localhost | HTTPS con certificado confiable | HTTPS o localhost |
| Tienda | opcional: Trusted Web Activity con PWABuilder — fuera de Synsema | opcional: PWABuilder → proyecto Xcode, caso a caso — fuera de Synsema | — |
Firefox de escritorio no tiene instalación ni modo app: el sitio funciona como sitio.
Un offline que dice la verdad§
El sw.js del scaffold cachea la shell (/, /app.js, el manifest, el ícono) y deja el API en red: sin conectividad /api/ responde 503 {"error": "offline"} y la página lo dice. Nunca cachea una respuesta que no sea OK, así que una falla jamás se repite como dato. Si tu / muestra datos del usuario logueado, sacala de la lista de la shell y cacheá una página pública de "estás sin conexión"* — el caché es del navegador, no de un usuario.
Dos cosas que el scaffold decide por vos, y por qué: la shell es stale-while-revalidate (la copia cacheada responde al instante, la red la refresca para la próxima apertura — así un cambio en app.js o / aparece una apertura después; subí CACHE en sw.js cuando lo necesites ya, el activate borra el caché viejo). Y una notificación tocada enfoca la ventana que ya está abierta y le avisa (postMessage({type: "notificationclick", url})) en vez de abrir una segunda instancia sin estado; sólo si no hay ninguna abre una. Si el SO mató la app, esa ventana nueva arranca de cero — lo que tenga que sobrevivir (un borrador, un filtro, la vista actual) va a localStorage/IndexedDB, no a variables.
Push, de punta a punta§
1. Claves, una vez. synsema run push_keys.syn imprime las tres líneas para .env. El par sale de push_vapid_keys() (necesita require random: crea material secreto). La privada nace sellada — se imprime redactada, por eso el script declara require reveal("vapid_private") y la revela a propósito (auditado). Mismos formatos que web-push generate-vapid-keys.
2. Suscribirse. app.js lee la clave pública de GET /api/push/config, pide permiso, llama pushManager.subscribe({userVisibleOnly: true, applicationServerKey}) y manda PushSubscription.toJSON() — {"endpoint", "keys": {"p256dh", "auth"}} — con POST a /api/push/subscribe. Guardala en una tabla, atada al usuario logueado (requires auth); el scaffold guarda hasta 500 en state_* como demo.
3. Enviar. Una línea por push service que atiendas — deny-by-default, el push service es un host como cualquier otro:
require secret("VAPID_PRIVATE_KEY")
require env("VAPID_PUBLIC_KEY")
require net("fcm.googleapis.com") -- Chrome, Android, Brave, Opera …
require net("jmt17.google.com") -- … Chrome entrega cualquiera de los dos dominios de FCM
require net("*.notify.windows.com") -- Edge en Windows
require net("updates.push.services.mozilla.com") -- Firefox
require net("web.push.apple.com") -- Safari, iPhone, iPad, Mac
let vapid be {"public": env("VAPID_PUBLIC_KEY"), "private": secret("VAPID_PRIVATE_KEY"), "subject": "mailto:ops@example.com"}
let r be push_send(sub, {"title": "Pedido enviado", "body": "El #1042 va en camino", "url": "/orders/1042"},
{"vapid": vapid, "ttl": 3600, "urgency": "high", "topic": "order-1042"})
when r["gone"] -- 404 / 410: el navegador se desuscribió
sql_exec("DELETE FROM push_subs WHERE endpoint = ?", [sub["endpoint"]])
push_send(subscription, payload, opts) → {status, ok, gone, retry_after, body}:
payload: texto tal cual · un map/lista → JSON (lo quesw.jslee como{title, body, url}) ·bytes·nothingpara un "algo cambió" sin cuerpo. Como máximo 3993 bytes — el cuerpo cifrado tiene que quedar bajo los 4096 de los servicios. Un payloadsecretes error; un secret dentro de un map viaja redactado.opts.vapid(obligatorio):public(texto base64url),private(sólo unsecret— desecret("VAPID_PRIVATE_KEY"),as_secret(...)opush_vapid_keys(); un texto plano se rechaza, exactamente como la clave privada design: quien la tiene manda push a todos los usuarios),subject(mailto:ohttps://). Una pública que no corresponde a la privada falla acá, no como un 401 opaco del servicio.opts.ttlsegundos que el servicio guarda un mensaje no entregado (default 86400) ·opts.urgencyvery-low | low | normal | high·opts.topic1–32 caracteres[A-Za-z0-9_-](un mensaje más nuevo con el mismo topic reemplaza al pendiente) ·opts.timeoutsegundos (default 30).status201 = aceptado;gonecon 404/410 → borrá esa suscripción;retry_after(texto onothing) con 429/503. El endpoint tiene que serhttps://(http://sólo en loopback, para mocks). Servicio inalcanzable → error.- No confundas tus errores con suscripciones muertas. Un error de capability (
Capability not granted: net("jmt17.google.com")— un navegador cuyo push service no declaraste) o un servicio inalcanzable es tu configuración o tu red: conservá la suscripción, agregá el host que nombra el error, mandá de nuevo. Descartá una suscripción sólo congone, o cuando el error nombrasubscription(claves o endpoint rotos). ElPOST /api/push/testdel scaffold hace exactamente eso en surecover. Y mantené los hosts exactos:net("*.google.com")abriría egress mucho más allá del push. - Lo de adentro: RFC 8291/8188
aes128gcm— ECDH P-256 con clave efímera + sal de 16 bytes del CSPRNG del sistema por mensaje, HKDF, AES-128-GCM, un registro de 4096 bytes — y un JWT VAPID ES256 (RFC 8292) conexpde 12 horas. La aleatoriedad interna del protocolo no necesita la capabilityrandom, como el handshake TLS. No disponible en el perfil wasm/puro (necesita sockets);push_vapid_keyssí.
¿Ya usás un proveedor? OneSignal, Firebase Cloud Messaging, Pusher Beams y similares siguen funcionando: http_post a su API REST bajo require net(...) con bearer(secret("PROVIDER_KEY")). El push nativo es una opción, no una obligación.
Lo que esto no es§
Las APIs nativas que la web no tiene — Bluetooth, NFC, widgets en la pantalla de inicio, sincronización en segundo plano en iOS — viven en un cliente nativo (Swift, Kotlin, Flutter) que habla con el mismo servidor por el OpenAPI que deriva (Construí un API). Synsema no compila UI nativa, y jamás manda tus secretos a un dispositivo.
Un binario, la app entera (engine v0.6.16+)§
synsema build app.syn -o app --serve --bind 0.0.0.0 --port 8080 --domain app.example.com --tls-auto ops@example.com
./app # el runtime de serve con esos flags horneados; Ctrl-C = shutdown ordenado
--serve construye un binario server: los mounts estáticos del bloque serve (public/) y los templates se empaquetan solos y se sirven desde adentro del archivo, con ETag por contenido — nada que llevar al lado. El bind es obligatorio (un distribuible jamás adivina su interfaz): --bind, o la cláusula bind "…" del bloque (v0.6.18+); los otros flags son los que toma synsema serve, horneados. Mirá CLI § synsema build.
El mismo layout es una app de escritorio (engine v0.6.18+): bind "127.0.0.1", synsema build … --serve --no-console --icon public/icon.svg --bundle, el navegador como ventana de app, el service worker y el manifest haciéndola instalable desde Edge/Chrome en 127.0.0.1 — Tu app en el escritorio.
Probarla en un teléfono real§
El teléfono necesita un origen HTTPS (localhost sólo es seguro en la propia máquina). Tres caminos, del más barato al real:
- Un túnel a tu máquina:
cloudflared tunnel --url http://localhost:8080ongrok http 8080te dan una URL HTTPS pública para elsynsema servelocal — instalar y push funcionan en Android y iOS como en producción. La URL cambia por corrida; las suscripciones mueren con ella. - Android por USB:
adb reverse tcp:8080 tcp:8080y abríhttp://localhost:8080en el Chrome del teléfono — ahílocalhosttambién es contexto seguro, sin certificado. - Un VPS con
--domain … --tls-auto— lo real; iOS exige un certificado confiable, uno autofirmado no sirve.
Si lo automatizás (Playwright/Puppeteer contra el server local): cerrá el navegador en un finally y matá el synsema serve hijo al terminar — un test que explota deja un Chrome headless huérfano (en Windows buscá chrome.exe con ms-playwright en la línea de comando).
Tiendas, si las querés§
- Google Play: empaquetá la PWA como Trusted Web Activity (PWABuilder o Bubblewrap). Play verifica que sos dueño del origen por
/.well-known/assetlinks.json— ponelo bajopublic/.well-known/y el mount estático lo sirve como JSON. - Microsoft Store: acepta una PWA directa (PWABuilder genera el paquete).
- App Store: necesita un wrapper nativo (PWABuilder → Xcode); ojo que Web Push no funciona dentro de WKWebView — el push de iOS es para la instalación en pantalla de inicio, no para una app envuelta. Esa línea es de Apple, no de Synsema.