Backlit JavaScript SDK — window.backlit
The Backlit SDK is a small browser library that AI-generated static apps
("glows") use at runtime: read the signed-in user's identity, read and
write glow-shared data, per-user data, and owner-scoped records,
subscribe to realtime change events, and post write-only telemetry. This
page is the public contract for everything window.backlit exposes.
This document is published as HTML at /reference
and as plain Markdown at /sdk.md so AI agents can
fetch and parse the surface without HTML scraping. A machine-readable
TypeScript declaration of the whole surface is published at
/sdk.d.ts (hosted alongside the bundle at
sdk.backlit.run/sdk/v1/sdk.d.ts), and a routing index for agents at
/llms.txt.
Quickref
Drop in the SDK, then call window.backlit.*. Every method is async and
throws BacklitError on failure — except auth.me(), which resolves null
instead. The full prose, semantics, and examples are below; this section is
the ~80% an agent needs to write the calls.
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
Machine-readable types: /sdk.d.ts.
Pick a store
| Store | Who reads / writes | Use for |
|---|---|---|
backlit.data |
every visitor reads one shared set; signed-in non-viewer users write (anonymous sessions are read-only) |
leaderboards, shared docs, published content |
backlit.userdata |
each signed-in user, private isolated silo (requires sign-in) | per-user settings, drafts, history |
backlit.records |
any signed-in user creates + reads all; only the creator or an admin edits (requires sign-in) | comments, forum posts, submissions, galleries |
backlit.capture |
anyone writes; only an admin reads / lists / deletes | form submissions, telemetry the app must not read back |
backlit.device |
this device only, never synced; works signed out | a device-only preference (theme, last-opened tab) — never app data |
Methods
backlit.version: string
backlit.auth.me(): Promise<BacklitUser | null> // never throws; null when signed out
backlit.auth.users({ cursor?, limit? }?): Promise<{ items: BacklitUser[], next: string }> // admin only; THROWS
backlit.auth.login(returnPath?): void // send the visitor to sign in (no-op off-host)
backlit.auth.logout(returnPath?): void // sign the visitor out (no-op off-host)
// data, userdata, and records share this shape. records.* additionally carries
// `owner` on get / list / getBatch / getBatchJSON / putIfMatch / updateJSON, and adds reassign().
backlit.data.get(key): Promise<{ value: Blob, contentType: string, crc32c: string }>
backlit.data.put(key, body, contentType): Promise<{ crc32c: string }>
backlit.data.putIfMatch(key, body, contentType, expectedCrc32c): Promise<PutIfMatchResult>
backlit.data.delete(key): Promise<void>
backlit.data.list(prefix?, { cursor?, limit? }?): Promise<{ items: Array<{ key, crc32c, contentType, size }>, next: string }>
backlit.data.getBatch(prefix?, { cursor?, limit? }?): Promise<{ items: Array<BatchEntry>, next: string }> // list + each value; oversized entries arrive metadata-only (omitted:true)
backlit.data.getBatchJSON<T>(prefix?, { cursor?, limit? }?): Promise<{ items: Array<BatchJSONEntry<T>>, next: string }> // getBatch, each value JSON.parsed
backlit.data.getJSON<T>(key): Promise<{ value: T, crc32c: string }>
backlit.data.putJSON(key, value): Promise<{ crc32c: string }>
backlit.data.putJSONIfMatch<T>(key, value, expectedCrc32c): Promise<PutJSONIfMatchResult<T>>
backlit.data.updateJSON<T>(key, mutate, options?): Promise<{ value: T, crc32c: string }>
backlit.records.reassign(key, newOwnerUid): Promise<void> // admin only
backlit.capture.create(prefix, body, contentType): Promise<{ handle: string, crc32c: string }>
backlit.capture.update(handle, body, contentType): Promise<{ crc32c: string }>
backlit.capture.get(handle): Promise<{ value: Blob, contentType: string, crc32c: string }> // admin only
backlit.capture.list(prefix?, { cursor?, limit? }?): Promise<{ items: Array<{ handle, crc32c, contentType, size }>, next: string }> // admin only
backlit.capture.delete(handle): Promise<void> // admin only
backlit.on("storage.change", handler): () => void // handler({ store, operation, key, crc32c, owner? })
backlit.on("app-update-available", handler): () => void // handler({ client, server }) — a newer deploy is live than the one this page loaded
backlit.checkVersion(): Promise<{ client: string | null, server: string | null }> // server is null offline; fires app-update-available when they differ
backlit.push.supported(): { supported, reason?, requiresInstall, installed } // SYNC; never throws
backlit.push.state(): Promise<{ permission, subscribed, endpointId }> // requires sign-in
backlit.push.subscribe(): Promise<{ endpointId, permission: "granted" }> // requires sign-in; prompts for permission
backlit.push.unsubscribe(): Promise<void>
backlit.push.test(): Promise<void> // send yourself a test push
backlit.push.watch(cb): () => void // cb({ type: "notification-click", url } | { type: "subscription-changed" } | { type: "permission-changed", permission })
backlit.offline.enable(opts?): Promise<OfflineStatus> // opt-in on-device fallback: keeps an on-device copy of reads + queues writes when offline; call once at startup — see backlit.offline
backlit.offline.whenReady(): Promise<OfflineStatus> // resolves once an enable() in progress has finished
backlit.offline.whenSynced({ timeoutMs? }?): Promise<OfflineStatus> // resolves once the write queue has nothing more it can send right now
backlit.offline.status() / .pending() / .sync() / .disable() / .clear()
backlit.offline.watch(cb, { events? }?): () => void // default event set; name `events` to also get later types such as "evicted"
backlit.offline.peek(store, key): Promise<result | null> // the on-device copy of one key, no request (peekJSON parses it)
backlit.offline.cached(store, prefix?, opts?): Promise<{ items, next, fromCache: true, complete }> // what the device holds under a prefix, no request
backlit.offline.prefetch(store, prefixOrKeys, opts?): Promise<OfflinePrefetchResult> // fetch values onto the device before going offline (metered requests)
backlit.offline.pin(store, prefix) / .unpin(store, prefix): Promise<boolean>; backlit.offline.pins(): Promise<OfflinePin[]> // keep a prefix's values through eviction
backlit.device.get(key) / .getJSON<T>(key): Promise<string | null> / Promise<T | null> // device-only preferences: this device, never synced, capped — never app data
backlit.device.put(key, value) / .putJSON(key, value) / .delete(key): Promise<void>
backlit.hideUI(): void // remove the Backlit button this SDK injects (paid plans only; no-op otherwise; if hidden, offer your own sign-out via auth.logout)
body is string | Blob | ArrayBuffer | ArrayBufferView (plain objects are
rejected — use the *JSON helpers, or JSON.stringify + "application/json").
contentType is required. Keys/prefixes are [A-Za-z0-9._~-], 1–256 bytes
(prefix may be empty); capture handles are the "{prefix}-{guid}" value
create returned.
Shared writes — the one rule
For any data / records key more than one user can write, use
updateJSON (load → mutate → compare-and-swap → rebase-and-retry), never a
bare putJSON (an unconditional last-writer-wins overwrite that silently
drops a concurrent edit). userdata has a single writer, so putJSON is fine
there. Shared data writes need a signed-in non-viewer user — anonymous
sessions can only read.
await backlit.data.updateJSON("todos", (todos) => {
todos ??= { items: [] };
todos.items.push({ text, done: false });
return todos; // mutate MUST be replayable — it reruns against a fresher value on conflict
});
Error codes
Discriminate on err.code (err instanceof backlit.BacklitError). err.status
is the HTTP status, or 0 for client-side/network failures.
code |
Meaning |
|---|---|
not_found |
get / delete on a key that was never written |
unauthenticated |
a shared data write, userdata.* / records.*, an admin-only capture op, or auth.users() with no user session |
forbidden |
a viewer wrote shared data; a non-creator/non-admin wrote a records key; a non-admin called an admin-only op |
invalid_key / invalid_prefix / invalid_handle |
argument failed the charset / length / handle-form rule |
invalid_cursor |
a list (or auth.users()) cursor was not a valid pagination token |
unknown_handle |
well-formed capture handle with no stored record |
invalid_body |
getJSON read non-JSON bytes, or the server could not decode the body |
invalid_argument |
bad body type, missing contentType, a non-serializable putJSON value, or a malformed reassign owner id |
unsupported_content_type |
content-type off the allowlist (or an always-denied executable type) |
payload_too_large |
body exceeds the per-object cap (10 MiB); also a backlit.device value over its 8 KiB limit (status 0) |
quota_exceeded |
a write exceeded the account's storage allowance (413), or any call landed after the monthly bandwidth allowance ran out (429); also a backlit.device write past its key or total-size limit, or the browser's own storage full (status 0) |
conflict |
updateJSON exhausted its retries (maxAttempts, default 6) under contention; or a records delete/reassign lost a concurrent-write race (412) — retry |
identity_mismatch |
a backlit.offline queued write replayed while a different user was signed in on this device; the SDK quarantines it until the write's own user is signed in again — never thrown, only visible via offline.pending() / offline.watch() |
unsupported_host |
the SDK was loaded off a deployed glow host |
unsupported / requires_install / permission_denied |
push.subscribe() — the browser can't do push, needs the app installed first (iOS), or the user declined the permission prompt; unsupported is also a backlit.device code (this browser has no on-device storage the SDK can use) |
cross_glow_token · glow_not_found · glow_dark · region_mismatch · invalid_host · method_not_allowed · network · internal |
transport / glow-state failures |
A putIfMatch mismatch is not an error — it resolves
{ matched: false, witness } carrying the current value.
Deploy bundle contract
Every deploy bundle must contain index.html, APP.md (what the app
is, who it's for), and DATA.md (how it uses the data namespaces) at
the archive root, or the upload is rejected with
missing_required_files; .html/.htm/.js/.mjs files that
reference localStorage or indexedDB are rejected with
browser_storage_forbidden — use window.backlit.* instead. APP.md
and DATA.md are served publicly: no secrets, credentials, or PII.
An optional root notify.json turns on push notifications for the
glow (see backlit.push); without one, push is simply off.
An optional root offline.json turns on offline install for the glow
(see Offline install); without one, the app needs the network.
A glow is installable without a web app manifest of your own; if you
ship one, its <link rel="manifest"> must carry
crossorigin="use-credentials" or a Private glow can't be installed
(see Packaging the deploy bundle).
Loading
The SDK is a single UMD bundle hosted on the Backlit CDN. Drop one
<script> tag in your HTML; no build step required.
<!doctype html>
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
<script>
(async () => {
const user = await window.backlit.auth.me();
// ...
})();
</script>
(The async IIFE matters: classic <script> blocks can't use top-level
await — that's a SyntaxError outside module scripts.)
Two URL channels are supported:
| URL | Stability | Use for |
|---|---|---|
https://sdk.backlit.run/sdk/v1/sdk.js |
Stable | Production glows. Pinned to the v1 surface. |
https://sdk.backlit.run/sdk/latest/sdk.js |
Rolling | Quick experiments. Aliases the newest v1. |
The bundle is an IIFE that attaches a single global, window.backlit. It
takes no constructor — the namespaces below are ready to use the moment
the script tag finishes parsing.
window.backlit.version is a string identifying the bundled SDK version
(e.g. "1.0.0").
The SDK runs only on a deployed glow host (a *.backlit.run subdomain,
or a recognised dev host — *.backlitdev.run, *.localtest.me,
localhost, *.localhost).
Loaded anywhere else — straight from file://, an AI design tool's
preview iframe, or any other origin — every data.* / userdata.* /
records.* / capture.* call (and auth.users()) rejects with
BacklitError(code: "unsupported_host"); only auth.me() resolves
null rather than throws. There is no local/offline fallback for the
data surface — deploy the glow to exercise it. (Offline app caching
is a separate, opt-in bundle feature — see
Offline install — and it never caches
store data either; a deployed glow may separately opt into an on-device
copy and write queue for its data with
backlit.offline.)
Storing app state
Most glows keep some state — a list, a document, user preferences. Backlit gives you three key/value stores; pick by who owns the data:
| Store | Who can read & write | Use for |
|---|---|---|
backlit.data |
every visitor reads (one shared set); signed-in non-viewer users write |
leaderboards, shared docs, published content |
backlit.userdata |
each signed-in user, isolated (requires sign-in) | per-user settings, drafts, history |
backlit.records |
any signed-in user creates + reads all; only the creator/admin edits (requires sign-in) | forum posts, comments, user submissions, galleries |
All three stores hold bytes, but the *JSON helpers serialize and parse
objects for you — so most apps never touch the raw byte API:
// Save an object. Returns its { crc32c } — an integrity/version hash.
await backlit.data.putJSON("todos", { items: [], updatedAt: 0 });
// Load it back. `value` is the parsed object; `crc32c` is its current hash.
const { value: todos, crc32c } = await backlit.data.getJSON("todos");
The first read of a key — before anything is written — throws not_found.
Catch it and start from a default:
let todos;
try {
todos = (await backlit.userdata.getJSON("todos")).value;
} catch (e) {
if (e.code !== "not_found") throw e;
todos = { items: [] }; // first run
}
Saving shared state safely
putJSON is an unconditional overwrite — last writer wins. That is fine
for userdata (you are the only writer) and for any data key only one
user ever writes. But when two users can edit the same data key, a
plain putJSON silently drops one of their changes.
For shared, multi-writer state use updateJSON instead. It loads the
current value, runs your merge function, and commits with a compare-and-swap;
if another session wrote in between, it rebases onto the latest value and
retries — so no edit is ever lost, and you never write a loop yourself:
// `mutate(current)` receives the current value (undefined on the first write)
// and returns the new value. It may run more than once — it replays against a
// fresher value on conflict — so keep it a pure function of its input (no side
// effects).
await backlit.data.updateJSON("todos", (todos) => {
todos ??= { items: [] };
todos.items.push({ text, done: false });
return todos;
});
updateJSON resolves the committed { value, crc32c }. It retries up to
maxAttempts (default 6) and throws BacklitError(code: "conflict") only if
it can't win the race in that many attempts — a sign of extreme contention.
It's built on putJSONIfMatch; reach for
that directly only when you
want create-only semantics or to inspect the conflicting value yourself.
Rule of thumb: putJSON when last-writer-wins is genuinely fine (single
writer, or idempotent content); updateJSON for anything several people
edit at once. Full method docs are under
backlit.data.
Migrations
When porting an existing standalone HTML app to a glow, replace browser-
local persistence (localStorage, sessionStorage, raw indexedDB)
with the equivalent Backlit surface. The mapping is straightforward
once you decide who the data belongs to:
| Browser API | Backlit equivalent | When to pick it |
|---|---|---|
localStorage / sessionStorage (per-user) |
backlit.userdata.* |
Settings, drafts, history — anything scoped to this signed-in user. Requires sign-in (a Private or Accounts glow). |
localStorage (app-global state) |
backlit.data.* |
Content that every visitor should see: a wiki page, a leaderboard, a published gallery. Writes need a signed-in non-viewer user. |
localStorage (device-only UI preference) |
backlit.device.* |
A theme, the last-opened tab, a collapsed panel — a small preference that belongs to this device and that nobody would expect on another. Works signed out. Anything the user expects to follow them to another device stays in backlit.userdata.*. See backlit.device. |
IndexedDB keyed records |
backlit.data.* / userdata.* / records.* |
Same split as above. Backlit's keyspace is flat — collapse object-store + key into one string; one entry per author that others can read but not edit → records.*. |
Hidden form posts / fetch("/submit") |
backlit.capture.create |
Contact forms, telemetry events, support requests — write-only captures the page can't read back. |
| File uploads to a separate server | backlit.data.put (binary) |
Accepts Blob / ArrayBuffer / typed arrays with a matching raster content type (image/png, image/jpeg, image/gif, image/webp). |
Three differences will trip you up if you don't plan for them:
- Everything is async.
localStorage.getItemis synchronous;backlit.data.getreturns aPromise. Wrap reads inawaitand move any work that depended on synchronous storage into anasyncinitialiser. - Objects use the
*JSONhelpers.localStoragestores strings; for object state useputJSON/getJSON, which serialize and parse for you. (The underlyingput/gettake rawstring | Blob | ArrayBuffer | ArrayBufferViewwith an explicitcontentType— that's the path for binary like images.) - Keys are restricted.
[A-Za-z0-9._~-]only, no slashes. If you usedlocalStorage.setItem("user/42/profile", …)translate touser.42.profile(or any other slash-free scheme).
A typical port — the localStorage version on the left, the Backlit
version on the right:
// before
const prefs = JSON.parse(localStorage.getItem("prefs") ?? "{}");
prefs.theme = "dark";
localStorage.setItem("prefs", JSON.stringify(prefs));
// after
let prefs = { theme: "light" };
try {
prefs = (await backlit.userdata.getJSON("prefs")).value;
} catch (e) {
if (e.code !== "not_found") throw e;
}
prefs.theme = "dark";
await backlit.userdata.putJSON("prefs", prefs);
⚠️ This load → mutate → save is safe here only because
userdatahas a single writer — you. The identical shape on sharedbacklit.data.*is a lost-update bug. For any shared key more than one session can write, useupdateJSONinstead ofputJSON.
When you need a key gone, call backlit.data.delete(key) or
backlit.userdata.delete(key) — see the delete sections
below. Reach for backlit.capture.create whenever the page must
capture something — a contact form, an event — but ordinary app code
should never be able to read it back even if the page's JavaScript is
compromised. (An admin user can later read, enumerate, and delete
those captures with backlit.capture.{get,list,delete}; non-admin
sessions cannot.)
Offer advanced features that fit the app
A faithful port reproduces what the original app already did — but moving onto Backlit unlocks capabilities a plain static page never had, and the person you're building for usually doesn't know to ask for them. If you're an AI agent migrating an app, read what it does and proactively offer the matching upgrades below before you deploy. Describe the benefit in plain terms, suggest only what genuinely fits the app, and wire one in only if the user says yes.
| If the app… | Offer | Backlit surface |
|---|---|---|
| shows shared state several people view at once — leaderboard, kanban board, presence, a collaborative doc | Realtime sync + safe writes — live updates across tabs and users (no polling), and updateJSON so concurrent edits merge instead of clobbering. Realtime makes a conflict visible; updateJSON makes it impossible to lose. |
backlit.on("storage.change", …) + data.updateJSON |
| lets people post content others see but shouldn't edit on each other's behalf — comments, chat, forum threads, reviews, profiles, a shared gallery, contest entries | Owner-scoped records — anyone signed in can post and read everyone's, but only each item's author (or an admin) can edit or delete it, so users can't tamper with one another's content. Emits realtime events and has updateJSON, just like data. |
backlit.records.* (requires sign-in) |
| keeps per-user settings, drafts, or history in one global blob | Per-user data — each signed-in user gets an isolated silo | backlit.userdata.* (requires sign-in) |
| collects form submissions, contact requests, or telemetry | Private capture + an admin review page — visitors write submissions the page can't read back; an admin page lists and reads them | backlit.capture.create to capture; capture.list/get/delete behind an admin |
| shows or hides controls by role — an editor vs. a read-only viewer | Permission-aware UI — branch on the signed-in user's role | backlit.auth.me() (the permission field) |
| needs sign-in, or should be limited to specific people or domains | Private or Accounts auth mode — Private restricts sign-in to an allowlist; Accounts lets any visitor create an account themselves; leave it Public for an open app | the glow's auth_mode setting |
| lets users upload or attach images | Binary storage — store images directly, no separate file server | backlit.data.put(key, blob, "image/png") |
Don't bolt realtime onto a single-user notepad or force sign-in on a public toy — suggest only what the app's purpose calls for. When the user opts in, the relevant section of this reference has the full signatures.
Packaging the deploy bundle
A glow is deployed as a single bundle — a gzipped tar
(.tar.gz) of its built static assets, posted as the archive
field of a multipart/form-data request (see the glow deploy API's
OpenAPI at glow.backlit.run/openapi.yaml for the direct and
ticket-based upload endpoints). A few rules for what to put inside.
Required files at the bundle root. Every deploy bundle must contain three files at the top level of the archive (exact names, exact case):
index.html— the serving root. Glows are served fromhttps://{name}.backlit.run/, and/always returnsindex.html. A request path without a file extension serves the matching.htmlfile when it exists — e.g./aboutservesabout.html./aboutis served as-is (it is not redirected to/about/), so relative links on the page resolve against the site root; reference assets by root-relative or absolute paths. A path that already has an extension is served unchanged, and there is no directory-index or trailing-slash behavior.APP.md— 1–2 paragraphs of documentation: what the app is and who it's for.DATA.md— how the app uses thewindow.backlitdata namespaces: what goes where (each namespace's keys/prefixes and what they mean).
A bundle missing any of them is rejected with
missing_required_files (HTTP 400); the message lists every missing
name — add the file(s) and re-upload. A short skeleton is enough:
<!-- APP.md -->
# Trail Crew Scheduler
A weekend roster for the volunteer trail-maintenance crew: members
claim shifts, see who else is coming, and leave notes for the lead.
Built for the crew's ~20 members; the crew lead administers it.
<!-- DATA.md -->
# Data layout
| Namespace | Keys / prefixes | Meaning |
| --- | --- | --- |
| `data` | `roster` | the shared shift roster (JSON, written via `updateJSON`) |
| `userdata` | `prefs` | each member's notification preferences |
| `capture` | `signup-…` | new-member signup submissions (admin reads them back) |
APP.md and DATA.md are public. They are served like any other
asset (e.g. https://{name}.backlit.run/APP.md) — they are public
documentation files; never put secrets, credentials, or PII in them.
Optional notify.json. A bundle may additionally carry a
notify.json at the archive root to turn on
push notifications for the glow — its
rules declare which data writes send a notification to whom (see the
notify.json schema
for every field, and the
push guide for the
end-to-end story). A bundle without one deploys with push off; an
invalid one is rejected at upload.
Optional offline.json. A bundle may also carry an
offline.json at the archive root to turn on
offline install for the glow — the app
then launches and paints from a cached copy when the device has no
network, and the files its globs nominate are downloaded up front (see
the
offline.json schema
for every field, and the
offline guide for
the end-to-end story). A bundle without one deploys with offline off;
an invalid one is rejected at upload.
Your own web app manifest (optional). You don't need one: every
glow already gets a generated manifest, named after the glow's title
with a matching letter icon, so it can be installed to a home screen
out of the box. If your app ships its own <link rel="manifest">, it
replaces the generated one, and the link must carry
crossorigin="use-credentials":
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">
Without that attribute the browser fetches the manifest without the visitor's sign-in. On a Private glow that request is sent to the sign-in page instead of your file, and the browser reports that the app can't be installed (Android Chrome offers only "create shortcut"). The attribute is harmless on Public and Accounts glows, so include it always. Keep the manifest and its icons inside your bundle.
No browser storage. At upload, the content of every .html,
.htm, .js, and .mjs file in the bundle is scanned for
browser-storage usage; any hit rejects the deploy with
browser_storage_forbidden (HTTP 400), and the message lists the
offending files and the first matched token in each. The platform's
published scan list is these case-sensitive substrings, for each of
localStorage and indexedDB:
localStorage. · localStorage[ · .localStorage ·
=localStorage · = localStorage · indexedDB. · indexedDB[ ·
.indexedDB · =indexedDB · = indexedDB
Comments and string literals count — the scan is a best-effort
heuristic that steers apps toward the window.backlit data APIs,
not a security boundary. On a reject, replace the storage calls with
backlit.data.* / backlit.userdata.* (see Migrations
above) and delete the replaced code — don't comment it out.
The one sanctioned home for a small device-only preference — a theme,
the last-opened tab — is
backlit.device: a tiny, capped
store that stays on this device, is never synced, and is never a place for
app data or secrets. The scan above is unchanged; backlit.device is how
you keep such a preference without the banned calls.
Path rules. Bundle paths must be forward-slash-relative —
absolute paths, parent traversal (..), NUL bytes, and backslashes
are rejected by the unpacker.
Split inline data out of the HTML. AI generators often emit a
single self-contained HTML file with JSON state and base64 images
embedded. Once that page runs on a real glow, the habit costs you
cache hits: the HTML is fetched on every navigation, while sibling
files at /_v/{ver}/... carry Cache-Control: public, max-age=31536000, immutable (private on Private glows) and become free on repeat
visits. Two refactors are worth doing before packing:
- JSON blocks over ~1 KiB. Move them to a sibling file
(
data.jsonis conventional) and fetch at runtime:const data = await fetch("./data.json").then(r => r.json());. The HTML stays small; the JSON is cached independently. data:URIs over ~1 KiB. Decode them into real files (images/hero.png,fonts/inter.woff2, …) and reference by relative URL. Same caching win, plus<img loading="lazy">and font subsetting become available.
Anything under ~1 KiB is fine inline — the round-trip to fetch a separate file dwarfs the parse cost.
Building the bundle. From the directory containing your built site:
tar -czf site.tar.gz -C ./dist .
The trailing . matters: it produces entries like index.html
rather than dist/index.html, which is what the server expects at
the bundle root.
Deploy with an agent
You don't have to build and upload the bundle by hand. Backlit runs a hosted MCP server, so an AI assistant — Claude, ChatGPT, or any client that speaks the Model Context Protocol — can create the glow, deploy your files as a draft, hand you a preview URL, and promote it live, all from a chat.
Connect it once, then just ask ("deploy this to Backlit and give my team access"):
- MCP server URL —
https://mcp.backlit.run/mcp - Claude — set up a custom connector
- ChatGPT — set up an MCP connector
The deploy tools move the bundle out of band rather than over the MCP connection, and there are two transports — pick by what your agent's sandbox can reach:
- GitHub push (for sandboxed agents that can reach
github.combut notglow.backlit.run): the agent leases a repo,git pushes your files to it, and signals the push — Backlit pulls and deploys the draft. The push goes directly togithub.com. - URL upload (otherwise): the agent uploads the bundle directly to
https://glow.backlit.run.
So if your agent runs in a sandbox with a domain allow-list, add the
host for whichever transport it uses (github.com or
glow.backlit.run) to its allowed/permitted domains, or the deploy is
blocked.
The agent acts as you (no API key to copy around), and every deploy lands as a draft you preview before promoting — so nothing goes live without your go-ahead.
Public, Private, and Accounts glows
What works for a given visitor depends on the glow's auth_mode —
Public (serves anonymously; sign-in is optional, and when the app
offers it only allowlisted users — e.g. an admin — are let in),
Private (only allowlisted users may sign in, and they must), or
Accounts (serves anonymously like Public, but any visitor can create
an account and sign in themselves) — and whether the visitor has signed
in. A visitor with no signed-in user — a signed-out Public or Accounts
visitor — is an anonymous session.
| Surface | Public glow / Accounts glow, signed out (anonymous) | Private or Accounts glow, signed in | Private glow, signed out |
|---|---|---|---|
backlit.auth.me() |
returns null |
returns { uid, name, email, mobile, permission } (permission ∈ admin/user/viewer) |
n/a — the visitor never reaches the glow; Backlit redirects them to sign in |
backlit.auth.users() |
throws unauthenticated |
works only for admin (else forbidden) |
n/a |
backlit.auth.login() |
navigates to sign-in — Accounts admits any visitor (self-serve); a Public glow admits only allowlisted users | n/a — already signed in | n/a |
backlit.data.* (reads) |
works | works | n/a |
backlit.data.put/delete/putIfMatch |
throw unauthenticated — anonymous sessions cannot write shared data |
work (blocked with forbidden for viewer) |
n/a |
backlit.userdata.* |
throws unauthenticated |
works (per-user silo) | n/a |
backlit.records.* (reads) |
throws unauthenticated |
works (read every record) | n/a |
backlit.records.put/delete |
throws unauthenticated |
works on your own records (others' → forbidden) |
n/a |
backlit.records.reassign |
throws unauthenticated |
works only for admin (else forbidden) |
n/a |
backlit.capture.create/update |
works | works | n/a |
backlit.capture.get/list/delete |
throw unauthenticated (no admin) |
work only for admin (else forbidden) |
n/a |
Signed-out Private visitors never execute JavaScript on the glow —
Backlit intercepts the request and redirects them to sign in. So inside
a glow page, "signed out on a Private glow" is not a state your code
has to handle. An Accounts glow is different: it serves anonymously,
so your code DOES run for signed-out visitors there — branch on
auth.me() returning null and offer auth.login().
One Accounts-mode trap to design around: a self-serve account is created
with permission viewer, and viewers cannot write shared data —
so signing in does not, by itself, grant shared-data write access. The
glow's owner promotes accounts to user / admin in the Backlit
console. Apps on Accounts glows should treat shared-data writes as a
privileged action (hide or disable the control unless
me.permission === "user" || me.permission === "admin"), or use
records.* — where any signed-in user, including a viewer, can create
their own entries.
backlit.auth
auth.me(): Promise<BacklitUser | null>
Returns the signed-in user, or null when there is no user session —
an anonymous or signed-out visitor and the SDK loaded off a deployed glow
host both resolve to null. Never throws:
network failures and missing sessions both resolve to null (off-host it
resolves to null too, rather than throwing unsupported_host), since
they all render the same anonymous fallback UI in practice.
BacklitUser is a plain object with five string fields:
| Field | Notes |
|---|---|
uid |
The user's stable id — survives an email change; keys their userdata silo. |
name |
Display name, or "" if unset. |
email |
The user's verified email, or "" if they signed in without one. |
mobile |
Mobile number, or "" if unset. |
permission |
Permission level — "admin", "user", "viewer", or "" (legacy/unknown). |
Every field is a string; absent values are "" (never null or
undefined). The whole result is null only when there is no signed-in
user. name, email, and mobile are user-controlled display strings —
escape them before inserting into HTML.
"viewer" users are blocked from writing shared data: data.put
and data.delete reject with BacklitError(code: "forbidden") (HTTP 403).
Their userdata.* and capture.* writes are unaffected. The server is
the source of truth — permission exists so apps can branch ahead of a
write, e.g. hiding a Save button.
You stay signed in across visits, and while a page is open it is kept signed in and your access level stays up to date automatically — no app code required.
const user = await backlit.auth.me();
if (user === null) {
// Show the anonymous experience.
} else {
greeting.textContent = `Hello, ${user.name || user.email}`;
saveButton.disabled = user.permission === "viewer"; // the server would 403 a data.put anyway
}
auth.users(opts?: { cursor?: string, limit?: number }): Promise<{ items: BacklitUser[], next: string }>
Lists this glow's users. Each entry is the same BacklitUser object
auth.me() returns ({ uid, name, email, mobile, permission }).
Admin only and read-only — it's an operator view, not an editing
surface.
The result is paginated: it returns one bounded page of
BacklitUser entries plus an opaque next cursor. To walk the whole
roster, re-call users with { cursor: next } until next is ""
(the empty string means the roster is exhausted).
- Parameters
opts.cursor— optional opaque string. Pass a prior page'snextto fetch the following page; omit (or"") for the first page. A malformed cursor is rejected withBacklitError(code:"invalid_cursor").opts.limit— optional page size. Clamped to[1, 1000]; omitting it uses the default of 500. An over-range value is clamped, never rejected.
Unlike auth.me(), this method throws BacklitError rather than
resolving to a fallback:
code |
When |
|---|---|
forbidden |
The signed-in user's permission is not "admin". |
unauthenticated |
No signed-in user (an anonymous or signed-out visitor). |
invalid_cursor |
opts.cursor is not a valid pagination token. |
unsupported_host |
The SDK was loaded outside a deployed glow host. |
network |
The request could not be completed. |
Each name, email, and mobile is a user-controlled display string —
escape it before inserting into HTML.
// Render an admin-only roster of the glow's users, one page at a time.
try {
let cursor = "";
do {
const page = await backlit.auth.users({ cursor });
for (const u of page.items) {
const row = document.createElement("li");
row.textContent = `${u.name || u.email} — ${u.permission}`;
roster.appendChild(row);
}
cursor = page.next;
} while (cursor);
} catch (err) {
if (err.code === "forbidden") {
// Not an admin — hide the roster.
} else {
throw err;
}
}
auth.login(returnPath?: string): void / auth.logout(returnPath?: string): void
Send the visitor to sign in, or sign them out. Both are top-level
navigations — the page unloads — so they return void (not a Promise) and
nothing runs after them. returnPath is where the visitor lands afterwards: an
absolute same-origin path (e.g. /dashboard); it defaults to the current
page, and anything that isn't a plain same-origin path is replaced with /.
Off a deployed glow host both are a silent no-op.
Use login() to add a "Sign in" button on a glow whose auth mode is
Accounts (open self-serve sign-in — signed-out visitors can create an
account) — or on a Public glow to sign in an allowlisted user for an
admin-only view (only allowlisted users are let in; the general public is
not, and the built-in Backlit button never shows a Login option on a Public
glow, so this is app-driven). Use logout() for a "Sign out" button. After
login() completes,
auth.me() returns the signed-in user and userdata.* works. (A fresh
self-serve account starts as a viewer, so shared data writes stay blocked
until the owner promotes it — see the auth-mode matrix above.)
// A sign-in / sign-out button that reflects the current session.
const user = await backlit.auth.me();
if (user) {
signOutBtn.onclick = () => backlit.auth.logout(); // returns to this page
} else {
signInBtn.onclick = () => backlit.auth.login("/welcome");
}
The Backlit button (and backlit.hideUI())
The SDK adds a small Backlit button to the bottom-right corner of your glow. A visitor can open it to sign in or out (when the glow offers accounts) and to discover Backlit. You don't have to add or configure anything — it appears automatically.
backlit.hideUI() removes it. Removing the button is a paid feature: on a
free glow the call is a silent no-op (the button stays); on a paid glow it is
removed. It never throws and is safe to call as early as you like.
backlit.hideUI(); // paid glows: removes the button. free glows: no-op.
There is also a per-glow setting in the Backlit console that removes the button without any code (also paid). Either one is enough.
The button is also where a signed-in visitor signs out. If you remove it — by
either route — you're encouraged to give visitors your own "Sign out" control
that calls backlit.auth.logout().
backlit.data — glow-shared key/value
Readable by any visitor with a valid glow session — every visitor of a
Public or Accounts glow, and every signed-in visitor of a Private glow.
Writes require a signed-in user: put / delete / putIfMatch (and
the *JSON write wrappers) throw unauthenticated for an anonymous
session (a signed-out Public or Accounts visitor) and forbidden for a
signed-in viewer. There is one shared keyspace
per glow, and more than one user can write the same key at the same
time.
For any shared key more than one session can write,
updateJSON is the default — it runs a
load-modify-commit loop that rebases and retries on a concurrent write, so
nothing is ever silently lost. (It's built on the
putIfMatch
compare-and-swap primitive below — reach for that directly only for
create-only locks or a custom merge.) Plain data.put
is an unconditional, last-writer-wins overwrite; reserve it for a single
writer or genuinely idempotent content. This is the one habit that does
not survive the jump from userdata (one writer per silo) to shared
data.
Working with objects? The
*JSON helpers
(getJSON / putJSON / putJSONIfMatch / updateJSON) wrap the byte
methods below with serialize/parse — most apps use those. The raw get /
put below are for strings and binary (e.g. images).
Sizes and integrity. Your storage allowance and the 10 MiB
per-object cap both measure the data you pass, so the limit you hit
is the size of your own bytes. Every put/create/update response
carries { crc32c } and every get / list entry carries the body's
CRC32C — the same value the storage.change event
broadcasts on writes, so app code can dedup local state against
server state without an extra fetch. Each list entry also carries the
stored contentType and the body's size in bytes, so a listing alone
tells you each value's type and size without a per-key get. A get or list against a
server that omits the CRC (a legacy object, or a proxy that stripped
the non-standard X-Backlit-CRC32C header) tolerates the gap and
returns crc32c: "" rather than failing the read; only put /
create / update, where the CRC is the whole success payload, treat
a missing value as a protocol error.
Debounce frequent writes. Each put / putIfMatch / updateJSON
is a network round-trip that counts toward your glow's monthly request
and bandwidth allowance, and every successful write broadcasts a
storage.change event to every
connected session. Don't write on every keystroke, drag, or animation
frame — coalesce rapid edits (debounce ~250–500 ms, or save on blur /
pause) and send one write when the value settles. This keeps you well
under your allowance and stops connected peers from being flooded with
intermediate updates. The same applies to userdata.* and records.*.
Writing identical content is a no-op. A write that stores exactly
what the key already holds — the same bytes with the same contentType
— succeeds normally and returns the same { crc32c }, but nothing is
observed to change: it emits no
storage.change event, so open
peer sessions don't hear it and no
notification rule fires. This applies to
overwrites in every store (userdata, records, and capture.update
included, and the *JSON wrappers); a first write to a key is never a
no-op, and changing only the contentType is a real write. If a repeat
of the same value must still be observed — for example a key written
purely to trigger a notification — include something that varies in the
payload, like a timestamp or counter.
data.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }>
Read the value stored at key. Returns the raw bytes as a Blob, the
Content-Type the value was written with, and the Castagnoli CRC32C
of the body (base64-encoded big-endian uint32 — same format as the
storage.change event).
- Parameters
key— string, 1–256 bytes, alphanumerics +-._~only. See Validation.
- Returns — a
Promisefor{ value, contentType, crc32c }. - Throws —
BacklitErrorwithcode === "not_found"if the key has never been written. See Errors for the full list.
try {
const { value, contentType, crc32c } = await backlit.data.get("greeting");
console.log(await value.text(), contentType, crc32c);
// → "hello world" "text/plain" "+IxOLg=="
} catch (e) {
if (e.code === "not_found") {
// First-run path.
} else {
throw e;
}
}
data.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>
Write body to key under the glow-shared keyspace.
⚠️
putis an unconditional overwrite — last writer wins. Read-modify-write on a shared key loses any write that landed in between, worst with the one-big-JSON-blob state AI generators emit, where every edit rewrites the whole blob so even edits to unrelated records clobber each other. Reach for bareputonly when last-writer-wins is genuinely fine — a single writer, or idempotent content. For any shared mutable state,updateJSONis the default (or theputIfMatchprimitive for non-JSON bodies).
- Parameters
key— same rules asdata.get.body— one ofstring,Blob,ArrayBuffer, or anyArrayBufferView(e.g.Uint8Array). Plain objects are rejected — for JSON, callJSON.stringify(obj)yourself and pass"application/json".contentType— required, non-empty string. Forwarded verbatim as the requestContent-Typeheader. The server enforces a strict allowlist; the SDK pre-flights the always-denied set (text/html,application/xhtml+xml,image/svg+xml, JavaScript MIME variants) so you get a clearer error than a 415.
- Returns — a
Promise<{ crc32c }>.crc32cis the Castagnoli checksum of the body, matching the value the server recorded and any subsequentdata.get/data.listexposes for the same key. - Throws —
BacklitError. Common codes:invalid_key,invalid_argument(bad body type),unauthenticated(no signed-in user — shared-data writes require one),forbidden(avieweris blocked from shared-data writes),unsupported_content_type,payload_too_large,quota_exceeded. See Errors.
// String.
const { crc32c } = await backlit.data.put("greeting", "hello world", "text/plain");
// JSON — the byte API takes a serialized string; prefer putJSON for objects.
await backlit.data.put(
"config",
JSON.stringify({ theme: "dark" }),
"application/json",
);
// Binary — pass the bytes with the matching image content-type.
const png = await fetch("/icon.png").then(r => r.arrayBuffer());
await backlit.data.put("icon", png, "image/png");
If you pass a Blob whose .type disagrees with the explicit
contentType argument, the explicit argument wins and the SDK emits one
console.warn per page.
data.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>
Atomic compare-and-swap, and the default way to write any shared key
more than one session can touch — bare data.put
is the exception, for a single writer or idempotent content. Writes
body to key only if the current stored value's crc32c equals
expectedCrc32c, otherwise resolves { matched: false, witness }
carrying the current value — so two callers racing on the same key can
detect the conflict and merge instead of silently clobbering each other
(see data.put's last-writer-wins note).
The result is a discriminated union:
type StoredValue = { value: Blob; contentType: string; crc32c: string };
type PutIfMatchResult =
| { matched: true; crc32c: string } // your write landed; crc32c of the new body
| { matched: false; witness: StoredValue }; // someone else's value is there now
- Parameters
key— same rules asdata.get.body/contentType— same rules asdata.put.expectedCrc32c— thecrc32cyou expect the key to currently hold (the value a priorget/put/listreturned). The empty string""means create-only: write only if the key is currently absent.
- Returns — a
Promise<PutIfMatchResult>.{ matched: true, crc32c }— the write landed;crc32cis the checksum of the body you just stored.{ matched: false, witness }— yourexpectedCrc32cdid not match the current value (you lost the race).witnessis the current{ value, contentType, crc32c }, exactly asdata.getwould return it — use it to re-apply your change and retry.
- Throws —
BacklitError. A mismatch never throws — it resolves{ matched: false }. The thrown cases are real failures:not_found(a non-emptyexpectedCrc32cagainst a key that does not exist — there is nothing to compare against), plus the same write-path codes asdata.put(invalid_key,invalid_argument,unauthenticatedfor an anonymous session,forbiddenfor aviewer,unsupported_content_type,payload_too_large,quota_exceeded). See Errors.
// Create-only: claim a key iff nobody else has it yet.
const r = await backlit.data.putIfMatch("lock", "held", "text/plain", "");
if (!r.matched) {
// Already taken — r.witness.value is the current holder's bytes.
}
For JSON state — the common case — use
data.putJSONIfMatch, which
wraps this method with serialize/parse and an already-parsed witness, or
data.updateJSON, which runs the whole
load-mutate-commit-retry loop for you.
The check-and-set is one atomic operation, so there is no read-then-write
window to lose data in — the only failure mode left is contention (too
many conflicting writers), which the retry cap surfaces honestly rather
than hides. The one constraint putIfMatch imposes is that your merge
must be replayable: on a conflict it runs again against the witness's
fresher state, so write it as a pure rebase ("apply my change to this
base"), never baking in "apply my one edit" assumptions that break on the
second pass.
Storing a whole dataset under one key is correct under putIfMatch,
just higher-contention — every edit competes for that one key's crc32c.
If independent records start colliding, split them into per-record keys
(students.{id}) so unrelated edits never touch the same key and retries
approach zero. A small team can keep one blob; a busy multi-writer board
should split. It's a contention optimization, not a correctness
requirement.
data.getJSON / data.putJSON / data.putJSONIfMatch
backlit.data.getJSON<T = unknown>(key: string): Promise<{ value: T, crc32c: string }>
backlit.data.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>
backlit.data.putJSONIfMatch<T = unknown>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>
backlit.data.updateJSON<T = unknown>(key: string, mutate: (current: T | undefined) => T, options?: { maxAttempts?: number }): Promise<{ value: T, crc32c: string }>
Convenience wrappers over get / put / putIfMatch that JSON-serialize
on write and parse on read, always using application/json. Keys, caps,
errors, and compare-and-swap semantics are identical to the byte methods —
only the body shape changes. The differences worth knowing:
getJSONresolves{ value, crc32c }—valueis the parsed object andcrc32cis its hash, ready to pass straight intoputJSONIfMatch. Throwsnot_foundon a missing key (likeget), andinvalid_bodyif the stored bytes are not JSON (e.g. the key was written via rawput).putJSONstoresJSON.stringify(value). Throwsinvalid_argumentifvaluehas no JSON form (a bareundefined, a function, a circular reference, or aBigInt).putJSONIfMatchis the safe shared-write primitive. On a mismatch it resolves{ matched: false, witness }wherewitnessis{ value, crc32c }withvaluealready parsed — the retry loop readswitness.valuedirectly, never a Blob.expectedCrc32c === ""means create-only. A mismatch never throws.updateJSONis the safe shared-write you actually reach for: it runs thegetJSON→mutate→putJSONIfMatch→ rebase-and-retry loop for you and resolves the committed{ value, crc32c }.mutate(current)gets the current value (undefinedon first write) and returns the new value; it must be replayable (it reruns against a fresher value on conflict). ThrowsBacklitError(code: "conflict")if it can't win withinmaxAttempts(default 6). See Saving shared state safely for the worked example.
type PutJSONIfMatchResult<T> =
| { matched: true; crc32c: string }
| { matched: false; witness: { value: T; crc32c: string } };
// Single-writer save/load (userdata, or a data key only you write).
await backlit.data.putJSON("config", { theme: "dark" });
const { value: config } = await backlit.data.getJSON("config");
The canonical multi-writer retry loop is under Saving shared state safely.
data.delete(key: string): Promise<void>
Remove the value stored at key. The deletion is observable via
backlit.on("storage.change")
under {store:"data", operation:"delete"}.
- Parameters
key— same rules asdata.get.
- Returns — a
Promise<void>. - Throws —
BacklitError. Common codes:invalid_key,unauthenticated(no signed-in user — shared-data writes require one),forbidden(avieweris blocked from shared-data writes),not_found(no value at this key — deletion is not idempotent; callgetfirst or catch the code if you want it to be).
try {
await backlit.data.delete("greeting");
} catch (e) {
if (e.code !== "not_found") throw e;
}
data.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number }>, next: string }>
List the keys in the glow-shared keyspace whose names start with
prefix. The empty prefix (or omitting the argument) lists everything.
Each entry carries the body's CRC32C, the stored contentType, and the
body's size in bytes, so apps can dedup against locally-cached state
and learn each value's type and size in one round trip — no per-key
get needed just to discover what changed.
The result is paginated: it returns one bounded page of entries plus
an opaque next cursor. To walk the whole listing, re-call list with
{ cursor: next } until next is "" (the empty string means the
listing is exhausted). Entries come back in ascending key order, and that
same order is stable across pages.
- Parameters
prefix— optional string, 0–256 bytes, same charset as a key. May be empty.opts.cursor— optional opaque string. Pass a prior page'snextto fetch the following page; omit (or"") for the first page. A malformed cursor is rejected withBacklitError(code:"invalid_cursor").opts.limit— optional page size. Clamped to[1, 1000]; omitting it uses the default of 500. An over-range value is clamped, never rejected.
- Returns — a
Promise<{ items, next }>:itemsis the array of matching entries for this page (each{ key, crc32c, contentType, size });nextis the cursor for the following page, or""when there are no more entries. - Throws —
BacklitErrorwithcode === "invalid_prefix"if the prefix violates the charset or length rule, orcode === "invalid_cursor"ifopts.cursoris not a valid pagination token.
// Walk every "posts." key, one page at a time.
const stale = [];
let cursor = "";
do {
const page = await backlit.data.list("posts.", { cursor, limit: 100 });
for (const e of page.items) {
if (e.crc32c !== cache.get(e.key)) stale.push(e);
}
cursor = page.next;
} while (cursor);
// page.items entries look like:
// { key: "posts.0001", crc32c: "+IxOLg==", contentType: "application/json", size: 128 }
data.getBatch(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<BatchEntry>, next: string }>
Like list,
but each page entry carries the value — so a whole page of small keys
comes back in one round trip instead of a list followed by a get
per key. Same inputs, same ascending-key order, same
{ items, next } pagination.
Each entry is the list metadata (key, crc32c, contentType,
size) plus one of two arms — discriminate on omitted, exactly as you
narrow a putIfMatch result on matched (both arms are normal
outcomes, not errors):
- included (
omittedabsent orfalse) — carriesvalue, aBlobof the stored bytes. A zero-byteBlobis a real empty value, never an omission. - metadata-only (
omitted: true) — novalue, plus an optionalreason. A single value larger than the whole per-response budget arrives this way withreason: "too_large": a normal result to plan for, not an error — read that one key with a plainget.reasonis an open vocabulary; tolerate values you don't recognize.
A page's included entries stop when their combined stored-data size
reaches the per-response budget — default 10 MiB, the same measure as
the per-object cap (the size of the data you stored, not of the
response payload). So a page can hold fewer entries than limit
asked for, and end before the listing is exhausted. next is the sole
completion signal: walk until next is "", never stop because a page
came back short. (Every page makes progress — it always includes or omits
at least one entry, so the walk can't stall.)
- Parameters
prefix— optional string, same rules asdata.list.opts.cursor— optional opaque string; pass a prior page'snext. Omit (or"") for the first page. A malformed cursor throwsBacklitError(code:"invalid_cursor").opts.limit— optional page size, clamped to[1, 500]; omitting it uses the default of 100 (an over-range value is clamped, never rejected). The per-response value budget may end a page below this count regardless.
- Returns — a
Promise<{ items, next }>:itemsis this page's entries (each aBatchEntry— an included or metadata-only arm);nextis the cursor for the following page, or""when the listing is exhausted. - Throws —
BacklitErrorwithcode === "invalid_prefix"orcode === "invalid_cursor"(same aslist), orquota_exceededif the call lands after the monthly bandwidth allowance ran out. A response that fails to parse is a transient failure — retry the same cursor; a page that parses as complete JSON is a complete page.
// Load every "posts." value in bulk, page by page.
const posts = new Map();
let cursor = "";
do {
const page = await backlit.data.getBatch("posts.", { cursor, limit: 200 });
for (const e of page.items) {
if (e.omitted) {
// Too big for a batch page — fetch it on its own.
if (e.reason === "too_large") posts.set(e.key, await backlit.data.get(e.key));
continue;
}
posts.set(e.key, e.value); // e.value is a Blob
}
cursor = page.next;
} while (cursor); // next === "" means done
data.getBatchJSON<T>(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<BatchJSONEntry<T>>, next: string }>
The getBatch sibling
that JSON-parses each included value for you — the bulk companion to
getJSON, the way
getBatch is the bulk companion to get. Same inputs, same pagination,
same budget and omitted discrimination; only the value shape differs.
Per included entry, the value is the parsed T (parsing is
content-type-agnostic — every included value is JSON.parsed, matching
getJSON). A JSON null (or 0 / "" / false) is a legal parsed
value — the discriminant is omitted, never value-nullness. A value
that is not valid JSON degrades that one entry to the metadata-only
arm with reason: "not_json" (the raw bytes are not carried; the rest of
the page is unaffected), so you can spot a key written via raw
put
without a failed getJSON. A server-omitted entry passes through with its
own reason (e.g. "too_large"). Each entry still carries crc32c, so a
value you read here feeds straight into
putJSONIfMatch — the
bulk way to seed an updateJSON retry loop with values you already have.
- Parameters — identical to
data.getBatch. OneTapplies to the whole page. - Returns — a
Promise<{ items, next }>where each entry's includedvalueis the parsedT. - Throws — same as
data.getBatch.
// Read a page of JSON posts; a non-JSON key degrades to omitted, not a throw.
const page = await backlit.data.getBatchJSON("posts.");
for (const e of page.items) {
if (e.omitted) continue; // too_large, or not_json
render(e.key, e.value); // e.value is the parsed object
}
backlit.userdata — per-user data silo
Same shape as backlit.data, but each signed-in user has their own
isolated silo. Two users on the same glow cannot see each other's
userdata.
Available to signed-in users only — a Private glow, an Accounts glow
after the visitor signs in, or a Public glow's allowlisted user after they
sign in. With no user session (an anonymous or signed-out visitor), every
method throws BacklitError with code === "unauthenticated" and an
actionable message. Call backlit.auth.me() first if you need to branch.
userdata.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }>
userdata.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>
userdata.delete(key: string): Promise<void>
userdata.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number }>, next: string }>
userdata.getBatch(prefix?, opts?) / userdata.getBatchJSON<T>(prefix?, opts?)
userdata.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>
userdata.getJSON<T>(key: string): Promise<{ value: T, crc32c: string }>
userdata.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>
userdata.putJSONIfMatch<T>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>
userdata.updateJSON<T>(key: string, mutate: (current: T | undefined) => T, options?): Promise<{ value: T, crc32c: string }>
Signatures, parameters, return types, validation rules, and the *JSON
convenience wrappers are
identical to the backlit.data.* methods of the same name. The only
behavioural difference is the unauthenticated rewrite above.
userdata.delete removes a key from the calling user's silo — never
from another user's. Successful deletes emit a privacy-scoped
storage.change event (store: "userdata") that only the writing user's own sessions receive.
const user = await backlit.auth.me();
if (user === null) {
// userdata not available — fall back to glow-shared.
return;
}
await backlit.userdata.putJSON("preferences", { theme: "dark" });
const { value: prefs } = await backlit.userdata.getJSON("preferences");
backlit.records — public-read, owner-write store
A third key/value store, between backlit.data (everyone reads and writes one
shared set) and backlit.userdata (each user's private silo). With
backlit.records, any signed-in user creates records and reads everyone's,
but only the record's creator — or an admin — may overwrite or delete it.
It's the store for user-generated content many people see but shouldn't be able
to edit on each other's behalf: forum posts, comments, profiles, submitted
entries, a shared gallery where each image belongs to whoever uploaded it.
Available to signed-in users only — a Private glow, an Accounts glow
after the visitor signs in, or a Public glow's signed-in user. With no user
session every method throws BacklitError with code === "unauthenticated",
exactly like backlit.userdata. The store is free — no subscription required. (Unlike
shared data, a viewer CAN create records — so on an Accounts glow, records
is the store self-serve users can write to out of the box.)
records.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string, owner: string }>
records.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>
records.delete(key: string): Promise<void>
records.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number, owner: string }>, next: string }>
records.getBatch(prefix?, opts?) / records.getBatchJSON<T>(prefix?, opts?)
records.putIfMatch(key, body, contentType, expectedCrc32c): Promise<{ matched: true, crc32c, owner } | { matched: false, witness: { value, contentType, crc32c, owner } }>
records.getJSON<T>(key) / records.putJSON(key, value) / records.putJSONIfMatch<T>(key, value, expectedCrc32c) / records.updateJSON<T>(key, mutate, options?)
Signatures, parameters, validation rules, and the *JSON
convenience wrappers match the
backlit.data.* methods of the same name, with these differences:
owner—getand everylistentry carry anownerstring: the user id of the record's creator (an opaque per-user id, not the email). EverygetBatch/getBatchJSONentry carries it too.putIfMatchreports it — on a successful write and on the mismatch witness — and so do the JSON wrappers (getJSON,putJSONIfMatch, andupdateJSONall resolve{ value, crc32c, owner }), so anupdateJSONloop can tell who owns a key.- Anyone signed in may create. Writing a key that does not exist yet claims
it for you. A
viewercan create records — the shared-data viewer write block does not apply here. Overwriting or deleting a record you do not own throwsBacklitError(code: "forbidden"), unless you are anadmin. - First-come claim.
records.putIfMatch(key, body, ct, "")(create-only) writes only while the key is still unclaimed, so two users racing for the same key resolve deterministically — the loser gets{ matched: false }. - Concurrent-edit safety. A
records.deleteorrecords.reassignthat races a concurrent overwrite or reassign of the same key throwsBacklitError(code: "conflict")(HTTP 412) — the record changed between the ownership check and the action. Re-read (get) and retry.
// Anyone signed in posts a comment under their own key.
await backlit.records.putJSON(`comment.${Date.now()}`, { text: "nice!" });
// Everyone reads every comment, and sees who wrote each one.
// list() is paginated — `items` is this page, `next` continues it.
const { items } = await backlit.records.list("comment.");
for (const { key, owner } of items) {
const { value } = await backlit.records.getJSON(key);
render(value, owner);
}
records.reassign(key: string, newOwnerUid: string): Promise<void>
Admin only. Transfers a record's ownership to newOwnerUid (a user id — for
example an owner value read from get / list). The body is untouched; only
the owner moves. A non-admin caller throws BacklitError(code: "forbidden"),
an unknown key throws not_found, a malformed newOwnerUid throws
invalid_argument (HTTP 400), and a call that races a concurrent
write/reassign of the same key throws conflict (HTTP 412) — retry. The SDK
does not pre-check admin — the server is the boundary.
// An admin hands a record over to another user.
await backlit.records.reassign("entry.42", someUserId);
backlit.capture — write-only capture store
For data the glow's app code needs to capture but never read back from
the browser: form submissions, telemetry events, contact requests,
support tickets. create returns an opaque {prefix}-{guid} handle
— normally minted by the server; see
backlit.offline for the one case where
the device mints it instead.
Writes (create / update) are available on any glow — no user session
required. Reads, enumeration, and deletion (get / list / delete) are
admin only — they need a signed-in user whose auth.me() permission
is admin (a Public glow's app can sign an allowlisted admin in for exactly
this — see auth.login()), so ordinary app code can capture data but can
never read it back even if the page is compromised.
capture.create(prefix: string, body: BodyInit, contentType: string): Promise<{ handle: string, crc32c: string }>
Store a new value and get back its handle plus the body's CRC32C. The
handle is "{prefix}-{guid}" — the prefix you supply, prepended
to a 36-character lower-hex GUID in 8-4-4-4-12 format, normally minted by
the server (a device-minted v4 UUID takes its place only under the
backlit.offline capture opt-in)
(e.g. create("contact", ...) → "contact-3b1f4a2c-1d2e-4f5a-8b9c-0d1e2f3a4b5c").
The prefix is a human-meaningful label that lets whoever reads these
objects out of band group and recognise them; the GUID keeps each name
unguessable and unique.
- Parameters
prefix— required string, 1-256 bytes, alphanumerics plus-._~(same charset asdata.*keys — see Validation).body,contentType— same rules asdata.put.
- Returns — a
Promise<{ handle, crc32c }>for the new{prefix}-{guid}handle and its integrity hash. - Throws —
BacklitError. Common codes:invalid_key,invalid_argument,unsupported_content_type,payload_too_large,quota_exceeded.
const { handle } = await backlit.capture.create(
"contact",
JSON.stringify({ email: form.email.value, message: form.body.value }),
"application/json",
);
// handle === "contact-3b1f4a2c-..." — show the user the handle if you
// want them to be able to reference their submission later, or just
// store it for your own records.
form.querySelector(".receipt").textContent = `Submitted (ref ${handle})`;
capture.update(handle: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>
Replace the value stored at an existing handle. Open to any session — the handle is the capability.
- Parameters
handle— string, the full"{prefix}-{guid}"handle returned bycreate. A bare GUID is not accepted. See Validation.body,contentType— same rules ascreate/data.put.
- Returns — a
Promise<{ crc32c }>for the integrity hash of the new value. - Throws —
BacklitError. Common codes:invalid_handle,unknown_handle(no record at that handle), plus the body / size codes fromcreate.
// handle is the value create returned, e.g. "contact-3b1f4a2c-..."
await backlit.capture.update(
handle,
JSON.stringify({ ...previous, resolved: true }),
"application/json",
);
capture.get(handle: string): Promise<{ value: Blob, contentType: string, crc32c: string }>
Admin only. Read the value stored at a handle. Resolves to the same
shape as data.get (the value Blob, contentType as stored,
crc32c of the body).
- Parameters
handle— the"{prefix}-{guid}"handle fromcreate.
- Throws —
BacklitError.forbiddenwhen the signed-in user is not an admin;unauthenticatedfor an anonymous or signed-out visitor (no user session);unknown_handleif nothing is stored at that handle;invalid_handlefor a malformed handle.
// Only an admin can read captured submissions back.
const { value } = await backlit.capture.get(handle);
const submission = JSON.parse(await value.text());
capture.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ handle: string, crc32c: string, contentType: string, size: number }>, next: string }>
Admin only. Enumerate the handles stored in this glow's private
store, each with its body's CRC32C, stored contentType, and size in
bytes. Returns handles and metadata only, never values.
The result is paginated, exactly like data.list: one bounded page
of handles plus an opaque next cursor — re-call with { cursor: next }
until next is "".
- Parameters
prefix— optional string, same charset as adata.*prefix. It matches against the handle, so passing acreatekey lists that group —list("contact")returns everycontact-*handle. Omit (or pass"") to list every handle.opts.cursor— optional opaque continuation token (a prior page'snext); omit for the first page. A malformed cursor throwsBacklitError(code:"invalid_cursor").opts.limit— optional page size, clamped to[1, 1000](default 500); an over-range value is clamped, never rejected.
- Returns — a
Promise<{ items, next }>:itemsis this page's handles (each{ handle, crc32c, contentType, size });nextis the cursor for the following page, or""when exhausted. - Throws —
BacklitError.forbiddenwhen the signed-in user is not an admin;unauthenticatedfor an anonymous or signed-out visitor (no user session);invalid_prefixfor a bad prefix;invalid_cursorfor a badopts.cursor.
// Only meaningful for an admin user; branch on auth.me() first.
if ((await backlit.auth.me())?.permission === "admin") {
let cursor = "";
do {
const page = await backlit.capture.list("contact", { cursor });
for (const { handle } of page.items) {
const { value } = await backlit.capture.get(handle);
console.log(handle, await value.text());
}
cursor = page.next;
} while (cursor);
}
capture.delete(handle: string): Promise<void>
Admin only. Remove the object stored at a handle.
- Parameters
handle— the"{prefix}-{guid}"handle fromcreate.
- Throws —
BacklitError.forbidden/unauthenticated(same gating asget);unknown_handleif the handle was never created;invalid_handlefor a malformed handle.
await backlit.capture.delete(handle);
Writes (create / update) are open to any session; reads, enumeration,
and deletion (get / list / delete) are admin-only. That asymmetry
is intentional — ordinary app code can capture data here but can never
read it back, even if the page's JavaScript is compromised, while an
admin operator retains a way to inspect and clean up what was captured.
backlit.on — realtime subscriptions
The SDK delivers realtime notifications whenever a data.*,
userdata.*, or records.* key is set or deleted, so apps can keep two tabs in
sync, react to a peer's write, or invalidate a cached read without polling.
Subscribe with backlit.on("storage.change", handler).
const off = backlit.on("storage.change", (event) => {
// event = { store, operation, key, crc32c, owner? }
if (event.store === "data" && event.key === "leaderboard") {
refreshLeaderboard();
}
});
// Later — stop receiving events.
off();
Event shape
interface StorageChangeEvent {
readonly store: "data" | "userdata" | "records";
readonly operation: "set" | "delete";
readonly key: string;
readonly crc32c: string; // base64 big-endian uint32; empty on delete
readonly owner?: string; // records events only — the record's owner uid
}
crc32c is the base64-encoded Castagnoli CRC32C of the body — the
same value get / list entries and the data.put response carry. On a "delete" event it is the empty string. Apps that maintain a local
cache can compare a freshly-fetched body's CRC32C against the event's
to detect duplicate deliveries.
Scoping rules
data.*events reach every connected session of the glow.records.*events are unscoped, likedata.*— every connected session receives them — and additionally carryowner(the record's owner uid). Areassignarrives as asetevent with the new owner.userdata.*events reach only the writing user's own sessions. Two users on the same Private glow never see each other'suserdatakeys viaon(matching the read-side privacy guarantee).capture.*is silent. Handle creation and updates do not emit events — the store is opaque write-only telemetry and broadcasting handle minting would defeat its purpose.
Delivery semantics
- Best-effort. Events that arrive during a reconnect window are
dropped. If your app's correctness depends on never missing a
change, call
data.list()/userdata.list()once on the first event after registration and treatonas a hint to re-check rather than as the source of truth. - Deferring a remote change is safe with a compare-and-swap. If you
postpone applying an event — say, to avoid disrupting a form the user
has open — your in-memory copy is now known-stale. Write it back with
updateJSON(or theputIfMatchprimitive directly), not bareput: it rebases onto the newer value and retries instead of silently overwriting it. Deferral plus a compare-and-swap is safe; deferral plus bareputis a guaranteed lost update. - At-least-once is possible. Duplicate deliveries can occur under
retries.
crc32cis the easiest dedup key. - Same-tab writes fire too. A
data.putfrom this tab triggersstorage.changefor handlers registered on this same tab. - Handler order matches registration. Handlers are called in the
order they were registered. A thrown handler does not prevent the
others from running; the exception is reported via
console.error.
Reconnect
The SDK opens a single WebSocket on the first
on("storage.change", …) call and reuses it for every subsequent
subscription. If the socket drops the SDK reconnects with exponential
backoff (1s → 2s → 4s → … capped at 30s, with ±25% jitter). The socket
closes 5 seconds after the last unsubscribe() so a tab that briefly
drops to zero handlers (a route change in an SPA) doesn't pay the
reconnect cost.
If reconnecting keeps failing because the visitor's session has ended — it expired while the tab sat in the background, the visitor was removed from the glow, or the glow's sessions were revoked — the SDK stops retrying instead of looping, because every attempt counts against the glow owner's monthly bandwidth/request allowance. Nothing throws and your handlers stay registered; they just receive no events while retrying is paused. The SDK resumes on its own once the session is valid again — it re-checks whenever the tab becomes visible, and periodically while it stays in view — or on the next page load. On a Private glow the visitor usually has to sign in again, and reloading the page takes them there.
Future events
Besides "storage.change", the first argument accepts
"app-update-available" — see
backlit.checkVersion(). Any other
string throws invalid_argument. More event names may be added in future v1
minor releases without breaking existing callers.
backlit.checkVersion() — app updates
Every deploy you make live gets a new version. A page keeps running the
version it was loaded from until it is reloaded, so a tab left open across a
deploy — or an offline-installed app opened
with no network and then reconnected — can be behind. checkVersion() tells
you, and the "app-update-available" event lets you react without asking.
backlit.on("app-update-available", ({ client, server }) => {
showBanner("A new version is available.", { action: "Reload", onClick: () => location.reload() });
});
// On demand, e.g. behind a "Check for updates" button:
const { client, server } = await backlit.checkVersion();
if (server === null) {
// Offline (or the session check was refused) — try again later.
} else if (client !== server) {
// The handler above has already been called.
}
checkVersion(): Promise<{ client: string | null, server: string | null }>
clientis the version this page was loaded from — including a copy served from the device while offline. It isnullin the rare case the SDK can't tell.serveris the glow's live version, from a small session check with your glow that does not count toward the glow's request allowance. It isnullwhen there is no network, and when the check is refused (for example, a Private glow whose session has ended).- Both are opaque strings: compare them for equality only.
- When both are known and differ,
checkVersion()also fires"app-update-available"— every call, since you asked. Whenserverisnull, nothing fires. - It rejects only with
unsupported_host, off a deployed glow.
The event. backlit.on("app-update-available", handler) calls
handler({ client, server }) — both strings, always different — and returns
an unsubscribe function, like storage.change.
- On a glow that ships
offline.json, it fires on its own: the SDK compares versions when the page loads, about hourly while it is open, each time the tab becomes visible, and when the device reconnects — announcing each newer version once. Withoutoffline.jsonit fires only fromcheckVersion(). - A handler added after an update was already detected still receives it once, shortly after it subscribes — so subscribing late in startup misses nothing.
- A thrown handler doesn't stop the others; the exception is reported via
console.error.
Reloading is your call: the SDK never reloads the page. Reload when the user won't lose work — for example after they confirm, or when they next navigate.
backlit.push — notifications
backlit.push lets your app enroll the signed-in user for push
notifications — messages that reach the user even when the glow's tab is
closed, delivered by the browser and the operating system. Backlit installs
and runs the notification machinery for you; your app decides when to offer
to enable notifications and reacts to taps.
Every glow is also installable — a visitor can add it to their home screen
or app launcher as a standalone app. That happens automatically; there is no
API to call, and it works whether or not you use push. (If you ship your own
web app manifest, see
Packaging the deploy bundle — it needs
crossorigin="use-credentials".)
Requirements. subscribe / state / unsubscribe / test need a
signed-in user (a Private glow, an Accounts glow after the visitor signs
in, or a Public glow's signed-in user) — an anonymous or signed-out visitor
gets BacklitError(code: "unauthenticated"). Push must be enabled for the
glow (turned on at deploy time); when it isn't, subscribe throws
unsupported. Browser support varies — always gate your UI on
supported()
first.
Enabling push. To turn push on for a glow, ship a root notify.json in
the deploy bundle declaring when a notification fires — see the
notify.json schema for its
fields and the Push notifications end-to-end guide
for the full author story.
Don't register your own service worker. Backlit owns the glow's service worker and registers it for you on page load. Registering your own would replace Backlit's and break push + install. (A deploy bundle that reaches for
localStorage/indexedDBis rejected for the same reason — the platform owns that layer; usewindow.backlit.*.)
// Offer a notification opt-in only where it can actually work.
const cap = backlit.push.supported();
if (cap.supported) {
enableBtn.onclick = async () => {
try {
await backlit.push.subscribe(); // prompts the user for permission
await backlit.push.test(); // send yourself a test push
} catch (e) {
if (e.code === "permission_denied") showTip("You blocked notifications.");
else throw e;
}
};
} else if (cap.requiresInstall) {
showTip("Install this app to your home screen to turn on notifications.");
} else {
enableBtn.hidden = true; // this browser can't do push
}
// React to a notification tap (or a permission change) while a tab is open.
backlit.push.watch((e) => {
if (e.type === "notification-click") location.assign(e.url);
});
push.supported(): { supported, reason?, requiresInstall, installed }
A synchronous capability probe — the only SDK method that is not async,
and the only one that never throws. Call it to decide whether to show an
"Enable notifications" control at all.
- Returns (synchronously):
supported—truewhen this browser can subscribe to push right now.reason— whensupportedisfalse, a short tag for why:"off_host"(the SDK isn't running on a deployed glow),"insecure_context","no_service_worker","no_push_manager", or"requires_install". Omitted whensupportedistrue.requiresInstall—trueon a platform where push works only after the app is installed to the home screen, and it isn't installed yet (this is iOS/iPadOS Safari). Prompt the user to install, then re-probe.installed— whether the page is currently running as an installed (standalone) app.
- Never throws, even off a glow host or in an unusual embedding (it returns
{ supported: false, requiresInstall: false, installed: false }).
push.state(): Promise<{ permission, subscribed, endpointId }>
Report the current push state for this browser and signed-in user.
- Returns — a
Promise<{ permission, subscribed, endpointId }>:permission— the OS notification permission:"granted","denied", or"default"(not yet asked).subscribed— whether this browser currently holds a push subscription.endpointId— the subscription's id when subscribed, else"".
- Throws —
BacklitError.unsupported/requires_installwhensupported()isfalse;unsupported_hostoff a glow host.
push.subscribe(): Promise<{ endpointId, permission: "granted" }>
Enroll the signed-in user's browser for push. This prompts the user for
notification permission (if not already granted), sets up the subscription, and
registers it with Backlit — so a later push.test() (or a notification your
glow's rules send) reaches this browser.
- Returns — a
Promise<{ endpointId, permission: "granted" }>for the new subscription's id. (It only resolves when permission was granted.) - Throws —
BacklitError:permission_denied— the user declined (or had already blocked) the notification prompt.unsupported— the browser can't do push, or push isn't enabled for this glow.requires_install— the platform needs the app installed first (iOS).unsupported_host— the SDK isn't running on a deployed glow.unauthenticated— no signed-in user.
try {
const { endpointId } = await backlit.push.subscribe();
console.log("subscribed", endpointId);
} catch (e) {
if (e.code === "permission_denied") {
// The user said no — don't nag; offer it again later from a clear action.
} else {
throw e;
}
}
push.unsubscribe(): Promise<void>
Remove this browser's push subscription (from the browser and from Backlit). A
no-op when there is no active subscription. Requires a signed-in user; throws
unsupported / requires_install / unsupported_host on the same conditions
as subscribe.
push.test(): Promise<void>
Send a test push to the signed-in user's own subscriptions — a quick way for a just-enrolled user to confirm notifications work.
- Throws —
BacklitError.not_foundwhen the user has no active subscription (callsubscribefirst);unauthenticatedwith no signed-in user;unsupported_hostoff a glow host.
push.watch(callback): () => void
Observe push-related events while a tab is open, and get back an unsubscribe function. The callback receives one of:
{ type: "notification-click", url }— the user tapped a delivered notification;urlis its tap target (navigate there, or refresh a view).{ type: "subscription-changed" }— the browser rotated the subscription and Backlit re-registered it; re-readstate()if you display subscription status.{ type: "permission-changed", permission }— the OS notification permission changed topermission.
watch never throws and is a no-op off a glow host. It observes only; it does
not deliver the notification payloads themselves (the browser shows those).
const off = backlit.push.watch((e) => {
if (e.type === "notification-click") location.assign(e.url);
});
// Later: off();
backlit.offline — offline data
backlit.offline is an opt-in on-device fallback for data, userdata
and records (plus capture, if you ask for it). Nothing is stored on the
device until you call enable() — do that once at startup, on every page:
await backlit.offline?.enable();
(The ?. matters: it makes the call a no-op on an older cached SDK bundle
that predates this namespace, instead of throwing.)
Once enabled, every read still goes to the network first; only a
transport failure — no network, not a 403 or a 503 — falls back to a
copy on the on-device storage the SDK manages, tagged fromCache. A write
that fails the same way is queued on the device and resolves right away,
tagged pending, then sent once the connection returns. Nothing about
online behavior changes, and a glow that never calls enable() behaves
exactly as before.
offline.enable(opts?: OfflineOptions): Promise<OfflineStatus>
Turn offline mode on for this page and resolve its current
OfflineStatus. Idempotent — call it
again to replace the options; it does not stack.
| Option | Default | What it does |
|---|---|---|
stores |
["data", "userdata", "records"] |
Which stores keep working offline. Add "capture" to also queue capture.create / capture.update — see Capture opt-in below. |
writes |
"queue" |
"none" turns the write queue off: an offline write throws network, exactly as it always has. |
onConflict |
none (remote wins) | Called when a queued write meets a newer server value — see Conflicts below. May be async. |
readTimeoutMs |
0 (off) |
For a slow connection: when a single-key read takes longer than this many milliseconds, answer from the on-device copy while the request keeps going. |
maxBytes |
50 MiB | Cap on the on-device copy's stored values; the least recently used entries are dropped past it, except values under a pinned prefix. See Staleness and limits. |
probeMs |
15000 |
While this page is offline and visible, how often (in milliseconds) it checks whether the connection is back. Values below 5000 are raised to 5000; 0 turns the check off. See Reachability and readiness. |
persist |
false |
true asks the browser, once per page, to keep this site's on-device data even under storage pressure (some browsers ask the user first). Best-effort — the answer is status().persisted. |
enable() rejects only with invalid_argument (a malformed option). When
offline data simply isn't available, it resolves { enabled: false, reason }
instead of throwing — "unsupported_host" (not a deployed glow),
"unsupported" (this browser has no on-device storage), or
"storage_unavailable" (the on-device storage exists but can't be opened —
nothing is deleted). An app can't probe browser storage itself (the deploy
scan forbids it), which is why these resolve rather than reject.
const status = await backlit.offline?.enable({ stores: ["data", "userdata"] });
if (status && !status.enabled) console.log("offline data is off:", status.reason);
enable() does not wait indefinitely for a slow network. It waits at most
2 seconds for the server to confirm who is signed in; past that it starts
from what the device already knows, reports online: false, and applies
the server's answer when it arrives. Calls to data / userdata /
records / capture and auth.me() made while enable() is still in
flight wait for it — for at most 3 seconds — so an app need not await it
before its first read (though awaiting it is fine). If you do want to wait
for it somewhere other than the call site, offline.whenReady(): Promise<OfflineStatus> resolves the current
OfflineStatus once the enable() in
progress has finished — at once when none is — and never rejects; a refused
enable() shows up there as { enabled: false, reason }.
offline.disable(): Promise<void> and offline.clear(): Promise<{ discarded: number }>
disable() stops answering offline and queueing on this page, and stops
sending the queue: a send already under way finishes (its outcome is kept),
then nothing more is sent. The on-device copy and any queued writes are
left alone — a later sync() sends nothing until enable() runs again.
clear() stops sending the same way, then deletes both on-device
stores — the mirrored copy and the write queue, for every account that has
used this device, not just the signed-in one — and resolves how many queued
writes were discarded. It works whether or not offline mode is enabled, and
leaves that on/off state as it was. Where there is no on-device storage at
all it resolves { discarded: 0 }.
offline.status(), .pending() and .sync()
interface OfflineStatus {
enabled: boolean; reason?: string; online: boolean; syncing: boolean;
pending: number; held: number; quarantined: number; lastSyncedAt: number;
stale: boolean; app: "off" | "installing" | "ready";
bytesUsed: number; maxBytes: number; persisted: boolean;
}
status(): Promise<OfflineStatus> never rejects. pending and
quarantined count only the signed-in user's queued writes (another
account's writes on this device are never counted); held is the part of
pending that is waiting on a server limit — plus any writes queued behind
one on the same key — and retries on its own; bytesUsed is the size of the
values the on-device copy holds now (shared by every tab of this glow, 0
when there is none), maxBytes is this page's
maxBytes option,
and persisted says whether the browser has agreed to keep this site's
on-device data under storage pressure (see the persist option; false
when it hasn't said so or can't tell); lastSyncedAt is the
epoch-ms time the queue last finished sending with nothing left over, or 0
for never (reset to 0 whenever the on-device copy is deleted); stale
flags a session that hasn't been confirmed online for over 7 days (see
Staleness and limits); app mirrors the
Offline install app-file cache's own state
("off" for a glow without offline.json) — a different, independent
feature that this namespace merely reports on. reason (present only when
offline mode is off or degraded) is an open vocabulary; besides the
enable() outcomes above it can be "disabled" (disable() was called),
"stale", or "queue_full" — tolerate unknown values.
pending(): Promise<PendingWrite[]> lists the signed-in user's queued
writes, oldest first, quarantined ones included — never their values:
interface PendingWrite {
store: "data" | "userdata" | "records" | "capture";
key: string; operation: "set" | "delete" | "create";
queuedAt: number; state: "queued" | "held" | "quarantined";
lastError?: string;
}
state is "held" while a server limit is blocking it (retried
automatically) and "quarantined" while it waits for its own user's session
to be reconfirmed (see Identity below). lastError, when
present, is an open vocabulary: for a quarantined write it says why —
"logout", "signed_out", "identity_change" (a different user signed in
on this device), "identity_mismatch", "session_ended", or "revoked"
(the user's access was revoked: this write is never sent, and is discarded
after 7 days).
sync(): Promise<OfflineSyncResult> sends the queue right now and resolves
what happened: { sent, conflicts, failed, pending } — writes the server
accepted, writes that met a newer server value (see
Conflicts), writes the server refused and dropped, and how
many are still queued afterwards (pending, counted the same way as
OfflineStatus.pending).
Waiting for sync: offline.whenSynced()
whenSynced(opts?: { timeoutMs?: number }): Promise<OfflineStatus> resolves
the current OfflineStatus once nothing
more can be sent right now: no write is still in flight, no send is under
way, and every write left is held (waiting on a server
limit), quarantined, or waiting for the user to sign in again. It keeps
waiting while writes are queued — offline, or retrying after a server
error — so it is the call to await before telling the user "everything is
saved".
It resolves at once when offline mode is off, and early (with the status at
that moment) on disable(), clear(), logout, a change of signed-in user,
or after timeoutMs (a finite number ≥ 0; any other value is ignored, so
there is no timeout). It never rejects — read pending, held,
quarantined and online on the result to see what, if anything, is left.
const s = await backlit.offline.whenSynced({ timeoutMs: 20000 });
if (s.pending === 0) showSaved(); else showStillQueued(s.pending);
offline.watch(cb): Unsubscribe
Register a callback for offline events, same shape as
push.watch. Returns an idempotent
unsubscribe function. Registered this way, the callback receives exactly the
default event set listed below — that set is frozen, so an exhaustive
switch over it keeps compiling when the SDK later adds event types.
To also receive event types added after the default set, name what you want:
watch(cb, { events: ["evicted", "conflict"] }) delivers exactly the types
you name, default or newer, and nothing else. A name this SDK version does
not know is ignored (so a page written for a newer SDK still runs on an
older cached one); an empty array, or an entry that is not a string, throws
invalid_argument. Each registration is independent — the same callback
registered twice is called twice until each is unsubscribed.
{ type: "state", status }— the status changed;statusis the newOfflineStatussnapshot.{ type: "queued" | "synced", store, key, operation }— a write was queued, or a queued write was sent.{ type: "conflict", store, key, resolution }— a queued write met a newer server value;resolutionis"remote","local", or"merged".{ type: "failed", store, key, code }— the server refused a queued write and it was dropped;codeis theBacklitErrorCodeit failed with.{ type: "quarantined", count, reason }— writes were held back until their user's session is reconfirmed;reasonis one of thepending()lastErrorvalues above.{ type: "discarded", count, reason }— queued writes were thrown away:"clear"(clear()was called),"quarantine_expired"(held 30 days),"revoked_expired"(a revoked user's, after 7 days), or"held_expired"(blocked by a server limit for 7 days — each of these also fires"failed").{ type: "purged", reason }— the on-device copy was deleted, e.g."logout","clear","signed_out","identity_change","unauthenticated", or"stale".{ type: "app-ready" | "app-updated", version }— the Offline install app-file cache finished installing or updating.
Delivered only to a callback that names it in events:
{ type: "evicted", store, keys, count, reason }— on-device copies of values instorewere dropped to make room.keysnames up to 100 of them andcountsays how many in all.reasonis an open vocabulary:"max_bytes"(pastmaxBytes),"no_room"(pinned values fill the cap, so a new value was kept without its bytes) or"quota"(the browser's storage ran out) today. Only this tab's own drops of the signed-in user's (and the glow-shared) data are reported, and they are not relayed to other tabs.
reason/code values above are open vocabularies — tolerate values not
listed here.
Reads offline
A read still goes to the network first. Only a transport failure falls back to the on-device copy — a 403 or a 503 is never treated as "offline" and passes straight through as usual.
- A key the device already knows is absent (it was deleted, or a prior
read confirmed there was never a value) throws
not_foundwithfromCache: true— the same shape as an online miss. listandgetBatchanswer from whatever the device already holds, which can be an incomplete slice of the full keyspace — this device may simply not have seen every key yet. Pages served this way carry an SDK-issued cursor; keep paging with it — even once you're back online — until it comes back empty. A cursor from a page the network answered can't be continued while offline; that throwsnetworkinstead.BlobResult/JSONResult/RecordResult/ their JSON equivalents /ListPage/ batch entries all carryfromCache: truewhen they came from the device — the same tagBacklitErrorcarries for a cachednot_found.- A write the server acknowledged keeps the device copy. When an online
put,putIfMatch,delete(or a queued write finally sent) is acknowledged, the on-device copy of that key is updated to match — so a key you just saved reads back offline even if this device had never read it first. Only what the server acknowledged is kept; a queued write is never treated as confirmed. - A key the device lost track of is never reported absent. A value the
device dropped to make room, or whose state it can no longer vouch for,
gives no answer offline (a read throws
network, like any read the device can't answer) rather than a falsenot_found. - A change seen elsewhere invalidates the copy. When
backlit.on("storage.change")shows that a key was changed by someone else, the device copy of that key is unusable — it is not served, and aputIfMatchagainst it can't be answered locally — until the next read of that key refreshes it. completesays whether a page is the whole story. Every page answered from the device — thelist/getBatchfallback, andoffline.cached()— carries a booleancomplete. It istrueonly when the device holds a complete listing that covers the prefix, has lost track of nothing in this page's range, and every earlier page of the same listing was complete too; the final page's flag therefore speaks for the whole listing.falsemeans "only what this device has seen". Pages the network answered never carrycomplete.
Device-view reads: offline.peek() and offline.cached()
get, list and friends go to the network first. To paint something
instantly from what the device already holds — and then refresh with a
normal read — use the two device-view reads. Neither ever makes a network
request.
const cached = await backlit.offline.peekJSON("data", "todos"); // no request
if (cached) render(cached.value, { stale: true });
const fresh = await backlit.data.getJSON("todos"); // refresh
render(fresh.value);
offline.peek(store, key) resolves the device's copy of one key: the value
your own queued write left (pending: true), else the value the server last
confirmed (fromCache: true), else null. null also means the device
holds nothing usable for it — only a description of the value, or nothing;
the key is known deleted, or has a queued delete; offline mode is off; or
store isn't one it was enabled for. offline.peekJSON(store, key) is the
same with the value JSON.parsed whatever its content type (a value that
isn't JSON rejects with invalid_body). Both reject only with
invalid_argument (a store other than "data", "userdata" or
"records") or invalid_key, and wait, for at most 3 seconds, for an
enable() in progress.
A copy kept for the signed-in user (every store except data on a Public or
Accounts glow) is only answered once this page has confirmed who is signed
in, or its requests have failed for lack of a network — until then peek
resolves null, so a slow connection never shows the previous user's data.
offline.cached(store, prefix?, opts?) resolves one page describing what
the device holds under a prefix — never the values:
interface DeviceEntry {
key: string; crc32c: string; contentType: string; size: number;
hasValue: boolean; // true: the value itself is on the device (peek answers it)
pending?: true; // a write of this key is queued on this device
}
// records rows additionally carry `owner`
crc32c is "" (and size 0) where the device doesn't know them. Your
own queued writes are laid over the rows: a queued put adds or replaces a
row (hasValue: true, pending: true) and a queued delete removes it. The
page is { items, next, fromCache: true, complete }; paginate with next
exactly like list() (limit is clamped to [1, 1000], default 500). When
the device may not answer — offline mode is off, or the identity rule above
isn't met — it resolves an empty page with complete: false. It rejects
only with invalid_argument (a bad store), invalid_prefix, or
invalid_cursor (a cursor that a page answered from this device didn't
return).
Prefetch and pins
By default the device only holds what you have already read or written. Before a planned offline session — a field visit, a flight — warm it up.
offline.prefetch(store, target, opts?) fetches values onto the device,
one request at a time, and resolves what was done. target is either a
prefix ("": the whole store) or an array of 1–1000 keys. For a prefix it
lists the prefix, then fetches the values not already on the device — one at
a time or in pages, whichever takes fewer requests. A value larger than a
quarter of maxBytes is never requested (it is named in notStored). Run it
again when nothing changed and it makes only the listing requests.
Each request counts toward your glow's request allowance — bound it with
maxRequests or signal.
| Option | What it does |
|---|---|
signal |
An AbortSignal. Aborting stops early; the call then resolves with reason: "aborted" (it never rejects with an AbortError). |
pin |
For a prefix, pin it first (ignored for a list of keys). |
maxRequests |
At most this many requests (a positive integer); unlimited when omitted. |
onProgress |
Called after each request with { phase, requests, keys, stored, bytes, totalKeys, totalBytes } (phase is "list" or "fetch"). |
It resolves { …progress, complete, notStored, reason? }. complete is
true only when every key was dealt with — fetched, or named in
notStored — and nothing stopped the run. notStored lists
{ key, reason } for values that are not on the device ("exceeds_max_bytes",
"no_room", "max_bytes", "not_found", and others). reason, when
present, says why it stopped early — "aborted", "network", "disabled",
"not_enabled", "identity_change", "stale", "max_requests", "quota"
(this device's storage failed), "rate_limited", "unavailable" (the glow is
dark or the server failed), and others; it is an open vocabulary. Once its
arguments are valid it never rejects, and nothing is stored after a stop. It
runs only while called — nothing is fetched later on its own — and resolves
reason: "not_enabled" when offline mode is off. It rejects only with
invalid_argument (a store other than "data", "userdata" or
"records", one offline mode wasn't enabled for, or a bad option),
invalid_prefix or invalid_key.
const r = await backlit.offline.prefetch("data", "docs.", {
pin: true, maxRequests: 200,
onProgress: (p) => bar.set(p.keys / Math.max(p.totalKeys, 1)),
});
if (!r.complete) console.log("not everything is on this device:", r.reason, r.notStored);
Pins. Ordinarily the least recently used values are dropped to stay
under maxBytes. A pinned prefix is exempt: its values are never dropped
to make room. offline.pin(store, prefix) pins a prefix ("": the whole
store) and resolves true when a pin was added, false when it already
existed or offline mode is off; offline.unpin(store, prefix) removes one
(true when it removed a pin); offline.pins() resolves the signed-in
user's (and the glow's shared) pins, oldest first, as { store, prefix, bytes, keys } — the bytes and keys being what the device holds under the
prefix now. pins() never rejects; pin / unpin reject only with
invalid_argument (a bad store) or invalid_prefix. Pinning itself never
fails for size, and pinned values are still capped by maxBytes — see
Staleness and limits. Pins never survive a
purge: they are deleted with the on-device copy they belong to — on
logout, sign-out, a different user, a stale session, or clear().
Some browsers clear a site's on-device data after a period without a visit
unless the app is installed to the home screen. The persist option on
enable() asks the browser to keep it (best-effort; see status().persisted)
— it does not lift that limit in every browser, so treat a prefetch as a
head start for a planned session, not permanent storage.
Writes offline
A write that fails for lack of a network is queued on the device (when
writes: "queue", the default) and resolves immediately with pending: true — on the write's own result, and on the PutResult /
PutIfMatchResult / their JSON equivalents. Queued writes to the same key
run in the order they were made: a later write never overtakes an earlier
one still waiting to be sent for that key.
putcomputes the body's CRC32C on the device; if the key's current value is already known, replaying it later is a compare-and-swap against that value — if it isn't, the write replays unconditionally, exactly like an online blindput.putIfMatch/putJSONIfMatchcheck the expected CRC32C against what the device already knows, locally: a match queues the write (resolvingmatched: true, pending: true); a mismatch resolvesmatched: falsewith awitnessfrom the on-device copy (fromCache: true) — without waiting for the network. If the device has no local knowledge to compare against, the call throwsnetworkinstead — with one exception:putIfMatch(key, value, type, '')(an empty expected checksum, i.e. create-only) on a key the device has never seen queues as a create-only write and resolvesmatched: true, pending: true. If the key turns out to exist when the write is finally sent, that is resolved as a conflict. A non-empty expected checksum against a key the device has never seen still throwsnetwork, and so doesupdateJSONon a key the device has never seen (it has no value to compute from).deleteagainst a key the device already knows is absent throwsnot_found(withfromCache: true) rather than queuing anything — a delete was never idempotent online either.
Conflicts
A queued write can meet a newer value once it's finally sent — someone
else wrote the key while you were offline. By default the server's value
wins: the write is dropped, a conflict event fires with
resolution: "remote", and the on-device copy is corrected. A queued
create-only write whose key turned out to exist is such a conflict too:
onConflict sees base: "" and remote set to the value now on the
server.
Pass onConflict to enable() to change that outcome. It receives:
interface OfflineConflict {
store: "data" | "userdata" | "records"; key: string;
operation: "set" | "delete";
local: { value: Blob; contentType: string; crc32c: string } | null;
remote: { value: Blob; contentType: string; crc32c: string } | null;
base: string;
}
and returns a merged { value, contentType } (retried as a compare-and-swap
against the server's value), "local" (your queued value wins the same
way), or "remote" / undefined (the default — the server wins). It may
be async; a throw or a 10-second timeout also resolves to the server
winning. updateJSON's mutate reruns automatically in the page that
queued the write, so a read-modify-write you already wrote with updateJSON
needs no extra conflict-handling code to stay correct offline.
Identity
Offline data is scoped per signed-in user on this device, and two existing
calls change only under enable():
auth.me()answers the last signed-in user this device confirmed, instead ofnull, when the network fails — as long as that confirmation is under 7 days old (see Staleness and limits). Online behavior, and every other reasonauth.me()resolvesnull, is unchanged.auth.logout()gives queued writes a brief chance to send, then holds — rather than discards — whatever is still unsent for that same account (seepending()'s"logout"reason above) before deleting the on-device copy and navigating. A held write is sent once that user is confirmed signed in on this device again.
Switching to a different user, on this or another tab, or a session ending or being revoked, likewise holds that user's unsent writes rather than losing or misdirecting them, and deletes the on-device copy for the account that's no longer current.
Reachability and readiness
Noticing the connection is back. Once a request has failed for lack of a
network, status().online is false. While the page is offline and
visible, the SDK checks on its own whether the connection has returned —
you don't need to poll or call sync():
- every
probeMs(default 15 seconds, never below 5 seconds, backing off toward 2 minutes after each miss) it sends a small request to a Backlit-operated address — no cookies, no referrer, and the answer is never read; - only if that gets any answer does it make a session check with your glow;
- when the session check answers, the page goes back online
(
status().onlineistrue, astateevent fires) and the write queue sends. Any successful data call flips it back online too.
Several tabs of the same glow share the check, so a second tab doesn't double
it. probeMs: 0 turns the whole check off, and the page then notices the
connection only when one of its own requests succeeds. If your app sets its
own Content-Security-Policy, add https://backlit.run to connect-src
so the SDK can detect connectivity cheaply; without it, it falls back to
checking with your glow less often.
Being ready. enable() never waits indefinitely for the network, and
data calls made while it is in flight wait for it (see
offline.enable);
offline.whenReady() resolves once it has finished. To wait for the queue
rather than the start-up, use
offline.whenSynced().
Staleness and limits
| Limit | Value |
|---|---|
| Session confirmation window | 7 days |
On-device copy size (maxBytes) |
50 MiB, default |
| Write queue | 2,000 writes / 50 MiB |
onConflict |
10 s timeout, 3 rounds |
| Quiet retry of the write queue | 1 min, doubling up to 30 min |
| Pause after a 429, or a dark / missing glow | 15 min |
Connectivity check (probeMs) |
every 15 s by default, minimum 5 s, backing off to 2 min |
Every per-user partition — every store on a Private glow, plus userdata
and records on Public/Accounts glows — stops answering from the device
once its last confirmed online session check is more than 7 days old:
status().stale becomes true, and a cached read for that user throws
network instead (writes are still accepted and queued, held until the
session is reconfirmed). Glow-shared data on a Public or Accounts glow —
read by any visitor, not one signed-in user — has no such bound.
Past the write-queue cap, a write throws network with
status().reason === "queue_full".
maxBytes is a hard cap, and pins don't lift it. Values under a
pinned prefix are never dropped to make room, and
pin() itself never fails for size. When the unpinned values can't make room
for a new one — pinned values fill the cap — the new value is kept without
its bytes (a description only: hasValue: false in
cached(), and a read
of it offline throws network), evicted watchers see reason "no_room",
and prefetch lists the key in notStored with that reason. A value larger
than a quarter of maxBytes is likewise never kept by a write or a
prefetch (a prefetch names it "exceeds_max_bytes"). Unpin something, or
raise maxBytes, to make room.
Retries. A write that couldn't be sent is retried by the SDK without you
asking. While the page is visible and writes are waiting, it retries quietly
with growing gaps — a minute, then two, doubling up to 30 minutes, and
starting over after a retry that sends something. A 429, or a glow that is
dark or missing, pauses sending for 15 minutes, and other tabs of the glow
honour the pause. Every retry is ordinary app traffic: each request counts
toward your glow's request allowance, which is why the gaps grow. Call
sync() to send immediately, or whenSynced() to wait.
Capture opt-in
capture is deliberately not in the default stores list. Add it
explicitly if you want capture.create / capture.update to keep working
offline:
await backlit.offline?.enable({ stores: ["data", "userdata", "records", "capture"] });
This is a weaker guarantee than the rest of capture's contract: a queued
create or update's body sits on the device, readable by that device's own
page JS, until it is sent — capture's normal write-only promise ("page code
can't read back what it captured") holds only once the write reaches the
server. Capture values themselves are never kept in the on-device
read copy — only queued, unsent bodies exist on the device, and only
until they send. capture.create under this opt-in mints its
"{prefix}-{guid}" handle on the device (a random v4 UUID in place of the
usual server-minted one) so the final handle is known immediately, even
before the create has reached the server.
backlit.device — device-only preferences
backlit.device is a tiny key/value store for device-only preferences —
a Light / Dark / Auto theme, the last-opened tab, a collapsed panel. The values
stay on this device: they are never synced, never shared, and never visible to
anyone else, and a user who signs in on another device doesn't get them. It is
deliberately small and capped so it can't become a place for app data.
- Preferences only. Anything the user expects to follow them to another
device belongs in
userdata; anything other people should see belongs indataorrecords. Never store secrets. - Works without
offline.enable(), signed in or out, on any auth mode. - Off a deployed glow host every method rejects with
unsupported_host.
// Apply the visitor's system preference first, then let a saved choice win.
document.documentElement.dataset.theme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const saved = await backlit.device.get("theme"); // string | null
if (saved) document.documentElement.dataset.theme = saved;
async function setTheme(t) { await backlit.device.put("theme", t); }
Apply prefers-color-scheme (or your default) before the first get: a
value stored while a user was signed in is only returned once this page has
confirmed that user is still signed in, so the read can take a moment on a
slow connection.
backlit.device.get(key): Promise<string | null> // null = no value
backlit.device.put(key, value: string): Promise<void> // replaces any value
backlit.device.delete(key): Promise<void> // idempotent
backlit.device.getJSON<T>(key): Promise<T | null> // get, JSON.parsed
backlit.device.putJSON(key, value): Promise<void> // put of JSON.stringify(value)
| Limit | Value |
|---|---|
| Key | the same charset and length rules as data keys |
| One value | 8192 bytes (UTF-8); for putJSON, the encoded text |
| Keys | 64 |
| Total | 65536 bytes of keys plus values (an overwrite counts only its change) |
Values are strings, or JSON through the *JSON methods — no Blobs or
binary.
Who the values belong to. A value stored while signed out is kept for
whoever signs in next on this device (it is the device's own preference, not
a user's). Values stored while a user was signed in are that user's on this
device: they are cleared when a different user signs in, when that user signs
out or their session has gone unconfirmed for over 7 days, and on
auth.logout(). A signed-in user's values are only returned once this page
has confirmed who is signed in; until then get waits up to 10 seconds and
then resolves null — the value is kept, not lost. put / delete that
can't confirm who is signed in within the same 10 seconds reject with
network and store nothing. offline.clear()
does not touch the device store.
Errors. Every method rejects with BacklitError:
code |
When |
|---|---|
invalid_key |
the key breaks the key rules |
invalid_argument |
put of a non-string, or putJSON of a value with no JSON form |
payload_too_large (status 0) |
a value over 8192 bytes |
quota_exceeded (status 0) |
past 64 keys or 65536 bytes, or the browser's own storage is full |
invalid_body |
getJSON on a stored value that isn't JSON |
network |
put / delete could not confirm who is signed in in time (see above) |
unsupported |
this browser has no on-device storage the SDK can use, or it can't be opened |
unsupported_host |
the SDK was loaded off a deployed glow host |
Offline install (offline.json)
Offline install caches your app's files — a different, independent
feature from backlit.offline above,
which caches your app's data. Ship either alone, or both together for an
app that looks and behaves normally with no network at all.
A deploy bundle may carry an optional offline.json at its root, and its
presence turns offline install on for the glow: after an online visit
the app launches and paints from a cached copy when the device has no
network, and the files you nominate are downloaded up front so the whole
app is available offline right after it installs. A bundle without the
file deploys with offline off. The
offline.json schema
is the authority on every field, default and limit; the
offline guide walks
the end-to-end story.
{} is a complete, valid file — "offline on, precache everything".
Three optional fields narrow it:
| Field | What it does |
|---|---|
precache |
Bundle-relative glob patterns naming the files to install up front — absent means every file. |
exclude |
The same glob dialect, removing files from whatever precache selected. |
navigation_fallback |
The page served for an offline navigation with no cached response — optional, and it must itself be in the precache set. |
APP.md, DATA.md, notify.json and offline.json are always
excluded from the precache set and cannot be re-added; index.html
is always included last and cannot be excluded (the
schema carries the
complete always-excluded set). A file matched by no pattern is simply
not precached — never an error.
What you can observe once a version carrying the file is live:
- Offline state travels with the version. It is decided at deploy time and never re-derived, so a rollback to a version that didn't carry the file switches offline off.
- Navigations are network-first. Online you always get the current deploy; the cached copy is used only when the network is unavailable.
- An app opened from the cached copy learns when it is behind. Once the
device is back online, the SDK fires
"app-update-available"if a newer deploy is live — seebacklit.checkVersion()— so you can offer a reload. - Each device downloads the precache set once per version it sees, and those requests are metered like any other request to the glow.
- The SDK bundle is cached too, so
window.backlit.*exists offline (its calls still need the network — see below). - Offline works on every auth mode. The cached copy is dropped when
a user signs out of a Private glow, when a session stops being
accepted (expiry, or removal from the allowlist), and when a version
without
offline.jsongoes live. Each is acted on while the device is online; the next successful online visit rebuilds the cache. - Browsers may evict cached storage after a stretch of disuse (iOS and Safari especially), so a long-dormant app may need the network again on its next launch.
Not covered. Offline install caches your app's files, not its
data: every data.* / userdata.* / records.* / capture.* call
still needs the network and fails offline like any other unreachable
request, unless the app separately opts into
backlit.offline — a different, independent
feature.
An offline.json that is too large, or whose precache set exceeds the
platform's file-count or total-size caps, is rejected at upload with
offline_too_large; one that is malformed, reaches for something the
platform disallows, or names a navigation_fallback outside the
precache set is rejected with invalid_offline (the
deploy API reports which).
Errors
Every method (except auth.me(), which never throws) rejects its
returned Promise with a single error class,
BacklitError. Discriminate on .code.
class BacklitError extends Error {
readonly code: string; // one of the codes in the table below
readonly status: number; // HTTP status, or 0 for network / client-side
readonly backlitCause: unknown; // underlying Error or fetch failure, when there is one
}
instanceof BacklitError works as expected. .code matches the
server's {error:{code,message}} envelope verbatim so the same switch
statement handles both server-emitted and client-side errors.
try {
await backlit.data.put("key", new Date(), "application/json");
} catch (e) {
if (e instanceof backlit.BacklitError) {
switch (e.code) {
case "invalid_argument": /* the Date isn't a BodyInit */ break;
case "invalid_key": /* charset / length problem */ break;
case "quota_exceeded": /* account storage/bandwidth allowance hit */ break;
default: throw e;
}
} else {
throw e;
}
}
Error codes
code |
Source | Meaning |
|---|---|---|
not_found |
server | get / getJSON / delete on a key that has never been written (any store), a putIfMatch with a non-empty expectedCrc32c against an absent key, or records.reassign on an unknown key. |
unauthenticated |
server / SDK | A shared-data write (data.put/delete/putIfMatch and the JSON write wrappers), userdata.*, or records.* called without a user session, or capture.get/list/delete or auth.users() with no signed-in user (an anonymous or signed-out visitor, an expired session). |
cross_glow_token |
server | Your session cookie is bound to a different glow. Rare; indicates cookie reuse. |
forbidden |
server | A viewer attempted data.put / data.delete; a non-creator/non-admin attempted to overwrite, delete, or reassign a records key; or a non-admin attempted capture.get/list/delete or auth.users(). (capture.create/update and records create are unaffected.) |
invalid_key |
both | Key violates the charset ([A-Za-z0-9._~-]) or length (1–256 bytes) rule. |
invalid_prefix |
both | Prefix violates the charset or length rule (length 0 is allowed). |
invalid_cursor |
server | A list opts.cursor is not a valid pagination token. Pass a prior page's next verbatim, or omit it for the first page. |
invalid_handle |
both | capture.update/get/delete handle is not the {prefix}-{guid} form (a bare GUID is rejected). |
unknown_handle |
server | capture.update/get/delete against a well-formed handle that has no record. |
invalid_body |
both | SDK-side when getJSON (or offline.peekJSON, or device.getJSON) reads a value that is not valid JSON; server-side when the request body could not be decoded. |
invalid_argument |
both | Client-side rejection (status 0): wrong body type, missing contentType, or a putJSON value with no JSON form (undefined / function / circular / BigInt). Also server-side (HTTP 400) for a malformed records.reassign owner id. |
unsupported_content_type |
both | Content-Type isn't on the allowlist (or is one of the always-denied executable types). |
payload_too_large |
server / SDK | Body exceeds the server's per-object cap (default 10 MiB). Also client-side (status 0): a backlit.device value over its 8192-byte limit. |
quota_exceeded |
server / SDK | Two cases, split by status: 413 — the write would push you over your account's storage allowance (the just-written object has already been rolled back); 429 — the account's monthly bandwidth/request allowance is exhausted, which rejects every call, reads and lists included, until the monthly reset. Also client-side (status 0): a backlit.device write past its 64-key or 65536-byte limit, or the browser's own storage full. |
method_not_allowed |
server | A non-SDK caller used an unsupported HTTP method. Should never fire from the SDK. |
identity_mismatch |
server | A backlit.offline queued write replayed on this device while a different user was signed in than the one who queued it. It is never thrown from a normal call — the SDK quarantines the write for its own user and retries once they're confirmed signed in again; the code surfaces only in offline.pending()'s lastError and an offline.watch() quarantined event. |
invalid_host |
server | The request reached Backlit with no recognisable glow Host. Should never fire from the SDK. |
glow_not_found |
server | The glow has been deleted (or never existed) since the page loaded. |
glow_dark |
server | The glow has been switched to Dark since the page loaded. |
region_mismatch |
server | The request reached a Backlit region different from the glow's region. |
network |
SDK | fetch itself rejected: offline, CORS, aborted, etc. .backlitCause holds the underlying error. Also backlit.device put / delete that could not confirm who is signed in within 10 seconds (nothing was stored). |
conflict |
both | SDK-side (status 0): updateJSON exhausted its compare-and-swap retries (maxAttempts, default 6) under write contention. Server-side (HTTP 412): a records.delete / records.reassign lost a concurrent-write race — re-read and retry. |
unsupported_host |
SDK | A data.* / userdata.* / records.* / capture.* call — or auth.users() / push.* / device.* — ran while the SDK was loaded outside a deployed glow host. The SDK has no data surface off-glow; deploy the glow to use it. (auth.me() and push.supported() do not throw it — they resolve null / { supported:false } instead.) |
unsupported |
SDK | push.subscribe/state/unsubscribe — this browser can't do push (no service worker / Push API / secure context), or push isn't enabled for this glow. Gate on push.supported() first. Also any backlit.device method when this browser has no on-device storage the SDK can use. |
requires_install |
SDK | push.subscribe/state/unsubscribe on a platform where push works only from an installed home-screen app (iOS Safari) and it isn't installed yet. push.supported() reports this as requiresInstall: true. |
permission_denied |
SDK | push.subscribe — the user declined (or had previously blocked) the notification-permission prompt. |
internal |
both | Server returned 5xx (or a non-JSON 4xx), or the response body wasn't parseable. |
A few patterns worth pulling out:
not_foundvs.glow_not_found—not_foundis "no value at this key under a live glow";glow_not_foundis "the glow itself is gone". Handle them differently.payload_too_largevs.quota_exceeded— the first is a per- object limit (don't upload a 100 MB file); the second is an account allowance — storage (a 413 on writes) or monthly bandwidth (a 429 on any call, reads included).unauthenticatedfromuserdata.*— the SDK rewrites the server's terse message to one that names the actual constraint ("requires a signed-in user; an anonymous or signed-out visitor cannot use this surface").- A
putIfMatchmismatch is not an error — it resolves{ matched: false, witness }rather than rejecting, so it carries nocode. The only thrown case unique toputIfMatchisnot_found: a non-emptyexpectedCrc32cagainst a key that does not exist.updateJSON(which loops onputIfMatchfor you) instead absorbs mismatches as retries and throwsconflictonly when it gives up.
BacklitError.status
The HTTP status code that produced the error, when one exists. 0 for
client-side validation failures (invalid_argument, invalid_key,
invalid_prefix, invalid_handle, the always-denied content-type
pre-flight), for conflict raised by updateJSON (gave up after its
retries — a server-raised records race carries HTTP 412 instead), for
unsupported_host (the SDK was loaded off a deployed glow host), and
for transport failures (network).
Validation
The SDK pre-flights all inputs so you see a BacklitError from the
caller's await rather than from a wasted round-trip. The rules below
mirror the server exactly.
Keys (every data.* / userdata.* / records.* method that takes a key — get, put, putIfMatch, delete, getJSON, putJSON, putJSONIfMatch, updateJSON)
- 1–256 bytes long.
- Characters: the RFC 3986 unreserved set — ASCII letters (
A–Z,a–z), digits (0–9), hyphen (-), period (.), underscore (_), tilde (~). - No slashes, no whitespace, no other punctuation.
Failures throw BacklitError with code === "invalid_key".
Prefixes (data.list, userdata.list, records.list)
Applies to every method that takes a prefix — the three lists above
and the matching getBatch / getBatchJSON on data / userdata /
records:
- 0–256 bytes long. The empty prefix (or omitting the argument) lists the whole keyspace.
- Same charset as a key.
Failures throw BacklitError with code === "invalid_prefix".
Handles (capture.update / get / delete)
A handle is the keyed string "{prefix}-{guid}" returned by create:
{prefix}is the prefix you passed tocreate(the key charset above).{guid}is a 36-char lower-hex 8-4-4-4-12 GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, eachxis[0-9a-f]) — normally minted by the server (backlit.offline's capture opt-in is the one case where the device mints it, as a random v4 UUID, before the write ever reaches the server).
A bare GUID with no prefix is not a valid handle. Never construct a
handle yourself — pass back exactly what create returned.
Failures throw BacklitError with code === "invalid_handle".
Content-Type
contentType is required on every put and create/update. It must
be a non-empty string. The server enforces a strict allowlist (by
default text/plain, text/css, text/csv, text/markdown, the
raster image families image/{png,jpeg,gif,webp}, application/json,
application/octet-stream, and application/pdf — note these are the
exact text/ subtypes, not a text/* wildcard; a PDF write is
additionally checked against its own magic bytes, and a PDF read is
served with a download disposition rather than rendered inline); the
exact allowlist may be tuned per deployment.
A small set is always denied, regardless of the allowlist, because serving them back on a GET could execute as same-origin script:
text/htmlapplication/xhtml+xmlimage/svg+xml(an SVG can embed<script>that runs on navigation to the object URL)application/javascript,text/javascript,application/ecmascript,text/ecmascript
The SDK pre-flights this always-denied set client-side
(unsupported_content_type). The full allowlist is enforced server-
side. On reads the server also sends X-Content-Type-Options: nosniff
so stored bytes can't be MIME-sniffed into executable markup.
Notes
- No SDK guard against double-load. If a page accidentally
<script src>s the bundle twice, the second copy rebindswindow.backlitwith an equivalent surface. There is no internal state to corrupt; a guard would break legitimate hot-reload flows. window.backlit._versionStale. After a deploy, sessions issued against an older live version of the glow stay valid (you don't get signed out), but the server signals that the page is now serving stale code. The SDK flipswindow.backlit._versionStale = trueon first sighting so apps that care can prompt the user to reload. The leading underscore means this is not part of the v1 contract and may evolve — usebacklit.checkVersion()and the"app-update-available"event instead.- Cookies and credentials. Requests carry the glow's session cookies automatically — the SDK never reads or writes cookies itself.
Changelog
backlit.checkVersion()and the"app-update-available"event were added.checkVersion()resolves{ client, server }— the version this page was loaded from and the glow's live version (serverisnulloffline) — and fires"app-update-available"when they differ. On a glow that shipsoffline.jsonthe event also fires on its own when a newer deploy goes live. Seebacklit.checkVersion().backlit.onnow accepts"app-update-available"; hand-written test doubles typed againstBacklitmust addcheckVersionand the newonoverload.backlit.offlinegained device-view reads, prefetch and pins, and a connectivity check; a few behaviours changed. New:offline.peek()/peekJSON()/cached()read the on-device copy with no request, and pages answered from the device now carrycomplete;offline.prefetch(),pin(),unpin()andpins()warm the device for a planned offline session (each prefetch request counts toward your request allowance);offline.whenReady()andoffline.whenSynced()wait for start-up and for the write queue;offline.watch(cb, { events })can name event types, including the new opt-inevicted;status()gainedheld,bytesUsed,maxBytesandpersisted; andenable()gainedprobeMsandpersist. Changed: a write the server acknowledged now keeps the device copy,putIfMatch(key, value, type, '')on a key the device has never seen now queues as a create-only write instead of throwingnetwork,enable()waits at most 2 seconds for a slow network (data calls made meanwhile wait for it), a page that goes offline notices the connection returning on its own (Reachability and readiness), and a queue stuck on errors retries quietly with growing gaps. If your test code has a hand-written double typed againstBacklitorOfflineNamespace, add the new members.backlit.devicewas added — device-only preferences. A small, capped key/value store (get/put/delete/getJSON/putJSON) for things like a theme or the last-opened tab. It stays on this device, is never synced, works signed out, and is cleared on logout or when a different user signs in.payload_too_large,quota_exceededandunsupportedcan now come from it as client-side errors. Seebacklit.device. Hand-written test doubles typed againstBacklitmust add thedevicemember.backlit.offlinewas added — opt-in offline data. Callbacklit.offline?.enable()once at startup anddata/userdata/records(pluscapture, if you ask for it) keep working with no network: reads fall back to a mirrored on-device copy (fromCache: true), and writes that can't reach the server are queued and sent later (pending: true). By default the server's value wins a conflict; anonConflictoption can resolve it differently. A new error code,identity_mismatch, surfaces only throughoffline.pending()/offline.watch(). Seebacklit.offlineand the offline data guide.- Offline install (
offline.json) was added. An optional root file in the deploy bundle turns on offline operation for the glow: the app launches and paints from a cached copy with no network, and the files itsprecache/excludeglobs nominate are downloaded up front.{}is a complete file; ship none and offline stays off. It caches your app's files, never its data. See Offline install and the offline guide. data/userdata/recordsgainedgetBatchandgetBatchJSON. Bulk reads that return the values alongside the same list-shaped pagination — one round trip for a whole page of keys instead of alistplus agetper key. An entry too large for a page arrives metadata-only withomitted: true(fetch it with a plainget); a page can end early at its per-response value budget, sonextis the sole completion signal.getBatchJSON<T>parses each value likegetJSONand degrades a non-JSON entry to the omitted arm.capturehas no batch read.backlit.pushwas added — notifications + universal install. A new namespace for Web Push:supported()(a synchronous, never-throwing capability probe),state(),subscribe(),unsubscribe(),test(), andwatch(). Push needs a signed-in user and must be enabled for the glow; three new client-side error codes surface the browser-capability cases (unsupported,requires_install,permission_denied). Separately, every glow is now installable — the SDK sets up the platform service worker on load automatically. Don't register your own service worker on a Backlit glow.auth.users()is now paginated. It takes{ cursor?, limit? }and returns{ items: BacklitUser[], next: string }— the same cursor shape as thedata/userdata/records/capturelist()surfaces. Previously it returned a flatBacklitUser[]capped at the first 1000 users. Walk the pages with{ cursor: next }untilnextis"".- Shared-data writes now require a signed-in user. Anonymous
sessions (a signed-out Public or Accounts visitor) can still read
backlit.data.*, butput/delete/putIfMatchand the JSON write wrappers now throwBacklitError(code: "unauthenticated"). Previously anonymous sessions could write shared data. Apps that collect input from anonymous visitors should usebacklit.capture.create(open to any session) or require sign-in. auth.users()was added. An admin-only, read-only roster of the Private glow's users — each aBacklitUser({ uid, name, email, mobile, permission }). Unlikeauth.me()it throwsBacklitError(forbiddenfor a non-admin,unauthenticatedwith no signed-in user,unsupported_hostoff a glow host,network).auth.user()andauth.permission()were replaced byauth.me(). A single call now returns the whole signed-in user —{ uid, name, email, mobile, permission }— ornullwhen there is no user. Reademail/permissionoff the result instead of the two old methods.window.backlit.modeand the__BACKLIT_LOCALoverride were removed. The SDK is always remote and runs only on a deployed glow host; the data surface has no local/offline (browser-storage) fallback — distinct from Offline install, which caches your app's files and never its data. Off a glow host everydata.*/userdata.*/records.*/capture.*call throwsBacklitError(code: "unsupported_host"), whileauth.me()resolvesnull.list()entries now carrycontentTypeandsize. Everydata.list/userdata.list/records.list/capture.listentry includes the stored content-type and the body's byte size alongsidecrc32c(and, for records,owner).