@voxell/lux
v0.1.1
Published
Real-time application state sync — local-first client, microsecond broker path, no Redis/Kafka glue. ~1 KB, zero deps.
Maintainers
Readme
Lux (@voxell/lux)
Redis is a cache. Kafka is a log. Lux is application state.
Local-first browser client (~1 KB, zero deps) + a sync path you control. Reads hit the browser immediately. Changes push over WebSocket. No framework. No build step required.
Product: voxell.ai/lux · Argument: Redis is not a state sync layer
<script type="module">
import { lux } from './lux.js'; // copy the file, or use the CDN below
const store = lux('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.
lux 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/lux.js into your assets and import it by path:
import { lux } from './lux.js';
// b) Or from the CDN, nothing to copy:
import { lux } from 'https://cdn.jsdelivr.net/npm/@voxell/lux/src/lux.js';
// c) With a bundler (Vite/webpack/React/etc.): `npm install @voxell/lux`, then
// import { lux } from '@voxell/lux';2a. Edge (Cloudflare Pages / Workers)
Copy edge/cloudflare-pages.js to functions/api/lux/[[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 LUX:
npx wrangler kv namespace create LUX
# Pages → Settings → Bindings → add a KV binding named LUX
# (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, Lux 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 Lux 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.lux_live_2a98c56e...) is provisioned.
Client Config with GCP Broker: Set the synchronization endpoint to your Lux broker URL and include your provisioned API key:
import { lux } from 'https://cdn.jsdelivr.net/npm/@voxell/lux/src/lux.js'; const docId = 'enterprise-settings'; const syncStore = lux(docId, { endpoint: 'https://<your-lux-broker>/api/lux', apiKey: 'YOUR_PROVISIONED_LUX_KEY', realtime: true // Enable real-time WebSocket push sync });Direct Container Deployment (Alternative): A containerized Go implementation of the sync broker is located in
lux-sync-broker/. It compiles into a lightweight image, scales down to zero when idle, and implements an automatic daily write circuit breaker to stay within Google's free-tier database limits.To deploy the service spec:
cd lux-sync-broker gcloud run services replace service.yaml
3. Verify
curl https://yoursite.com/api/lux/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 lux 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 (lux:) keep lux'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('/lux.js').then(({ lux }) => …). - 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="/lux.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? lux stores under
lux-<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 |
|---|---|
| lux(id, opts?) | create a store. opts: endpoint (default /api/lux), 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/lux)
lines up with the edge route (/api/lux/<id>) out of the box.
When to use it
Use lux 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
lux is the persistence piece of a broader idea — static delivery + local-first client + a low-latency edge store — written up here: The Lux 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.
