@codefusion-cc/app-update
v0.1.2
Published
Keeps people on the newest version: moves an open page onto a newer deploy without losing anyone's work, and tells an installed app's users about a newer GitHub release, what it brings and whether theirs is behind; in an app on an iPhone's Home Screen, op
Maintainers
Readme
@codefusion-cc/app-update
Keeps people on the newest version. A page: an installed web app or a tab left in the background can run old code for days while the Worker behind it is new, and after a deploy a page may ask for scripts that no longer exist; this package notices both and moves the page onto the new build at a moment nothing would be interrupted. An installed app released on GitHub: which release is newest, whether the version someone runs is behind, what each release brings, and release notes people can read, written from the merged pull requests.
npm install @codefusion-cc/app-updateThe deploy publishes /version.json with its commit: versionFile() from @codefusion-cc/console/vite writes it,
and the page knows its own commit from buildIdentity() (e.g. as a Vite define).
In the page
Once, at startup, in a module the rest of the app imports:
// src/updates.ts
import { watchAppUpdates } from '@codefusion-cc/app-update/browser'
export const updates = watchAppUpdates({ current: __APP_COMMIT__, storageKey: 'myapp-update' })Imported outside a browser (a Node test, a server render) it watches nothing, and importOrReload only imports.
The page checks the version file when it opens, every 10 minutes while in view (checkEveryMs), and whenever the
person comes back to it. Knowing of a newer build:
- a link the person clicks loads its page afresh instead of a router move;
- coming back to the page (another app, a locked phone) reloads it at once;
- with
reloadWhenIdle: true, the page reloads as soon as nothing holds it, trying again every 30 seconds, in view or in the background (where nobody sees it; the version is then checked in the background too): for pages people leave open for hours, like a chat.
Nothing is cut short. The reload waits while:
- a field was changed on this page (until the router moves on: call
updates.navigated()on each route change), or a focused field holds text; - a request that changes data (anything but GET and HEAD) or a Web Lock task runs (
holdDuringWrites, on by default: the package wrapsfetchandnavigator.locks.request); - the app holds it:
const release = updates.hold()for an unsaved form or a call, orupdates.whileHeld(task)for work that must finish (the browser also asks before the page is closed).
When the page cannot reload by itself, updates.onNewVersion(commit => …) tells the app, once per version, so it
can offer a refresh. A page reloads into each version at most once per tab (the tab's sessionStorage remembers),
so a cache serving the old build cannot make it loop; without that storage it never reloads by itself.
Scripts a deploy removed
A lazily loaded page whose files are gone reloads into the new build and keeps its loading state:
const Settings = lazy(() => updates.importOrReload(() => import('./Settings.tsx')).then((m) => ({ default: m.Settings })))Vite's vite:preloadError and a failed <link rel="modulepreload"> reload too, and the package takes Vite's error
while the page reloads for it, so no error screen or failure report shows meanwhile. Such a reload happens at most once
a minute, so a file missing for good shows its error instead of looping. It waits for the same holds as a reload into a
newer build (a changed field, someone typing, a write, a Web Lock task, hold, whileHeld) and comes as soon as the
last one lets go (someone typing: on the next try, every 30 seconds), a hidden page too; meanwhile Vite's error goes on
and onNewVersion hears of the newer build, so the app can show its error and offer the refresh. Failures count as
expected for the first minute of that wait only, so a file missing for good on a page held for long is still reported.
A page still here 10 seconds after setting out to reload (RELOAD_WAIT_MS: still held, or the person chose to stay
when the browser asked) gets the import's error.
Offline (navigator.onLine false) the page never reloads or sends a link to a fresh load: that would land on the
browser's offline page. A script that fails then is the network's doing, so the import's error goes to the app (its
error screen and retry), and a newer build stays a notice until the connection is back and the deploy answers, when a
waiting reload comes. Coming back to a page reloads it into a newer build only when its version check just got an
answer, so a device that says it is online but reaches nothing keeps the notice too.
A reload waiting for a hold comes on the task after the hold lets go, not inside it: writes made one after another
(await save(); await publish()) hold it from the first to the last. updates.isReloading() says a reload is on
its way (failures now are expected: don't report them), and updates.reloadForStaleScript() is the same reload
for an error the app caught itself.
Releases of an installed app
@codefusion-cc/app-update (the root entry) runs wherever fetch does: a Worker, a page, Node.
import { fetchGitHubReleases, isOutdated, latestRelease, releasesSince } from '@codefusion-cc/app-update'
const result = await fetchGitHubReleases('owner/app', {
userAgent: 'app.example.com',
init: { cf: { cacheTtl: 600, cacheEverything: true } }, // in a Worker: GitHub asked at most every 10 minutes
})
if (result.ok) {
const latest = latestRelease(result.releases) // the highest version that is not a pre-release
if (isOutdated(device.version, latest?.version)) showUpdate(latest, releasesSince(result.releases, device.version))
} else {
// 'offline' | 'rate-limited' (with retryAt when GitHub says) | 'not-found' | 'unavailable' | 'invalid'
}Versions follow Semantic Versioning 2.0: 1.2.0-rc.1 comes before 1.2.0, 1.2.0-beta.11 after 1.2.0-beta.2,
build metadata never counts (1.2.0+dev is 1.2.0, a good spelling for development builds), and a leading v is
fine. compareVersions is null and isOutdated false when either side is not a version, so a broken report never
nags anyone. fetchGitHubReleases never rejects: no network, a timeout, the rate limit and odd answers are reasons;
drafts and tags that are not versions are left out.
Release notes on screen
parseReleaseNotes(release.notes, { repo }) reads the Markdown release notes use (headings, lists, paragraphs, code,
emphasis, links, #123, @name) into blocks of plain data the app renders with its own components. Nothing is HTML:
markup stays visible text and links only ever go to http(s) or mailto. A bare pull request address reads #12, a
comparison v1.0.0...v1.1.0; #123 and @name link only with repo, and web names another GitHub's pages.
In React
@codefusion-cc/app-update/react has what a page shows of all this, styled by the app:
import { BuildVersion, createLatestRelease, ReleaseNotes } from '@codefusion-cc/app-update/react'
// One read for every component on the page, again after 10 minutes (30 s after a failure).
export const latest = createLatestRelease(() => fetch('/api/releases/latest').then(r => (r.ok ? r.json() : null)))
function WhatsNew({ release }) {
const newest = latest.useLatest() // undefined while read, null when unknown
return (
<>
<ReleaseNotes notes={release.notes} repo="owner/app" classes={{ list: 'list-disc pl-5', link: 'link' }} />
<BuildVersion version="1.2.0" commit="abc1234" repo="owner/app" />
</>
)
}ReleaseNotes takes Markdown or blocks from parseReleaseNotes and renders text and safe links only (new tab
by default); its headings start at h3, under the release's own title. BuildVersion shows v1.2.0 · abc1234 with
the commit linked (commitUrl, also in the root entry), and a build outside git (dev) as it is.
Release notes people can read
codefusion-release-notes writes a release's notes from the pull requests it merged, grouped by the branch type
(feat/ New, fix/ Fixes, perf/ Faster, docs/ Documentation, the rest Under the hood, Renovate's on one line),
else a Conventional Commits title, else a common label (bug, enhancement, dependencies…). The release's own pull
request and those labeled skip-changelog stay out. writeReleaseNotes takes messages to write them in another
language (ENGLISH is the default).
# .github/workflows/release.yml, on a pushed v* tag
- run: npx -y -p @codefusion-cc/app-update@^0.1 codefusion-release-notes "$GITHUB_REF_NAME" > notes.md
env: { GITHUB_TOKEN: '${{ github.token }}' }
- uses: softprops/action-gh-release@v2
with: { body_path: notes.md }The release before is the highest version tag below this one (pre-releases count only for a pre-release); --previous
names another. @codefusion-cc/app-update/release-notes has the same as functions (writeReleaseNotes,
mergedPulls, previousTag) for a script of its own.
Links to other sites from the Home Screen
An app added to an iPhone's or iPad's Home Screen opens links to other sites in a browser sheet that shares none of Safari's sign-ins, so every link to npm, GitHub or a dashboard asks the person to sign in again. Once, at startup:
import { openLinksInSafari } from '@codefusion-cc/app-update/browser'
openLinksInSafari()There, a click on an https link to another origin goes to x-safari-https://…, which iOS 17 opens in Safari; when
the app is still on screen 600 ms later (SAFARI_FALLBACK_MS: an older iOS), the link opens as it would have. Links
the app handles itself (preventDefault), downloads and clicks with a modifier are left alone. In a browser tab, on
desktop and on Android nothing changes. It returns a function that stops it.
In an app's tests
@codefusion-cc/app-update/testing is a page and the deploy behind it, for tests of the app's code around the
package (a form that holds the reload, a task under whileHeld):
import { testPage } from '@codefusion-cc/app-update/testing'
const page = testPage({ deployed: 'e461357' })
const updates = watchAppUpdates({ current: 'abc1234', storageKey: 'test' }, page.env)
const release = updates.hold()
await page.comeBack()
expect(page.reloads).toBe(0)
release()
await page.comeBack()
expect(page.reloads).toBe(1)Tests
Written from how a page meets a deploy: links of every kind, coming back, the back-forward cache, blocked storage, writes and Web Locks that fail, double events, a cache serving the old build, and scripts missing for good. Releases: the ordering Semantic Versioning lists, with property tests of a total order; GitHub offline, slow, rate limited both ways, missing, refusing and answering nonsense; hostile notes (script links, HTML, unclosed markers, 128 KiB of one marker) and notes in any script; pull requests merged twice, squashed, never merged, and comparisons over several pages.
