@guymichaely/app-sync
v0.2.2
Published
When to sync, a live connection, Cloudflare Access sign-in, and sync status and settings views, for local-first apps that each keep their own data and merge.
Downloads
778
Readme
app-sync
When to sync, the live connection, Cloudflare Access sign-in, and the sync status and settings views, shared by apps that each keep their own data, merge, and server. Nothing here knows what's synced.
SyncIndicator / SyncSettings ──▶ SyncController ──▶ the app's SyncEngine (one sync: merge, upload)
│ ▲
LiveConnection ┘ └ the app's settings store (configure / save)- The app owns its data, how one sync works (merge, compare-and-set upload, receipts), how clashes are settled, where settings are kept, and how its windows hear about each other.
- The controller decides when that sync runs, one at a time, and reports how it went.
- The views render the controller's snapshot and call its actions.
npm install @guymichaely/app-syncIt ships compiled JavaScript with types, and React 19 is a peer dependency. Import the core from @guymichaely/app-sync, the views from @guymichaely/app-sync/react, and their styles from @guymichaely/app-sync/sync.css.
The engine
interface SyncEngine<Choices> {
sync(run: { explicit: boolean; choices?: Choices; signal: AbortSignal }): Promise<void>;
lastSyncedAt(): string | null;
}sync does one whole sync and resolves when this device and the server agree (as of when it started), recording lastSyncedAt. To say why not, it throws:
| Throw | Means | Then |
| --- | --- | --- |
| SyncOffline | the server can't be reached | retries in 1s, 2s, 5s, 15s, then every 30s (the live connection's reconnect schedule) while the app is visible, for as long as it takes; not while the browser says it's offline (coming back online syncs), and not while the live connection is reconnecting (getting through syncs) |
| SyncSignedOut | Access turned it away | nothing automatic (no live connection either) until Sign in again or Sync now gets through |
| SyncConflict(detail) | a clash only the person can settle | automatic syncs wait; resolve(choices) runs a sync with choices |
| anything else | a failure, shown with its message | waits for the next trigger (an edit, coming back, the live connection, the backstop) |
syncFetch makes the first two out of fetch: no network or 12s without an answer is SyncOffline; an Access redirect, 401, or 403 is SyncSignedOut. Every other response is the engine's to read.
signal is aborted when the device stops syncing, or when an automatic sync's device switches to manual. Check it before each request (or pass it to syncFetch); keep a response already received.
The controller
const sync = new SyncController<Choices, Conflict>({
engine,
save: (settings) => { /* store; tell other windows; on enabled: false, forget sync bookkeeping */ },
signInUrl: "/sync/signin",
live: { url: liveUrl("/sync/live"), onMessage: (message) => /* is this news here? */ },
lock: "myapp-sync",
});
sync.configure(storedSettings); // and again whenever another window changes them
sync.start();| Trigger | automatic | on-edit | manual |
| --- | --- | --- | --- |
| changed() (an edit here, another window's, the extension's) | 1.5s after the last | 1.5s after the last | — |
| opening, turning sync on, leaving manual, coming back to the tab, coming back online | sync | sync | — |
| live message the app says is news; live connection back while offline | sync | — | — |
| every 5 minutes while visible, unless the live connection is reconnecting | sync | — | — |
| Sync now / resolve | sync | sync | sync |
One sync runs at a time per window, and lock (a navigator.locks name) makes the browser's windows take turns. Anything asked for during a sync runs once after it: Sync now calls made meanwhile share one sync and its result; automatic triggers share another.
Actions: setMode(mode), turnOff(endSession) (stops syncing on this device; endSession also ends the Access sign-in), signIn() (turns syncing on and goes to sign in), changed(), syncNow(), resolve(choices), start(), stop(). syncNow and resolve resolve with the resulting state.
getSnapshot() / subscribe() fit useSyncExternalStore (useSync(controller) in React):
{ settings: { enabled, mode } | null, state, running, live, lastSyncedAt }
// state.kind: off | idle | synced | offline | signed-out | conflict | error
// live: off | connecting | open | unreachableAccess
signIn() saves the settings with syncing on, then goes to the server's sign-in route. That route sits behind Access, so reaching it means signed in, and it sends the browser back to the app:
case "/sync/signin": return new Response(`<!doctype html><script>location.replace("/")</script>`, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });A page rather than a redirect: an app in Capacitor's WebView loads pages natively and follows redirects without moving the address, so a redirect would leave it at the sign-in route.
Coming back needs nothing from the app. Syncing runs as on any opening; if the sign-in didn't happen (you backed out of it), Access turns it away and the state is signed-out, which offers Sign in again.
Framed
An app can be shown full-page in a frame on another page of the same site (example.com/app/ framing app.example.com), keeping its own origin. Same-site, the frame has the app's own storage, service worker, and cookies, as if visited directly. Sign-in pages refuse frames, so a framed signIn() posts { frame: "go", url } (the sign-in route, in full) to the page around it, which sends the whole tab there, adding return= its own address. The sign-in route goes back to return when it's one of the app's framing pages, and to the app otherwise:
const frames = ["https://example.com/app/"];
const back = url.searchParams.get("return");
const to = back && frames.some(frame => back.startsWith(frame)) ? back : "/";
return new Response(`<!doctype html><script>location.replace(${JSON.stringify(to).replaceAll("<", "\\u003c")})</script>`, ...);The page around it checks the message's origin and that url is on the app's origin before going.
Live connection
LiveConnection opens a WebSocket and hands each message to onMessage, parsed as JSON. It pings every 30s, treats no pong within 10s (or no open within 12s) as unreachable, reconnects after 1, 2, 5, 15, then 30s, drops at once when the browser goes offline, and reconnects at once when it comes back online or the tab becomes visible. The server answers "ping" with "pong" (ctx.setWebSocketAutoResponse on a Durable Object keeps that from waking it).
A clean close after opening (a deploy, a phone sleeping the page) just reconnects as connecting; only failing to open or answer makes it unreachable, which the indicator shows as "Can’t reach sync".
Views
import { SyncIndicator, SyncSettings, useSync } from "@guymichaely/app-sync/react";
import "@guymichaely/app-sync/sync.css";
<SyncIndicator sync={useSync(sync)} unsynced={count} problem={saveFailed ? "Save failed" : undefined}
settingsHref="#/settings" onSyncNow={() => void sync.syncNow()} />
<SyncSettings controller={sync} modes={appWordedModes} onResolve={openConflictScreen} />SyncIndicatoris for the top bar.- A problem shows in red and links to the settings.
- Otherwise it shows "Last sync 14:05" with a dot: amber while syncing or about to, green when automatic and connected.
- In manual mode it shows "N unsynced changes", which syncs when pressed.
unsyncedandproblemcome from the app.
SyncSettingshas sign-in, the three modes (modesrewords them), Sync now, Resolve, and Sign out. With the sign-in expired it only says so and offers Sign in again and Sign out.
Colours come from --sync-muted, --sync-ok, --sync-busy, --sync-problem, --sync-accent, --sync-line. Fonts and buttons are inherited: an app with class-styled buttons adds .sync-actions button (and .sync-primary, .sync-quiet) to its own button rules.
Fitting the two apps
Italian. The engine is syncOnce as it stands.
- Wrap
fetchinsyncFetch, and haveInventoryConflictErrorextendSyncConflict. lastSyncedAtreadssync-at.savewritessync-modeandsync-signed-in; onenabled: falseit also does whatforgetSyncdoes.- The live
onMessageisversion !== syncedVersion(). CloudSync,LiveUpdates, and App's scheduling effect go.SaveIndicatorbecomesSyncIndicator(the same behaviour, from there), and the sync half ofStorageSettingsPanelbecomesSyncSettingswith Italian's mode wording.- Its styles map the
--sync-*colours to its own and add.sync-actions buttonto its button rules. - Tried in a throwaway copy of Italian on 2026-10-06: it typechecked, its 100 tests passed, it built, and against a local Worker it synced on sign-in, took another device's change over the live connection, held a manual edit until pressed, and showed and recovered from the server going away.
Nutrition. The engine is one pass of its exchange under nutrition-sync.
- It throws
SyncOffline/SyncSignedOutfromrequest, and checkssignalwhere it now rechecks the preference. - Settings stay in the IndexedDB device record: load it, then
configure; the BroadcastChannel callsconfigurein other windows. - Live
onMessagereturns true for a newerrevision, and storesmirrorstatus itself (returning false). - Review counts and Google status stay in Nutrition's own panel.
Deliberately left out
- Settings storage, a pending-work count, or review counts in the controller. The app already has them, and passes them to the views or keeps them.
- Async settings loading/writing states, corrupt-settings repair, and settings read-back. The app reads its settings and calls
configure; a malformed stored value is the app's boundary to reject. - Giving up after N retries. A device that was offline for a minute would otherwise stop syncing until someone presses Sync now.
- Generation counters for stale callbacks. A sync's
AbortSignal, and timers that are cleared rather than checked, do that job. - Server code. Each app keeps its own Worker; the shared part there is one redirect route and a ping/pong auto-response.
Developing
npm install
npm run check # tsc, then node --test (Node 24 runs the .ts directly)Tests drive the controller and live connection with a fake clock, browser, and socket, and use Node's own navigator.locks for the cross-window lock. The views are rendered to markup with react-dom/server; tests/register.ts compiles their JSX in memory, since Node runs .ts but not .tsx. Nothing has run in a real browser yet: the first app to adopt this does that.
This is the merge of three independent implementations of the same design. Their test suites also pass against this source, apart from tests of their own wording and one deliberate difference: like Italian, the indicator shows no last-sync time while sync is off.
To release a change: bump version in package.json, then npm publish. That runs the type check and tests, then builds dist/ from src/, before uploading.
