LFStore
Global:LFStore · Enable:// @require store
App-scoped key/value storage backed by the extension background's IndexedDB. Keys are auto-namespaced by bundleId: one dataset per app across all sites, invisible to other apps. All methods except watch are async.
Methods
await LFStore.get(key, fallbackValue?): Promise<T>Reads a key; returns fallbackValue when absent (a key holding null counts as present)
await LFStore.set(key, value): Promise<T>Writes a JSON-serializable value; returns the value you passed in (not the normalized stored value — e.g. passing a Date still returns the Date object)
await LFStore.remove(key): Promise<void>Removes a key
await LFStore.keys(): Promise<string[]>Lists all keys of this app
await LFStore.clear(): Promise<void>Clears all data of this app
LFStore.watch(key | null, callback, { immediate?, includeSelf? }?): () => voidSubscribes to change notifications (synchronous registration); returns an unwatch function
Parameter details
| Parameter | Type | Default | Description |
|---|---|---|---|
key | string | — | Logical key, passed through String() (get(undefined) reads the key "undefined"; an empty string is a valid key); auto-namespaced by bundleId internally — keys() returns logical keys without the prefix |
fallbackValue (get) | T | null | Resolved when the key does not exist. A key holding null (including one set with undefined) counts as present, so get returns null rather than fallbackValue |
value (set) | T | — | Must be JSON-serializable. A Map/Set/WeakMap/WeakSet (convert to arrays first), function, BigInt, Symbol or cyclic reference at any depth rejects with TypeError, and the error message names the exact path, e.g. LFStore.set cannot store a Map value (at value.cache): ...; Date and other values with toJSON are stored as strings per JSON rules without an error (a Date reads back as an ISO string, not a Date); undefined is stored as null |
Notes: each key's value may occupy at most 8388608 bytes (8MiB) after JSON serialization and UTF-8 encoding; exactly 8MiB is accepted and exceeding it rejects. get() returns a fresh deep clone each call, so mutating the result never affects storage. Data lives in the extension background's IndexedDB — one dataset per app across all sites (like Tampermonkey GM_setValue), persisted across navigation.
Namespace ownership and isolation
The store belongs to the app, not to a single execution: a terminal run / run --tab script's bundleId suffix (::terminal-run::<pid>) is stripped by the background (same rule as file permissions), so liveDebug, enabled instances (app enable), and every run injection of the same local project share one dataset — a value written in one run is visible to the next run and to the enabled app.
Isolation is per bundleId and enforced by browser-attested identity: an app can never read, write, or watch another app's data (forged bundleId claims are rejected at the authorization layer). Note that a marketplace-installed copy and the local development copy are two different bundleIds (installed::<app_id> vs the local UUID), so they never share LFStore — development data does not carry over into the installed version, and vice versa.
Change subscription: LFStore.watch
LFStore.watch(key, callback, options?) subscribes to storage changes within this app's namespace and returns an unwatch function (idempotent; also auto-released on hot reload). It works across instances: a both app's page instance receives the background instance's writes and vice versa, and multiple tabs, liveDebug / enabled instances / run injections of the same app all see each other's writes — this is the recommended way to keep a page UI in sync with a background branch. The watch scope is strictly confined to this app's namespace: other apps can never be watched, and the local development copy and the marketplace-installed copy are invisible to each other (different bundleIds). key is an exact key, or null to watch the whole namespace (immediate is not supported for null).
| Option | Type | Default | Description |
|---|---|---|---|
immediate | boolean | false | Deliver a snapshot of the current value right after subscribing (reason immediate) to establish initial state |
includeSelf | boolean | false | Echo this instance's own writes back to it. Off by default (prevents write-notify feedback loops); other instances of the same app always receive them |
The callback receives { key, value, found, reason }: reason is set / remove / clear / immediate / resync; remove and clear deliver value: null with found: false. Semantics are state updates, not an event log: rapid writes to the same key within the coalescing window (~25ms) deliver only the final value, and values larger than 64KiB are not carried inline — the client transparently re-reads the current value before invoking the callback. When the transport drops (service-worker recycling / extension reload / BFCache) the client reconnects with backoff and re-subscribes with a resync snapshot, whose value may equal the previous one — treat callbacks as idempotent state updates. watch works in every execution environment: enabled/published apps, liveDebug, run --target background, run --tab (both MAIN and --world user-script), and run WITHOUT --tab (the editor sandbox). Environments without chrome.runtime.connect subscribe through the terminal bridge, where the host holds the real Port on the script's behalf — coalescing, resync, and unwatch behave identically.
Other details:
immediatefires once even for a missing key, delivering{ found: false, value: null, reason: 'immediate' }.- Each connection allows at most 256 watches. When the background rejects a subscription (for example over the limit), the error is only logged to the console —
watch()still returns an unwatch function and never throws. watch()throws synchronously only when the callback is missing, no channel is available in the current environment, orkeyisnullwhileimmediate: trueis passed.
Example
// @require store
const enabled = await LFStore.get("debug-enabled", true);
await LFStore.set("debug-enabled", !enabled);
const allKeys = await LFStore.keys();
await LFStore.remove("legacy-key");
// Watch changes: page and background branches of a both app receive each other’s writes
const unwatch = LFStore.watch("counter", (change) => {
// change: { key, value, found, reason }
LFRuntime.print("counter →", change.value, change.reason);
}, { immediate: true });
// Unsubscribe when finished; hot reload also cleans up automatically
// unwatch();