24: PWA & native apps
A Phlo app is already a website. The same build ships as an installable Progressive Web App with no extra tooling, and the same web output can be wrapped in a thin native shell for the app stores. There is no separate "mobile build": one source, two additional distribution shapes.
24.1: The manifest
A PWA needs a manifest describing the app: name, icons, how it launches. Phlo ships a manifest
resource that owns the route and the serving; your app only declares the body. List manifest in
your resources (data/app.json), then inject the body from app.phlo:
prop %manifest.body => arr(
id: '/',
name: %app->title,
short_name: 'App',
description: %app->description,
start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'any',
theme_color: %app->themeColor,
background_color: '#0e131b',
icons: [
obj(src: '/icons/app-192.png', sizes: '192x192', type: 'image/png', purpose: 'any'),
obj(src: '/icons/app-512.png', sizes: '512x512', type: 'image/png', purpose: 'any'),
obj(src: '/icons/app-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable'),
obj(src: '/icons/app-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable'),
],
)
The resource serves GET /manifest.json as application/manifest+json with a short cache, and
%manifest->head renders the <link rel="manifest"> tag for your head view. Because the body is a
lazy prop, it reads live app state (a per-language name, the active theme color) at request time.
An app with more than one manifest (a pro variant, a per-section PWA) keeps its own routes and
serves each body through the same helper; the resource's default route steps aside whenever no
%manifest.body is declared:
route GET pro manifest.json => manifest::output(manifests::body(pro: true))
Two body fields decide installability: display: 'standalone' and a maskable icon pair are what
make Chrome and Safari offer an install prompt at all. Without both, the manifest is valid but the
app never becomes installable. And set id explicitly ('/'): it anchors the installed identity to
the origin, so a later change of start_url does not turn the installed app into a different one.
24.2: The service worker
Installable also means offline-capable. Serve the worker itself through a route so its cache name can carry the app version, invalidating old caches on every deploy:
route GET sw.js {
%res->header('Cache-Control', 'no-cache')
output(strtr((string)file_get_contents(data.'sw.js.tpl'), ['{{VERSION}}' => %app->version]), type: 'text/javascript')
}
output() is the general-purpose response helper: it sets the content type and Content-Length,
JSON-encodes an array automatically, and handles Content-Disposition for downloads. Reach for
%res-> directly only for what it does not cover, such as the extra Cache-Control header here.
The template caches the app shell on install, drops stale caches on activate, and on fetch serves navigations network-first with an offline fallback to the cached shell:
const CACHE = 'app-{{VERSION}}'
const SHELL = ['/', '/app.js?{{VERSION}}', '/app.css?{{VERSION}}', '/manifest.json']
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)).then(() => self.skipWaiting()))
})
self.addEventListener('activate', e => {
e.waitUntil(caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
.then(() => self.clients.claim()))
})
self.addEventListener('fetch', e => {
if (e.request.method !== 'GET') return
if (e.request.mode === 'navigate') return e.respondWith(fetch(e.request).catch(() => caches.match('/')))
})
Lesson. A blanket
fetchhandler breaks the SPA engine. Phlo's.asyncnavigation reads and writes the DOM directly from anapply()response; it never expects a full HTML page back.Cache.matchignores headers, so if the worker serves the cached/shell for every request to/, an async request gets back a full HTML document where it expected a DOM patch, and the page corrupts itself. Exclude async traffic explicitly, by checking the header the engine sends on every async call:if (e.request.headers.get('X-Requested-With') === 'phlo') returnFalling through (no
respondWith) lets the browser handle the request normally, uncached. The same applies to any endpoint that must always hit the network live: a WebSocket upgrade, a byte-range media request, anything the cache would otherwise intercept and silently corrupt.
Register it once, early:
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js')24.3: What Capacitor adds
A PWA covers install-to-home-screen, offline shell, and push if you add it, all from the same deploy. Capacitor covers what a browser tab cannot: an app-store listing, and native APIs a background browser tab cannot reach reliably, such as background geolocation, a wake lock that survives the OS aggressively suspending a backgrounded tab, or native text-to-speech.
Capacitor does not bundle a copy of your app into the binary. It points a thin native shell at your running site:
{
"appId": "com.example.app",
"appName": "App",
"webDir": "www",
"server": { "url": "https://app.example.com", "cleartext": false }
}
The binary is a WebView loading your live, deployed Phlo app: no separate mobile build pipeline to keep in sync, no bundled assets to go stale. Ship a change the normal way (see the Deployment chapter) and every installed copy picks it up on next launch, exactly like a website.
Add platforms and native plugins as needed:
npm install @capacitor/core @capacitor/android @capacitor/geolocation
npx cap add android
npx cap sync
npx cap open android
Call a native plugin from your Phlo frontend script exactly like any other browser API: it is one more capability the page can reach, conditionally, when running inside the native shell.
import { Geolocation } from '@capacitor/geolocation'
const pos = await Geolocation.getCurrentPosition()
npx cap open android opens the generated project in Android Studio for a store-signed build. The same
cap add ios path applies for iOS, given a Mac and Xcode; the config and the plugin story are identical,
only the native project changes.
24.4: One app, three shapes
A Phlo app is a website first. It becomes installable with a manifest and a service worker, no build step changes. It becomes a store listing with Capacitor, no fork of the app, no second codebase to keep in sync, just a thin shell pointed at the same deploy and a small set of native plugins bridged in where the browser genuinely cannot follow. The browser tab, the installed PWA and the native binary are the same app at three points on one distribution spectrum, not three things to maintain.