Synsemadocsv0.6.xENES

Frontend

Your app on the phone (PWA)

A Synsema site installs on a phone or a desktop as an app: an icon on the home screen, full screen without the browser chrome, a shell that opens without network, and push notifications — with nothing but the web the server already renders. No native client, no app store, no framework. The client is the browser the user already has; the backend, the agents and the secrets stay on the server, where they belong.

synsema init --pwa myapp && cd myapp
synsema serve app.syn            # http://localhost:8080 — Chrome and Edge install it from localhost

Press Run for the doctested part — the VAPID pair is born sealed, the private key is only accepted as a secret, and delivering is refused before any socket opens unless you declared the 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)

What synsema init --pwa writes (engine v0.6.15+)§

FileWhat it is
app.synthe server: static "/" from "./public", the page, and mount api.api (engine v0.6.19+; before that the routes were inline)
api.syn(engine v0.6.19+) the API as an export routes api group — /api/ping and the push routes (/api/push/config, /api/push/subscribe at 10/min, /api/push/test at 2/min), so the same routes serve the desktop entry too. A module carries no require: the entry declares the capabilities
push_keys.synone-off: prints VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT for .env
index.htmlthe page — <link rel="manifest">, theme-color, apple-touch-icon, full-screen metas, <script src="/app.js">
public/manifest.webmanifestid: "/", name, start_url: "/", scope: "/", display: "standalone", icons 192 + 512 (any) and a separate 512 maskable (Lighthouse rejects a combined any maskable). Add screenshots (and a longer description) for the richer install sheet and the stores — they are yours to take, so init doesn't invent them
public/sw.jsthe service worker: shell cached stale-while-revalidate (serves from cache, refreshes behind — a change shows on the next open; bump CACHE to force it now), /api/ never cached, push → notification with badge + tag/renotify, tap → focuses the open window (and tells it via postMessage) instead of opening a fresh one
public/app.jsregisters the worker, the Install button (beforeinstallprompt), the iPhone hint, an honest status (navigator.onLine and a no-store probe of /api/ping — a down server is not "online"), Notify me (waits for the activated worker, reuses an existing subscription, retries the first subscribe with backoff, shows name: message on failure) and the demo Send a test push
public/icon.svg, public/icon-maskable.svg, public/badge.svgthe source icons: icon-192.png / icon-512.png / apple-touch-icon.png from the first, icon-maskable-512.png (full-bleed background, art in the safe 80%) from the second, badge-96.png (the monochrome status-bar icon Android needs — without it you get a generic one) from the third. All generated by init: edit an SVG, re-run init --pwa, its PNGs follow; the others are left alone

Everything else init writes (.env.example, .gitignore, .mcp.json) comes too; hello.syn does not — the starter is app.syn. Re-running is safe: a file you edited is kept and the factory version lands beside it as <file>.new. --pwa and --synfide are different starters — pick one (exit 2 with both).

The pieces, so you can add them to an existing app§

1. Mount public/ at the root — the service worker must be served from /sw.js to control the whole origin:

require serve(8080)
require file.read("index.html")            -- render() reads the page from disk (engine v0.6.14+)

serve on 8080
    static "/" from "./public" cache "1h"
    route "GET /"
        give render("index.html", {"title": "My app"})

.webmanifest is served as application/manifest+json (pinned — the browser rejects the manifest under any other type). Declared routes win over static, so / stays yours.

2. The <head> of your layout needs the scaffold's lines: <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"> and its apple- twin (iOS won't go full screen or use your icon without them), viewport-fit=cover.

3. HTTPS in production: synsema serve app.syn --domain app.example.com --tls-auto you@example.com (Deploy). localhost and 127.0.0.1 count as secure, so development needs nothing. iOS requires a trusted certificate for the service worker and the install.

4. Push is optional — the app works without keys; the page just says push is not configured.

Where it installs, honestly§

