npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-sync

It 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 | unreachable

Access

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} />
  • SyncIndicator is 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.
    • unsynced and problem come from the app.
  • SyncSettings has sign-in, the three modes (modes rewords 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 fetch in syncFetch, and have InventoryConflictError extend SyncConflict.
  • lastSyncedAt reads sync-at.
  • save writes sync-mode and sync-signed-in; on enabled: false it also does what forgetSync does.
  • The live onMessage is version !== syncedVersion().
  • CloudSync, LiveUpdates, and App's scheduling effect go.
  • SaveIndicator becomes SyncIndicator (the same behaviour, from there), and the sync half of StorageSettingsPanel becomes SyncSettings with Italian's mode wording.
  • Its styles map the --sync-* colours to its own and add .sync-actions button to 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 / SyncSignedOut from request, and checks signal where it now rechecks the preference.
  • Settings stay in the IndexedDB device record: load it, then configure; the BroadcastChannel calls configure in other windows.
  • Live onMessage returns true for a newer revision, and stores mirror status 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.