@voxell/featherweight
v0.1.0
Published
Cross-device state for static sites — local-first, synced through a dumb edge key-value store. No framework, ~1 KB.
Maintainers
Readme
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_sdk/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.2a. Edge (Cloudflare Pages / Workers)
Copy edge/cloudflare-pages.js to functions/api/featherweight/[[id]].js (note the double brackets for wildcard routing to handle both standard IDs and the WebSocket streaming route), 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.An optional, high-performance in-memory real-time push sync (via a Durable Object) is supported. To enable, add a Durable Object binding named SYNC_BROKER pointing to the SyncBroker class.
A standalone Worker variant (with CORS, for a separate origin) is in edge/cloudflare-worker.js.
2b. Edge (Google Cloud Run / GCP Marketplace)
For high-availability enterprises and teams requiring unified billing, Featherweight Sync can be deployed as a containerized serverless broker on Google Cloud Run and backed by Google Cloud Firestore.
You can subscribe directly via the Google Cloud Marketplace or run the broker container yourself:
GCP Marketplace Subscription:
- Subscribe to Featherweight Sync in the GCP Console.
- Click Register with Provider to activate your entitlement. This redirects you to the activation portal where your unique SDK
apiKey(e.g.fw_live_2a98c56e...) is provisioned.
Client Config with GCP Broker: Set the synchronization endpoint to your Google Cloud External Load Balancer IP and include your provisioned API key:
import { featherweight } from 'https://cdn.jsdelivr.net/gh/VoxellInc/featherweight_sdk/src/featherweight.js'; const docId = 'enterprise-settings'; const syncStore = featherweight(docId, { endpoint: 'http://34.117.115.19/api/featherweight', // Your high-availability GCLB IP apiKey: 'YOUR_PROVISIONED_FW_LIVE_KEY', // Your unique GCP entitlement key realtime: true // Enable real-time WebSocket push sync });Enterprise Go Sync Broker: A high-performance, containerized Go Sync Broker is available for enterprise licensing and direct deployment. It supports autoscaling to zero when idle, advanced rate limiters, and direct integration with your VPC and Firestore clusters. Subscribe directly via Google Cloud Marketplace or contact Voxell, Inc. for deployment options.
3. Verify
curl https://yoursite.com/api/featherweight/ping- Before binding KV:
{"data":null,"updatedAt":0,"nobind":true}— thenobindflag means storage isn't attached yet; the client runs local-only and nothing breaks, it just doesn't sync. - After binding + redeploy: the
nobindflag is gone, and aPUTpersists.
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 ofcloudflare-pages.jsto 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, nottype="module") you can't use a staticimport. 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 insideapply.- 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 asave()before the firstload().
API
| call | does |
|---|---|
| featherweight(id, opts?) | create a store. opts: endpoint (default /api/featherweight), debounce (ms, default 800), onStatus(s), storage (default localStorage), realtime (boolean, default false), websocketUrl (custom WS URL). |
| store.load(apply) | local-first load. apply(data, {source}) fires with the cached value, then the remote value if newer. If realtime: true, it automatically establishes a WebSocket connection for push sync. |
| 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
- Read:
load()paints fromlocalStoragesynchronously, thenGETs the blob from the edge and re-applies only ifupdatedAtis newer. The network is never in the way of showing your data. - Write:
save()updates the cache immediately and debounces aPUT. Offline? It stays cached and reportslocal-only; it syncs next time. - Reconcile: last-write-wins by
updatedAt. Simple on purpose. - 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.
