JavaScript SDK reference
The Backlit SDK is a small browser library that AI-generated static apps (glows) use at runtime: read the signed-in user's email, read and write glow-shared data, read and write per-user data, and post write-only telemetry. This page is the public contract for everything window.backlit exposes.
The same bundle also works when no Backlit server is in reach — opened straight from file://, embedded in an AI design tool's preview iframe, or served from any non-backlit.run host. In that situation the SDK transparently mirrors every operation against browser-local IndexedDB under a fixed [email protected] identity, so an app can be prototyped end-to-end before it ships to a glow. See Local development and testing.
A plain-text copy of this reference is published at backlit.run/sdk.md so AI agents can fetch and parse the surface without HTML scraping.
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://cdn.backlitdev.run/sdk/v1/sdk.js"></script>
<script>
const email = await window.backlit.auth.user();
// ...
</script>
Two URL channels are supported:
| URL | Stability | Use for |
|---|---|---|
https://cdn.backlitdev.run/sdk/v1/sdk.js | Stable | Production glows. Pinned to the v1 surface. |
https://cdn.backlitdev.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").
window.backlit.mode is "remote" when the SDK is talking to the live Backlit server and "local" when it's persisting to browser-local IndexedDB. The value is fixed for the lifetime of the page; branch on it to render a "running locally" banner or to disable features that only make sense once deployed. See Local development and testing for the detection rule and override hook.
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 of the glow (one shared set) | leaderboards, shared docs, published content |
backlit.userdata | each signed-in user, isolated (Private glow) | per-user settings, drafts, history |
backlit.records | any signed-in user creates + reads all; only the creator/admin edits (Private glow) | forum posts, comments, user submissions, galleries |
Both 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 session ever writes. But when two visitors 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 a Private glow. |
localStorage (app-global state) |
backlit.data.* |
Content that every visitor should see: a wiki page, a leaderboard, a published gallery. |
IndexedDB keyed records |
backlit.data.* / userdata.* |
Same split as above. Backlit's keyspace is flat — collapse object-store + key into one string. |
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 the appropriate image/* content type. |
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);
userdata has a single writer — you. The identical shape on shared backlit.data.* is a lost-update bug. For any shared key more than one session can write, use updateJSON instead of putJSON.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, chat, 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 |
| keeps per-user settings, drafts, or history in one global blob | Per-user data — each signed-in user gets an isolated silo | backlit.userdata.* (Private glow) |
| 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.permission() |
| needs sign-in, or should be limited to specific people or domains | Private auth mode — restrict to an allowlist; 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-api OpenAPI for the direct and ticket-based upload endpoints). A few rules for what to put inside.
index.html at the bundle root. Glows are served from https://{name}.backlit.run/; the file returned for / is the glow's default_file, which defaults to index.html. Either ship an index.html at the top level of the bundle or set default_file explicitly when you create the glow. 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.backlitdev.run/mcp - Claude — set up a custom connector
- ChatGPT — set up an MCP connector
The deploy tools hand the bundle off out of band, directly to https://glow.backlitdev.run rather than over the MCP connection — so if your agent runs in a sandbox with a domain allow-list, add that host to its allowed/permitted domains or the upload 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.
Local development and testing
The SDK auto-detects whether it's running on a real Backlit host. When the page's hostname doesn't end in .backlit.run (or a recognised dev suffix — .localtest.me, localhost, *.localhost), the SDK enters local mode: every data.* / userdata.* / capture.* call persists to a browser-local IndexedDB database named backlit_local in the page's origin, and auth.user() returns the fixed string "[email protected]". No Backlit services need to be reachable.
This is what makes the SDK usable inside AI design tools (v0, bolt, lovable) and from raw file:// pages: drop the same <script src> tag, write the same code against window.backlit, and deploy unchanged once you're ready. Local mode is a faithful mirror of the production surface, not a stub:
- The content-type allowlist matches the server default exactly (
text/plain,text/css,text/csv,text/markdown,application/json,application/octet-stream,image/{png,jpeg,gif,webp}). The always-denied executable types — includingimage/svg+xml— are rejected client-side the same way. - The per-object cap is 10 MiB; oversized writes throw
payload_too_large. - Writes that would push you over your storage allowance (shared, userdata, and private stores combined) throw
quota_exceeded. userdata.*always works under the single[email protected]identity — there's no Public-vs-Private distinction locally.putIfMatchis mirrored too: the compare-and-swap runs inside a single IndexedDB transaction, so it is atomic against a same-tab racing write and resolves{ matched: false, witness }on a CRC mismatch exactly like the live surface.
Use window.backlit.mode ("remote" or "local") to branch UI:
if (backlit.mode === "local") {
document.getElementById("env-banner").textContent =
"Running locally — data is stored only in this browser.";
}
Forcing the mode
Set window.__BACKLIT_LOCAL to true or false before the SDK script loads to override the auto-detect. Useful when serving from a plain static server on localhost (which auto-detects as "remote" since that's also the convention for live Backlit dev setups), or when you explicitly want to test the live Backlit surface from an unusual hostname.
<script>window.__BACKLIT_LOCAL = true;</script>
<script src="https://cdn.backlitdev.run/sdk/v1/sdk.js"></script>
Inspecting and clearing local data
The data lives in your browser's DevTools under Application → IndexedDB → backlit_local, with one object store per surface (shared, userdata, private) plus a meta store holding the running byte counter. To wipe state and start clean:
indexedDB.deleteDatabase("backlit_local");
Wire that one-liner into a debug button on your own dev page if you find yourself resetting state often.
When IndexedDB is unavailable
In some browsing modes (Safari Private mode in older versions, some in-app webviews, locked-down enterprise browsers) IndexedDB throws or returns undefined. Local mode detects this at first use and every data operation rejects with BacklitError(code: "internal") whose message names the constraint. auth.user() and window.backlit.mode keep working — they don't touch IndexedDB.
Public vs. Private glows
What works for a given visitor depends on the glow's auth_mode and whether the visitor has signed in.
| Surface | Public glow (anyone) | Private glow, signed in | Private glow, signed out | Local mode (any host off *.backlit.run) |
|---|---|---|---|---|
backlit.auth.user() |
returns null |
returns the verified email | n/a — Backlit redirects the visitor to auth.backlit.run before any JS runs |
returns "[email protected]" |
backlit.auth.permission() |
returns null |
returns admin / user / viewer |
returns "admin" |
|
backlit.data.* (reads) |
works | works | works (IndexedDB) | |
backlit.data.put / delete |
works | works (blocked for viewer) |
works (IndexedDB) | |
backlit.userdata.* |
throws unauthenticated |
works (per-user silo) | works under the fixed local user | |
backlit.records.* (reads) |
throws unauthenticated |
works (read every record) | works (IndexedDB) | |
backlit.records.put / delete |
throws unauthenticated |
works on your own records (others' → forbidden) |
works (single local user owns all) | |
backlit.records.reassign |
throws unauthenticated |
works only for admin (else forbidden) |
works (single local user is admin) | |
backlit.capture.create / update |
works | works | works (IndexedDB) | |
backlit.capture.get / list / delete |
throw unauthenticated (no admin) |
work only for admin (else forbidden) |
work (single local user is admin) |
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.
backlit.auth
auth.user()
backlit.auth.user(): Promise<string | null>
Returns the verified email of the signed-in user, or null for Public glows (which never have a user session). Never throws — network failures and missing sessions both resolve to null, since both render the same fallback UI in practice.
In local mode this method always resolves to "[email protected]", so apps written against a non-null return value can be developed and tested without a real Backlit signin flow.
const email = await backlit.auth.user();
if (email === null) {
// Show the anonymous experience.
} else {
document.getElementById("greeting").textContent = `Hello, ${email}`;
}
auth.permission()
backlit.auth.permission(): Promise<"admin" | "user" | "viewer" | null>
Returns the signed-in user's permission level (a lowercase enum), or null for Public glows, signed-out visitors, and sessions minted before permissions shipped. Never throws — like auth.user(), failures resolve to null.
"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 — this method exists so apps can branch ahead of a write, e.g. hiding a Save button. In local mode it always resolves to "admin".
const perm = await backlit.auth.permission();
if (perm === "viewer") {
saveButton.disabled = true; // the server would 403 a data.put anyway
}
backlit.data — glow-shared key/value
Readable and writable by any visitor with a valid glow session — i.e. every visitor of a Public glow, and every signed-in visitor of a Private glow. There is one shared keyspace per glow, and more than one session 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. 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.
data.get(key)
backlit.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.
Throws
BacklitError with code === "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, body, contentType)
backlit.data.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>
Write body to key under the glow-shared keyspace.
put is 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 bare put only when last-writer-wins is genuinely fine — a single writer, or idempotent content. For any shared mutable state, updateJSON is the default (or the putIfMatch primitive 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 }>. crc32c is the Castagnoli checksum of the body, matching the value the server recorded and any subsequent data.get / data.list exposes for the same key.
Throws
BacklitError. Common codes: invalid_key, invalid_argument (bad body type), 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");
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, body, contentType, expectedCrc32c)
backlit.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-empty expectedCrc32c against a key that does not exist — there is nothing to compare against), plus the same write-path codes as data.put (invalid_key, invalid_argument, forbidden for a viewer, 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 / data.updateJSON
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)
backlit.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, not_found (no value at this key — deletion is not idempotent; call get first 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?)
backlit.data.list(prefix?: string): Promise<Array<{ key: string, crc32c: 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, so apps can dedup against locally-cached state in one round trip — no per-key get needed just to discover what changed.
Parameters
prefix— optional string, 0–256 bytes, same charset as a key. May be empty.
Returns
A Promise<Array<{ key, crc32c }>> of matching entries, in unspecified order. The current server returns the entire result set in one response; pagination is a possible v2 evolution.
Throws
BacklitError with code === "invalid_prefix" if the prefix violates the charset or length rule.
const entries = await backlit.data.list("posts.");
// → [{ key: "posts.0001", crc32c: "+IxOLg==" }, ...]
const stale = entries.filter((e) => e.crc32c !== cache.get(e.key));
backlit.userdata — per-user data silo
Same shape as backlit.data, but each signed-in user has their own isolated silo, keyed by the user's verified email. Two users on the same glow cannot see each other's userdata.
BacklitError with code === "unauthenticated" and an actionable message. Call backlit.auth.user() first if you need to branch.In local mode the methods always succeed — the fixed
[email protected] user is implicitly signed in, so unauthenticated is never thrown.userdata.get / put / delete / list / *JSON
backlit.userdata.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }>
backlit.userdata.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>
backlit.userdata.delete(key: string): Promise<void>
backlit.userdata.list(prefix?: string): Promise<Array<{ key: string, crc32c: string }>>
backlit.userdata.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>
backlit.userdata.getJSON<T>(key: string): Promise<{ value: T, crc32c: string }>
backlit.userdata.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>
backlit.userdata.putJSONIfMatch<T>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>
backlit.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 email = await backlit.auth.user();
if (email === 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.
BacklitError with code === "unauthenticated", exactly like backlit.userdata. The store is free — no subscription required.In local mode the single fixed user owns every record, so writes always succeed and
reassign works too.records.get / put / delete / list / putIfMatch / *JSON
backlit.records.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string, owner: string }>
backlit.records.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>
backlit.records.delete(key: string): Promise<void>
backlit.records.list(prefix?: string): Promise<Array<{ key: string, crc32c: string, owner: string }>>
backlit.records.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<{ matched: true, crc32c, owner } | { matched: false, witness: { value, contentType, crc32c, owner } }>
backlit.records.getJSON<T>(key: string): Promise<{ value: T, crc32c: string, owner: string }>
backlit.records.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>
backlit.records.putJSONIfMatch<T>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>
backlit.records.updateJSON<T>(key: string, mutate: (current: T | undefined) => T, options?): Promise<{ value: T, crc32c: string, owner: string }>
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).putIfMatchreports it too — on a successful write and on the mismatch witness — 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 }.
// 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.
for (const { key, owner } of await backlit.records.list("comment.")) {
const { value } = await backlit.records.getJSON(key);
render(value, owner);
}
records.reassign(key, newOwnerUid)
backlit.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"), and an unknown key throws not_found. 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. The server returns an opaque {prefix}-{guid} handle on create.
Writes (create / update) are available on Public and Private glows alike — no user session required. Reads, enumeration, and deletion (get / list / delete) are admin only — they need a signed-in Private user whose permission is admin, so ordinary app code can capture data but can never read it back even if the page is compromised.
capture.create(prefix, body, contentType)
backlit.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 server-generated 36-character lower-hex GUID in 8-4-4-4-12 format (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-and.(same charset asdata.*keys — see Validation).body,contentType— same rules asdata.put.
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, body, contentType)
backlit.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.
Throws
BacklitError. Common codes: invalid_handle, unknown_handle (no record at that handle), plus the body / size codes from create.
// 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)
backlit.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. forbidden when the signed-in user is not an admin; unauthenticated on Public glows or signed-out Private visitors (no user session); unknown_handle if nothing is stored at that handle; invalid_handle for 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?)
backlit.capture.list(prefix?: string): Promise<Array<{ handle: string, crc32c: string }>>
Admin only. Enumerate the handles stored in this glow's private store, each with its body's CRC32C. Returns handles only, never values.
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.
Throws
BacklitError. forbidden when the signed-in user is not an admin; unauthenticated on Public glows or signed-out Private visitors (no user session); invalid_prefix for a bad prefix.
// Only meaningful for an admin user; branch on auth.permission() first.
if ((await backlit.auth.permission()) === "admin") {
for (const { handle } of await backlit.capture.list("contact")) {
const { value } = await backlit.capture.get(handle);
console.log(handle, await value.text());
}
}
capture.delete(handle)
backlit.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 as get); unknown_handle if the handle was never created; invalid_handle for a malformed handle.
await backlit.capture.delete(handle);
get / list / delete work unconditionally — an app built against them behaves the same once deployed and signed into as an admin.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 sessions whose verified email matches the writing user. 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
In remote mode 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.
In local mode events flow via BroadcastChannel between tabs of the same origin and synchronously (on the microtask queue) within a single tab. There is no socket to reconnect.
Future events
The first argument is currently restricted to the literal string "storage.change". Other event names will be added in future v1 minor releases without breaking existing callers.
Errors
Every method (except auth.user()) 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": /* glow data cap hit */ break;
default: throw e;
}
} else {
throw e;
}
}
Error codes
| code | Source | Meaning |
|---|---|---|
not_found | server | data.get / userdata.get on a key that has never been written. |
unauthenticated | server / SDK | userdata.* called without a user session (Public glow, 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. (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_handle | both | capture.update handle is not a 36-char lower-hex 8-4-4-4-12 GUID. |
unknown_handle | server | capture.update against a well-formed GUID that has no record. |
invalid_body | both | The body failed a content check: server-side for a declared Content-Type mismatch, or SDK-side when getJSON reads a key whose stored bytes are not valid JSON. |
invalid_argument | SDK | Client-side rejection: wrong body type, missing contentType, or a putJSON value with no JSON form (undefined / function / circular / BigInt). |
unsupported_content_type | both | Content-Type isn't on the allowlist (or is one of the always-denied executable types). |
payload_too_large | server | Body exceeds the server's per-object cap (default 10 MiB). |
quota_exceeded | server | The write would push you over your account's storage allowance. The just-written object has already been rolled back. |
method_not_allowed | server | A non-SDK caller used an unsupported HTTP method. Should never fire from the SDK. |
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. |
conflict | SDK | updateJSON exhausted its compare-and-swap retries (maxAttempts, default 6) under write contention. |
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 means you've used up your account's storage allowance.unauthenticatedfromuserdata.*— the SDK rewrites the server's terse message to one that names the actual constraint ("requires a signed-in user; Public glows 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 (updateJSON gave up after its retries), 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
Used by every data.* / userdata.* / records.* method that takes a key — get, put, putIfMatch, delete, getJSON, putJSON, putJSONIfMatch, updateJSON.
- 1–256 bytes long.
- Characters: ASCII letters (
A–Z,a–z), digits (0–9), hyphen (-), period (.). - No slashes, no underscores, no whitespace, no other punctuation.
Failures throw BacklitError with code === "invalid_key".
Prefixes
Used by data.list, userdata.list, records.list.
- 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
Used by 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 server-generated 36-char lower-hex 8-4-4-4-12 GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, eachxis[0-9a-f]).
A bare GUID with no prefix is not a valid handle. The GUID is always server-generated; 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, and application/octet-stream — note these are the exact text/ subtypes, not a text/* wildcard); 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.- Cookies and credentials. Requests carry the glow's session cookies automatically — the SDK never reads or writes cookies itself. In local mode there are no cookies; nothing leaves the browser.