Skip to content

VoxellInc/featherweight

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

featherweight

Cross-device state for static sites. Your data lives in the browser (instant, offline); a low-latency edge KV store syncs it between devices. No backend, no build step, ~1 KB, zero dependencies. Works with any framework — or none; it's just a function.

<script type="module">
  import { featherweight } from './featherweight.js';   // copy the file, or use the CDN below

  const store = featherweight('my-doc');             // id = the storage key
  store.load(state => render(state));                // cache instantly, then remote if newer
  saveBtn.onclick = () => store.save({ count: 7 });  // local now, edge debounced
</script>

That's the whole idea: local-first reads, last-write-wins sync, through an edge store that has no idea what your data means.


Why

A static site has no server to remember anything. The usual answers are both too big for a lot of cases:

  • Rewrite it as an SPA — ship a framework runtime to every visitor to persist a counter. A cargo plane for a sandwich.
  • Stand up a backend — a server, a schema, a deploy, to store a blob.

featherweight is the small middle: keep the state on the client, and use the edge as a byte pipe — a key-value namespace that stores and returns one JSON blob per id. On Cloudflare that's a Pages Function + KV binding you can stand up in two minutes, and it deploys with your static site.

Install

Two pieces: the client (browser) and one edge endpoint.

1. Client — pick one

// a) Copy src/featherweight.js into your assets and import it by path:
import { featherweight } from './featherweight.js';

// b) Or from the CDN, nothing to copy:
import { featherweight } from 'https://cdn.jsdelivr.net/gh/VoxellInc/featherweight/src/featherweight.js';

// c) With a bundler (Vite/webpack/React/etc.), bare imports work once it's on
//    npm; until then point at the file or the CDN URL above.

2. Edge (Cloudflare Pages)

Copy edge/cloudflare-pages.js to functions/api/featherweight/[id].js, then bind a KV namespace named FEATHERWEIGHT:

npx wrangler kv namespace create FEATHERWEIGHT
# Pages → Settings → Bindings → add a KV binding named FEATHERWEIGHT
# (Production + Preview), then redeploy.

A standalone Worker variant (with CORS, for a separate origin) is in edge/cloudflare-worker.js.

3. Verify

curl https://yoursite.com/api/featherweight/ping
  • Before binding KV: {"data":null,"updatedAt":0,"nobind":true} — the nobind flag means storage isn't attached yet; the client runs local-only and nothing breaks, it just doesn't sync.
  • After binding + redeploy: the nobind flag is gone, and a PUT persists.

Local dev

npx wrangler pages dev <build-dir> runs the Function plus a local KV, so you can test real sync before deploying. A plain static file server won't have the endpoint — the client simply runs local-only against it, which is also fine.

Integrating into an existing site (field notes)

These are the things that actually bite when you drop featherweight into a real, already-built page (learned by doing exactly that — two hand-written character sheets with drag-to-spend hit points, tap-to-burn spell slots, toggled conditions, and an editable notes field):

  • Reuse the KV you already have. A live site usually already has a KV namespace. You don't need a second one — change the single KV(env) line at the top of cloudflare-pages.js to point at it (e.g. env.NOTES). The route and key prefix (fw:) keep featherweight's data from colliding with anything else in that namespace.
  • It's an ES module. From an existing classic <script> (a plain IIFE, not type="module") you can't use a static import. Either switch that block to <script type="module">, or load it dynamically: import('/featherweight.js').then(({ featherweight }) => …).
  • Mind the load gap. Dynamic import is async, so there's a brief window before the library is ready. If the user can interact in that window, don't let the initial load() clobber their input — track an "already touched" flag and, if it's set, save() their state instead of applying the loaded one. (Add <link rel="modulepreload" href="/featherweight.js"> to shrink the gap.)
  • apply() must be idempotent. load() calls it with the cached value and then again with the remote value if newer. Set state absolutely (el.classList.toggle(cls, !!on), el.value = data.x) — never append, flip, or increment from inside apply.
  • Guard fields being edited. If apply() fires (e.g. a remote sync lands) while the user is typing in an input/contenteditable, writing to it wipes their text and jumps the caret. Skip those: if (el !== document.activeElement) el.textContent = data.notes.
  • Migrating off bespoke storage? featherweight stores under fw-<id>, which won't match your old localStorage keys, so existing local state reads as empty on first load. If you need it preserved, do a one-time copy from the old key into a save() before the first load().

API

call does
featherweight(id, opts?) create a store. opts: endpoint (default /api/featherweight), debounce (ms, default 800), onStatus(s), storage (default localStorage).
store.load(apply) local-first load. apply(data, {source}) fires with the cached value, then the remote value if newer.
store.save(data) write the cache now (survives reload instantly) + debounced PUT to the edge. Safe to call every keystroke.
store.flush() send a pending save immediately (use on pagehide).
store.peek() the current cached value, synchronously.

data is any JSON-serializable value. The wire/record shape is { data, updatedAt }. The client's default endpoint (/api/featherweight) lines up with the edge route (/api/featherweight/<id>) out of the box.

When to use it

Use featherweight when all of these are roughly true:

  • Single writer. One person editing their own thing across their own devices (a tool, a dashboard, a tracker, a game sheet, notes).
  • Small, read-mostly state. Kilobytes, not a database.
  • Offline and instant matter. You want it to work on a plane and never spin a loader for local reads.
  • No SSR requirement. First paint doesn't need to be server-rendered for SEO.

It shines for personal apps on a CDN where a backend would be overkill — and it drops cleanly into a framework app too, since it's just a function.

When not to use it (honest)

  • Multiple concurrent writers. It's last-write-wins; two devices editing at once will clobber. If you need real merge, reach for CRDTs (Automerge, Yjs) or a sync engine (ElectricSQL, Replicache/Zero) — a different, heavier, fine tool.
  • Anything sensitive or trust-bound. The endpoint has no auth by default — possession of the id is access. Fine for a personal doc; add a capability token (in the id/URL) or real auth before storing anything you'd mind a stranger reading or overwriting.
  • Large or heavily-queried data. It stores/returns one blob; it's not a database. KV is eventually consistent (cross-region propagation up to ~60s) and has write-rate limits.
  • You already have a backend. Then just use it.

How it works

  1. Read: load() paints from localStorage synchronously, then GETs the blob from the edge and re-applies only if updatedAt is newer. The network is never in the way of showing your data.
  2. Write: save() updates the cache immediately and debounces a PUT. Offline? It stays cached and reports local-only; it syncs next time.
  3. Reconcile: last-write-wins by updatedAt. Simple on purpose.
  4. Edge: a tiny function that puts/gets a JSON string in KV by id. It's deliberately simple — it knows nothing about your data, which is what makes it cheap, cacheable, and replaceable.

The bigger picture

featherweight is the persistence piece of a broader idea — static delivery + local-first client + a low-latency edge store — written up here: The Featherweight Pattern (where it fits among SPAs and HTML-over-the-wire, plus the JSON-vs-binary numbers on whether the payload should ever be binary).

License

MIT © Voxell, Inc.

About

Featherweight — fast, lightweight web apps with no framework. Static site + a dumb edge key-value store + client-side (zero-copy) hydration. Local-first, offline, cross-device. Includes the JSON-vs-binary benchmark.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors