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
require net("127.0.0.1")           -- a loopback mock endpoint: the option checks below run before any send

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)

-- Every option is checked BEFORE anything is encrypted or sent. The endpoint is a closed
-- loopback port, so an accepted call ends in "could not reach the push service".
let mock be {"endpoint": "http://127.0.0.1:9/push", "keys": subscription()["keys"]}
let vapid be {"public": keys["public"], "private": keys["private"], "subject": "mailto:ops@example.com"}

task send_error(payload, opts)
    try
        push_send(mock, payload, opts)
        give "sent"
    recover err
        give err

test "opts: vapid is required, and its subject is mailto: or https://"
    assert(contains(send_error("hi", {}), "opts.vapid is required"))
    assert(contains(send_error("hi", {"vapid": merge(vapid, {"subject": "ops@example.com"})}), "must be a mailto: address or an https:// URL"))
    let other be push_vapid_keys()
    assert(contains(send_error("hi", {"vapid": merge(vapid, {"public": other["public"]})}), "does not match opts.vapid.private"))

test "opts: ttl, urgency, topic, timeout — and nothing else"
    assert(contains(send_error("hi", {"vapid": vapid, "ttl": -1}), "opts.ttl must be an integer"))
    assert(contains(send_error("hi", {"vapid": vapid, "urgency": "urgent"}), "very-low, low, normal, high"))
    assert(contains(send_error("hi", {"vapid": vapid, "topic": "bad topic!"}), "1-32 URL-safe characters"))
    assert(contains(send_error("hi", {"vapid": vapid, "timeout": 0}), "opts.timeout must be an integer"))
    assert(contains(send_error("hi", {"vapid": vapid, "bogus": 1}), "valid options are: vapid, ttl, urgency, topic, timeout"))
    let ok be {"vapid": vapid, "ttl": 3600, "urgency": "high", "topic": "order-1042", "timeout": 2}
    assert(contains(send_error("hi", ok), "could not reach the push service"))       -- all accepted

test "payload: text, a map, bytes or nothing — at most 3993 bytes"
    assert(contains(send_error({"title": "t", "body": "b", "url": "/"}, {"vapid": vapid}), "could not reach"))
    assert(contains(send_error(bytes("raw", "utf8"), {"vapid": vapid}), "could not reach"))
    assert(contains(send_error(nothing, {"vapid": vapid}), "could not reach"))
    let big be join(apply((i) => "x", range(3994)), "")
    assert(contains(send_error(big, {"vapid": vapid}), "at most 3993"))
    assert(contains(send_error(as_secret("s"), {"vapid": vapid}), "the payload cannot be a secret"))

Qué escribe synsema init --pwa (engine v0.6.15+)§

ArchivoQué es
app.synel server: static "/" from "./public" con su require file.read("./public/**") (engine v0.6.45+), 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() reads the page from disk (engine v0.6.14+)
require file.read("./public/**")           -- static serves that folder: reading it (engine v0.6.45+)

serve on 8080
    static "/" from "./public" cache "1h"
    route "GET /"
        give render("index.html", {"title": "My 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. Sin la línea de ./public/ el server no arranca (… add require file.read("public/")); en un binario de synsema build el public/ empaquetado no necesita nada y se sirve solo desde el bundle.

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ísó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 hands out either FCM domain
require net("*.notify.windows.com")                -- Edge on Windows
require net("updates.push.services.mozilla.com")   -- Firefox
require net("web.push.apple.com")                  -- Safari, iPhone, iPad, Mac
require db                                         -- the subscriptions table (sql_exec below)

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": "Order shipped", "body": "#1042 is on its way", "url": "/orders/1042"},
    {"vapid": vapid, "ttl": 3600, "urgency": "high", "topic": "order-1042"})
when r["gone"]                                     -- 404 / 410: the browser unsubscribed
    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.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:

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§