Android (Chrome, Edge, Samsung)iPhone / iPad (Safari 16.4+)Desktop (Edge, Chrome)
Installthe Install button (beforeinstallprompt) or the browser menuShare → Add to Home Screen (no API; the page shows the hint)menu → Install: own window, Start-menu / Dock entry
Icon, full screenmanifestapple-touch-icon + the apple- metasmanifest
Offline shellservice workerservice workerservice worker
Pushyesonly from the installed app, permission on a tapyes (Windows delivers through *.notify.windows.com)
OriginHTTPS or localhostHTTPS with a trusted certificateHTTPS or localhost
Storeoptional: Trusted Web Activity via PWABuilder — outside Synsemaoptional: PWABuilder → Xcode project, case by case — outside Synsema

Firefox on desktop has no install/app mode: the site works as a site.

Offline that tells the truth§

The scaffold's sw.js caches the shell (/, /app.js, the manifest, the icon) and leaves the API on the network: without connectivity /api/ answers 503 {"error": "offline"} and the page says so. It never caches a non-OK response, so a failure is never replayed as data. If your / shows the logged-in user's data, take it out of the shell list and cache a public "you're offline"* page instead — the cache belongs to the browser, not to a user.

Two things the scaffold decides for you, and why: the shell is stale-while-revalidate (the cached copy answers at once, the network refreshes it for the next open — so a change to app.js or / appears one open late; bump CACHE in sw.js when you need it now, the activate step drops the old cache). And a tapped notification focuses the window that is already open and tells it (postMessage({type: "notificationclick", url})) instead of spawning a second, stateless instance; only when there is none does it open one. If the OS killed the app, that new window starts from zero — anything that must survive (a draft, a filter, the current view) belongs in localStorage/IndexedDB, not in variables.

Push, end to end§

1. Keys, once. synsema run push_keys.syn prints the three lines for .env. The pair comes from push_vapid_keys() (needs require random: it creates secret material). The private key is born sealed — it prints redacted, so the script declares require reveal("vapid_private") and reveals it on purpose (audited). Same formats as web-push generate-vapid-keys.

2. Subscribe. app.js reads the public key from GET /api/push/config, asks permission, calls pushManager.subscribe({userVisibleOnly: true, applicationServerKey}) and POSTs PushSubscription.toJSON(){"endpoint", "keys": {"p256dh", "auth"}} — to POST /api/push/subscribe. Keep it in a table, tied to the logged-in user (requires auth); the scaffold keeps up to 500 in state_* as a demo.

3. Send. One line per push service you serve — deny-by-default, the push service is a host like any other:

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

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}:

Already on a provider? OneSignal, Firebase Cloud Messaging, Pusher Beams and the like keep working: http_post to their REST API under require net(...) with bearer(secret("PROVIDER_KEY")). Native push is an option, not a mandate.

What this is not§

Native APIs the web doesn't have — Bluetooth, NFC, home-screen widgets, background sync on iOS — live in a native client (Swift, Kotlin, Flutter) that talks to the same server through the OpenAPI it derives (Build an API). Synsema does not compile native UI, and it never ships your secrets to a device.

One binary, the whole app (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                                   # the serve runtime with those flags baked in; Ctrl-C = ordered shutdown

--serve builds a server binary: the serve block's static mounts (public/) and the templates are bundled automatically and served from inside the file, with a content ETag — nothing to ship beside it. A bind is required (a distributable never guesses its interface): --bind, or the block's bind "…" clause (v0.6.18+); the other flags are the ones synsema serve takes, baked in. See CLI § synsema build.

The same layout is a desktop app (engine v0.6.18+): bind "127.0.0.1", synsema build … --serve --no-console --icon public/icon.svg --bundle, the browser as an app window, the service worker and the manifest making it installable from Edge/Chrome on 127.0.0.1Your app on the desktop.

Testing it on a real phone§

The phone needs an HTTPS origin (localhost is only secure on the machine itself). Three ways, cheapest first:

Automating it (Playwright/Puppeteer against the local server): close the browser in a finally and stop the synsema serve child when the run ends — a test that panics leaves a headless Chrome behind (on Windows look for chrome.exe with ms-playwright in its command line).

Stores, if you want them§

Next§