31. Installable, offline apps (PWAs) and static sites
An app with a pwa beside it is a progressive web app: it installs to a home screen or dock, opens without a
network, and takes a new version by itself. Any app that calls no server functions can be built as a static site, every
page rendered as it's built, for any static host. There's nothing to configure beyond the manifest.
// tcm: namespace reminders
import { appicon, icon } from "#codebase/reminders"; // reminders.icon (an .svg), reminders.appicon (a .png)
export const pwa = {
name: "Reminders",
theme_color: "#f2f2f7",
icons: [{ src: icon }, { src: appicon }, { src: appicon, purpose: "maskable" }],
shortcuts: [{ name: "Today", url: "/today" }],
};
tcm build reminders.App --static site --base /reminders # site/: upload it anywhere
tcm start reminders.App # or serve it, as any app
The reminders example is a complete one: its data in the
device's IndexedDB, installable, offline, built as a static site, and served so on tcm's own site. tcm's docs site is
a static site too: its app (tcmts.site.Docs) built with --static.
31.1 The manifest: pwa
tcm build and tcm start look for a definition named pwa in the app's namespace (reminders.pwa for
reminders.App). It's the web app manifest,
written as an object, as the spec has it, with these differences:
- Images are files of the codebase. An icon's or screenshot's
srcis a file imported like any definition: an.svg(its text) or a.png,.jpg,.webp,.gifor.ico(its bytes). The build keeps each among the app's assets, named by its content, and fills in itstype, and a.png'ssizesfrom the file. A URL is left as it is. - URLs are the app's paths.
start_url,scopeand a shortcut'surlare paths of the app:/is the app, whatever path it's served under (--base). - Defaults:
start_urlandscopeare the app's root, anddisplayisstandalone.
The pwa is part of the build: changing it alone (a new icon, a new color) is a new build, deployed like any. The page
gets what the manifest implies: its <title> is the app's name, and its head has the viewport, the manifest, the
theme color, the tab's icon (an .svg if there is one) and the home screen icon iOS uses (the largest .png).
A build says what an installed app would miss: no icons, or no .png of 180×180 or more, which iOS needs.
31.2 Offline: the service worker
A PWA's build has a service worker (sw.js, at the app's root, served by tcm start and written by --static). It
keeps the build on the device when it installs: its pages (a static site's every page, and what its server components
rendered) and every asset, named by their content.
- What it opens first is inferred, as where code runs is. An app that calls no server (no server functions or
server components), and any static site, opens a page it keeps from the device, online or not, so it starts at
once. One that calls a server asks the network for its page first.
tcm buildsays which. - A path it keeps no page for (a list only the device has,
/lists/<id>) is the network's, and with no network, the page that renders any path in the browser. - Assets come from the device, where they're kept by their content, so they never go stale.
- A new version is found by itself. The browser looks for a new
sw.jseach time the app is opened, and the page asks again whenever an installed app comes back on screen (once a minute at most). A new one keeps its build, then takes over; the page that's open keeps running what it loaded, and its next load is the new version. The build before stays on the device too, for a tab still running it. - The page hears of it:
useAppUpdate()from the react library gives{ ready, reload }, to offer the new version; other apps can listen fortcm:updateonwindow. - Apps on one site keep apart. Each app's caches are named by where it's served, so two apps under paths of one site don't clear each other's.
tcm dev doesn't register the service worker: while you work, every load is the code as it is.
Keeping data on the device is the app's own: IndexedDB, OPFS or localStorage, all of which make code run in the
browser. Ask for persistent storage (navigator.storage.persist()) so the browser keeps it when space runs low; an
installed app usually has it already.
31.3 Static sites
tcm build <app> --static <dir> writes the build as plain files, for any static host (GitHub Pages, Cloudflare
Pages, Netlify, tcm serve --site). A component app's pages are rendered as it's built, as tcm start renders a
page's first paint: each is there before any code runs, then the browser takes it over.
site/ index.html / today.html /today: each page the app links to, rendered 404.html the page that renders any other path in the browser: hosts serve it for a path with no file sw.js a PWA's service worker .nojekyll so GitHub Pages serves _app/ _app/assets/ the app's code, styles, icons and manifest, each named by its content _app/trees/ what server components rendered, each named by its content _app/site.<sha>.json which tree is in which file, and which trees each page shows
- The pages are the ones the app links to. From
/, the build follows each page's<a href>links to the app, asuseAppLinksdoes (not ones withrel="external",downloador atarget, nor to files other than.html), and renders each. There's no list of routes to write: give each page of the app a path (/today, not?list=today), and link to it. - Server components run as it's built. What they render goes into the page, and is kept as a file
(
_app/trees/), by the props it was rendered for, so the app goes from one page to another in the browser and reads the next page's tree from there, with no server. It reads a page's trees before it goes there: when a link to it is pointed at for a moment, focused or pressed, and when it's in view, once the page is idle (two dozen at most, and not when the connection is slow or saving data), so following it is at once. Each tree's file (React's Flight, astcm startwould send it) is named by its content (t.<sha256>.txt), as is the map of them every page names (site.<sha256>.json: which file each tree is in, and which trees each page shows), so a browser keeps them for good: only the pages are asked for again. A server component given props no page rendered it with fails, saying so. - Paths it has no page for (one only the device knows,
/lists/<id>) get404.html, which renders in the browser. A host serves/todayfromtoday.html(GitHub Pages, Cloudflare Pages, Netlify andtcm serve --siteall do); one that doesn't needs it said. --base /pathbuilds it for a path of a site (a GitHub Pages project is at/<repository>): its assets, service worker and the react library's paths are under it, as withtcm start --base. Without it, it's for the site's root.- Only an app that calls no server functions can be one. One that does is refused, with them named: a static site has no server to answer. (Server components are fine: they ran as it was built.)
- Deploying is building into the same directory and uploading it. Files already there stay, so a tab still running the last version can load what it needs, and a PWA's devices take the new one by themselves.
- A page is rendered for its path, not its query.
/today?sort=duegetstoday.html, rendered without the query; a component that renders differently for it doesn't match the page it hydrates. Keep what a page shows in its path. - An app whose entry is a function (
main, not a component) isn't rendered as it's built: its page renders in the browser.