Synsemadocsv0.6.xENES

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:

pwa.syn
-- 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+)§

ArchivoQué es
app.synel 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.synuna sola vez: imprime VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT para .env
index.htmlla página — <link rel="manifest">, theme-color, apple-touch-icon, los metas de pantalla completa, <script src="/app.js">
public/manifest.webmanifestid: "/", 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.jsel 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.jsregistra 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.svglos í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)
Instalarel botón Install (beforeinstallprompt) o el menú del navegadorCompartir → 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 completamanifestapple-touch-icon + los metas apple-manifest
Shell offlineservice workerservice workerservice worker
Pushsólo desde la app instalada, permiso con un toquesí (Windows entrega por *.notify.windows.com)
OrigenHTTPS o localhostHTTPS con certificado confiableHTTPS o localhost
Tiendaopcional: Trusted Web Activity con PWABuilder — fuera de Synsemaopcional: 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}:

¿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.1Tu 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:

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§

Siguiente§