Installable, offline apps (PWAs) and static sites — tcm-ts

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 src is a file imported like any definition: an .svg (its text) or a .png, .jpg, .webp, .gif or .ico (its bytes). The build keeps each among the app's assets, named by its content, and fills in its type, and a .png's sizes from the file. A URL is left as it is.
  • URLs are the app's paths. start_url, scope and a shortcut's url are paths of the app: / is the app, whatever path it's served under (--base).
  • Defaults: start_url and scope are the app's root, and display is standalone.

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 build says 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.js each 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 for tcm:update on window.
  • 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, as useAppLinks does (not ones with rel="external", download or a target, 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, as tcm start would 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>) get 404.html, which renders in the browser. A host serves /today from today.html (GitHub Pages, Cloudflare Pages, Netlify and tcm serve --site all do); one that doesn't needs it said.
  • --base /path builds 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 with tcm 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=due gets today.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.

built from c482a14e