1# Building and deploying a lofi app23lofi produces a static PWA. The production build contains prerendered HTML, JavaScript, the Jazz4WASM/runtime assets, the web manifest, and a revisioned service worker.56```mermaid7flowchart LR8 Source["Application source"] --> Doctor["doctor + test"]9 Doctor --> Build["deno task build"]10 Build --> Fingerprint["source fingerprint"]11 Build --> Precache["service-worker precache"]12 Build --> Scan{"secret scan passes?"}13 Scan -- "no" --> Stop["Stop and remove leak"]14 Scan -- "yes" --> Dist["dist/"]15 Dist --> Preview["Local preview"]16 Dist --> Deno["Deno Deploy"]17 Dist --> Static["Other static host"]18```1920## Build and preview2122```sh23deno task build24deno task preview25```2627`build` writes `dist/`, records a source fingerprint in `dist/lofi-build.json`, stamps the schema28compatibility manifest in `dist/lofi-schema.json`, generates the precache list, and scans for29server-secret values. Before Astro starts, the shared doctor preflight validates the author-owned30manifest and referenced assets. After Astro finishes, build verifies the emitted HTML links,31manifest, icons, nested routes, worker revision/scope, build identity, schema manifest, and exact32precache set together. `preview` refuses to start when the build identity is missing or invalid.3334To use another preview port:3536```sh37deno task preview --port 417338```3940## Configure the public application surface4142Before deploying, review:4344- `src/app.ts` — name, database namespace, stable credential origins, and repository URL;45- `public/manifest.webmanifest` — stable install identity, locale, icons, colors, launch behavior,46 and shortcuts;47- `public/favicon.svg` and any added icon files;48- page titles, descriptions, and starter copy;49- `.env` — either no public Jazz pair for local-only mode or a complete pair for optional sync.5051The default deployment base is `/`. When a static host mounts the application below the origin root,52set the path once in `.env` before running `dev` or `build`:5354```dotenv55LOFI_BASE_PATH=/field-notes/56```5758The value must be an absolute path, not an origin. Lofi feeds it into Astro's `base` setting and59uses the resulting base for public asset links, the manifest, the service worker, its scope, build60identity, and local preview. Upload the contents of `dist/` to that same mount point. A root build61and a subpath build are different deployment artifacts; rebuild after changing `LOFI_BASE_PATH`.6263Run `deno task doctor` and `deno task test` before the production build.6465### Customize the web manifest before launch6667Treat `public/manifest.webmanifest` as product source, not generated build metadata. Before users68install the app:6970- replace `name`, `short_name`, `description`, `id`, `lang`, and `dir` with product values;71- choose an `id` URL token that will remain stable for the lifetime of the installed app; unlike72 `start_url`, it identifies the app and does not need to be a navigable page;73- keep `scope`, `start_url`, every shortcut URL, and every shortcut icon aligned with the deployed74 base path;75- replace the regular, maskable, Apple touch, and transparent monochrome icon assets while keeping76 their purposes intact;77- keep `orientation: "any"` unless the product genuinely needs a screen-orientation lock; and78- replace or remove the starter `Open tasks` shortcut when replacing the task example.7980After launch, changing the name or start page is routine; changing `id` can make a browser treat the81manifest as a different installed application. Choose it once. If the product ships in multiple82languages, keep the default strings plus `lang` and `dir`, then add language maps such as83`name_localized`, `short_name_localized`, and `description_localized`. Localized values whose text84direction differs from the manifest default should declare their own `dir`.8586Storefront metadata is optional and product-specific. Add lowercase `categories` only when they87truthfully describe the finished app. Add `iarc_rating_id` only after obtaining a real IARC88certification code; never copy a placeholder rating. Experimental capabilities such as file,89protocol, share-target, and launch handlers are deliberately absent from the starter and should be90added only with matching product behavior and tests. Use the91[installed-app recipe catalog](recipes/README.md) for the supported opt-in patterns.9293### Replace or remove install presentation9495The starter manifest includes one labeled `540x720` narrow screenshot, one labeled `1280x720` wide96screenshot, and one **Open tasks** shortcut. They are examples of the real generated app, not97generic promotional art. Before launch:9899- replace both screenshots with current product captures and update each `sizes`, `label`, and100 `form_factor`, or remove the entire `screenshots` member and both files;101- replace the shortcut name, description, route, and icon with one useful product entry point, or102 remove the entire `shortcuts` member;103- keep shortcut routes inside manifest scope and backed by a prerendered route so they cold-start104 offline; and105- keep shortcut icons inside scope with truthful MIME types and dimensions.106107Build validation checks every referenced asset, requires labeled narrow and wide variants when the108`screenshots` member is present, and rejects shortcut routes that were not emitted. Screenshots are109deliberately excluded from the required shell precache; deleting promotional captures cannot break110offline startup.111112The starter does not claim a Web Share Target. If a product opts in, follow the tested113[Web Share recipe](recipes/web-share.md): first add a same-scope, prerendered action route, then add114a manifest member such as:115116```json117{118 "share_target": {119 "action": "./share/",120 "method": "GET",121 "enctype": "application/x-www-form-urlencoded",122 "params": { "title": "title", "text": "text", "url": "url" }123 }124}125```126127Use `parseTextShareTarget()` in the receiving island to ignore unknown parameters, reject duplicate128or oversized values, and accept a shared URL only after parsing it and allow-listing `https:` and129`http:`. Present the result as a draft: receiving a share is not user confirmation to persist it.130Build validation rejects malformed declarations, POST/file shares without a matching worker recipe,131and action routes that were not emitted for offline startup.132133## Deno Deploy134135Create the static application once:136137```sh138deno task deploy:create --org <org> --app <app>139```140141For later releases:142143```sh144deno task deploy145```146147Both tasks build first and deploy `dist/` as the static root.148149## Other static hosts150151Upload the contents of `dist/` to any host that can:152153- serve `index.html` at the application root;154- preserve the manifest and WASM content types;155- serve the application over HTTPS;156- keep the service worker at the intended scope;157- fall back to the appropriate prerendered HTML for application routes.158159Every prerendered route is included in the shell precache. While offline, a direct navigation such160as `/field-notes/settings/` resolves its cached `settings/index.html`; if that route was not161emitted, the worker falls back to the cached application root.162163The app is the only thing that needs hosting: sync is optional infrastructure the user can point at164a managed Jazz app or at [a self-hosted sync node](/node) they run themselves — the deployment does165not change either way.166167A worked example runs at [demo.lofi.host](https://demo.lofi.host): the starter app, generated from168the published package by the release workflow and uploaded as `dist/` to Cloudflare Pages, with169`dist/_headers.example` promoted to `_headers` so the policy below ships as a real response header.170171### Content-Security-Policy172173Every built page carries a strict Content-Security-Policy in a `<meta>` tag: scripts and styles are174admitted only from the app's own origin plus the exact hashes of the inline island bootstraps, with175`'wasm-unsafe-eval'` for the sync engine's WebAssembly and `object-src 'none'`, `base-uri 'self'`,176`worker-src 'self'` alongside. The meta tag enforces on every host, including hosts that cannot set177response headers — on those, it is the effective mechanism. Where your host supports response178headers, mirror the policy as a real `Content-Security-Policy` header using the generated179`dist/_headers.example`: a header additionally covers `frame-ancestors` (ignored in meta by spec)180and governs the service worker's own execution. `deno task preview` sends the header so the header181path is exercised before deployment.182183Tuning happens through the environment at build time: `LOFI_CSP_SCRIPT_SRC`, `LOFI_CSP_STYLE_SRC`,184and `LOFI_CSP_DIRECTIVES` extend the policy (space-separated sources; semicolon-separated185directives), `LOFI_CSP_CONNECT_SRC` pins the sync origin for apps with a fixed managed sink — note186that pinning it breaks runtime enrollment to any other node, which is why it is not set by default187(the sync location is user data, not build configuration) — and `LOFI_CSP=off` disables the policy188entirely. The build reports the shipped policy and warns on weakenings (`'unsafe-inline'`, remote189script origins, missing pages, or a disabled policy); what ships is the author's call.190191### Offline cache policy192193The build's precache manifest contains required shell resources only. Mutable shell assets (HTML,194manifests) are fetched bypassing the HTTP cache (matching the manifest's own `no-store` fetch), so a195new revision can never be populated from stale HTTP-cached responses. Content-hashed build assets196under `_astro/` cannot go stale under their names, so precaching admits the HTTP cache for them: a197first visit reuses the bytes the page's own module and engine fetches already downloaded instead of198downloading the shell a second time. If any listed response cannot be fetched, service-worker199installation fails and reports a precache error rather than exposing a worker that cannot cold-start200the application. Product-specific optional resources do not belong in that manifest.201202Cache names carry the registration scope, and lookups consult only the worker's own caches: apps203served from different base paths on one origin never read, shadow, or delete each other's caches,204and activation prunes only the current scope's previous revisions. Requests carrying a `Range`205header are left to the network — a cached complete response cannot answer a partial request.206207Runtime caching is a separate, best-effort policy. It accepts only successful, public, same-origin208responses inside the worker scope for fonts, images, scripts, styles, and workers. Navigation,209cross-origin requests, partial responses, `private` or `no-store` responses, and `Vary: *` responses210are not added. The cache retains at most 64 entries, moves refreshed URLs to the end, evicts the211oldest inserted overflow, and removes the current scope's previous build revisions during212activation.213214Navigation preload remains disabled: generated routes and their assets are precached, so starting a215parallel network request before the normal cache-first lookup would spend bandwidth on the expected216offline-ready path. Jazz sync, OPFS storage, background sync, and push remain outside the worker.217218### First-visit download219220A repeat visit opens from the precached shell without touching the network. A cold first visit is221dominated by the Jazz engine binary, so the build stamps every prerendered page with a fetch preload222for it: the download starts while the module graph is still arriving, and the tag carries the223binary's uncompressed size. Before the runtime opens it streams that same URL with byte progress,224and the engine's own fetch then reads the primed HTTP cache. Applications can name the wait with the225`useBootProgress` hook in `@nzip/lofi/preact` (phases `pending`, `downloading`, `opening`, `ready`,226`failed`, with `loadedBytes` and `totalBytes` while downloading); the starter task list shows the227pattern. Serving `_astro/` assets with compression and long-lived caching headers keeps both the228first visit and the warm-up cheap.229230### Install and update lifecycle231232The optional `PwaActions` UI uses a browser prompt only while `beforeinstallprompt` is actually233available. iOS receives its Share-menu steps. Other secure browsers with service-worker support get234generic browser-menu guidance that says to use **Install app** or **Add to Home Screen** only if the235browser offers it; an insecure or unsupported context is reported separately.236237When the app returns to the foreground, restores from the back-forward cache, or reconnects, Lofi238asks the active service-worker registration to check for an update. Checks share one in-flight239request, time out, and are rate-limited. Update state moves through `checking`, `installing`,240`ready`, and `applying`; a waiting worker activates only after the user chooses **Update app**. Once241the new worker takes control, every claimed tab reloads — the new worker prunes the old revision's242caches, so a document left on the old HTML and module graph would go stale — not only the tab that243chose the update. The first claim of a previously uncontrolled page does not reload, and a document244reloads at most once, so ordinary worker changes cannot create a reload loop.245246Foreground recovery reconnects managed sync only while the current account has elected sync and the247development inspector has not paused the transport; a configured server alone is not consent to248resume replication.249250A runtime-cache write error is best-effort and leaves the active worker ready. Registration,251required precache, and activation failures remain worker failures; update-check failures leave the252current worker running and retry on a later foreground signal.253254### Installed apps get their own storage container255256On iOS, iPadOS, and macOS, **Add to Home Screen** and **Add to Dock** create the installed web app257in a separate storage container. WebKit copies cookies at install time; OPFS, IndexedDB, and258localStorage stay behind in the browser. Local data created in a Safari tab before installing is259therefore not visible in the installed app — the data is safe in Safari's container, but the260installed app starts empty. Desktop installs isolate differently: a Chromium installed app shares261the storage of the browser profile that installed it (installing does not fork data), but each262browser keeps its own copy, so a site used in Chrome and installed from Edge holds two independent263local instances.264265The framework covers the WebKit fork on both sides of the boundary:266267- **Before installing.** While a browser context holds local-only data (writes that do not replicate268 because sync has not been elected), the iOS install guidance in `PwaActions` leads with a warning:269 installing opens a fresh copy, so back up or turn on sync first and restore in the installed app.270- **After installing.** While data is at risk, the runtime maintains a per-app flag cookie, the one271 signal WebKit copies at install. A standalone launch whose local storage has never been touched272 but which inherited that flag detects the fork and renders a fixed notice273 (`.lofi-storage-fork-banner`) naming where the data lives and the recovery path. Dismissing the274 notice clears the flag in the installed container and starts fresh.275276Apps can restyle the notice via its class, or replace both surfaces entirely: suppress the default277notice with `pwa: { forkNotice: "none" }` in `defineLofiApp` and render a custom surface from the278`useStorageFork` hook in `@nzip/lofi/preact` (states: `unarmed`, `idle`, `browser-data-at-risk`,279`fork-detected`). The `DeviceStatus` panel reports the same verdict in its PWA section.280281Two limits are inherent to the mechanism. The cookie copy is one-time at install, so nothing the282browser does afterwards reaches an already-installed container. And Safari caps script-set cookies283at seven days; the runtime refreshes the flag on every boot while data is at risk, but a user who284last visited the browser tab more than a week before installing gets no post-install notice. The285pre-install warning does not depend on the cookie and always applies. The recovery steps live in286[Troubleshooting](troubleshooting.md#the-installed-app-opened-empty-after-add-to-home-screen).287288### Schema compatibility and read-only mode289290The app shell and the local data version independently: a browser can hold an old cached shell next291to data another tab or device already migrated to a newer schema. Booting old code read-write onto292newer data is a defect, so lofi couples the two at build time and gates boot on the comparison.293294Every build stamps `dist/lofi-schema.json` with the schema range the bundle understands: a295fingerprint of the compiled schema (the head) plus fingerprints of every committed migration296snapshot under `src/migrations/snapshots/` (the lineage). The file ships in the precache, so an297offline shell always knows its own range. On each read-write boot the runtime records that range in298`localStorage` beside the store — readable before the runtime opens.299300On boot the gate compares the two:301302- **Code ahead of data** (the local record is in the bundle's lineage): the normal path. Migrations303 run as they always have, and the record advances to the new head.304- **Data ahead of code** (the bundle's head is in the local record's lineage): the shell is older305 than the data. The gate refuses table writes, surfaces the state through runtime diagnostics and306 the `DeviceStatus` panel, and prompts an update check. Reads continue — the app stays usable for307 looking things up, and nothing is lost; boot is never hard-failed by the gate.308- **Unrelated histories** (neither contains the other, e.g. a schema change shipped without its309 migration snapshot): treated like data-ahead. Writes stay refused until a bundle that knows the310 data's schema arrives.311312For users, read-only mode means edits are declined with the message "data was saved by a newer313version of the app"; updating the app (or reloading once the update installs) restores editing. The314refusal reaches application code as a `SchemaCompatibilityError` on the mutation promise, the same315surface as any other write failure.316317By default the framework renders a minimal fixed banner (`.lofi-schema-compat-banner`) with the318message and an update/reload action. Apps can restyle it via that class, or replace it entirely:319suppress it with `pwa: { updateBanner: "none" }` in `defineLofiApp` and render a custom surface from320the `useSchemaCompat` hook in `@nzip/lofi/preact` (states: `unchecked`, `compatible`, `data-ahead`,321`updating`).322323Applying an update coordinates across tabs on the same registration scope. The initiating tab324announces the swap on a scope-keyed BroadcastChannel; every tab pauses new local writes for a325bounded window while in-flight writes settle under a scope-keyed Web Lock, and only then is the326waiting worker activated. Afterwards, tabs that did not initiate the update reload by default;327`pwa: { staleTabs: "prompt" }` keeps their documents open instead, read-only, with a reload prompt.328Channel and lock names carry the scope key exactly like the cache names, so sibling lofi apps on one329origin never coordinate with each other by accident. Browsers without BroadcastChannel or Web Locks330fall back to the bounded pause alone.331332In development the gate is inert, and a deployment without `lofi-schema.json` (built before the333manifest existed) leaves writes enabled — the protection begins with the first build that ships the334manifest.335336Do not run a server-side Jazz credential in the static host or expose `JAZZ_ADMIN_SECRET` or337`BACKEND_SECRET` as public environment variables.338339## Stable origins matter340341Durable storage and service workers require a secure context outside localhost. WebAuthn credentials342also bind to the hostname. Choose the permanent production hostname before relying on device343credentials, add it to `credentialOrigins`, and avoid redirect or preview URLs that change between344deployments.345346## Release verification347348On the deployed HTTPS URL:3493501. Confirm the device capability panel passes.3512. Add data, reload, and restart the browser.3523. Install the PWA and perform an offline cold start.3534. If sync is configured, opt in with a throwaway account and verify another device can recover it.3545. Inspect the built application for the expected version/source fingerprint.