Skip to content

extforge/storage

extforge/storage is a typed wrapper over chrome.storage.{local,sync,session,managed}. It adds:

  • Per-instance namespacing
  • A typed watch() API
  • A transparent localStorage fallback for content scripts running on real web pages where chrome.storage isn’t accessible
  • A React hook (useStorage) in a separate subpath so the core stays React-free
import { Storage } from 'extforge/storage';
const store = new Storage({ area: 'local', namespace: 'app:v1' });
await store.set('user', { id: 1, name: 'Alice' });
const user = await store.get<{ id: number; name: string }>('user');
const unwatch = store.watch({
user: (next, prev) => console.log('user changed', prev, '', next),
'*': (next, prev) => console.log('any key changed'),
});
OptionTypeDefaultDescription
area'local' | 'sync' | 'session' | 'managed''local'Chrome storage area to use
namespacestring''Prefix prepended to every key — lets multiple Storage instances coexist in the same area
preferChromeStoragebooleantrueIf false, force the localStorage fallback (mainly useful in tests)
MethodDescription
get<T>(key)Read a value. Returns undefined if missing
set<T>(key, value)Persist a value
remove(key)Delete a key
clear()Remove every key. With namespace set, only namespaced keys are removed
watch(handlers)Subscribe to changes. handlers is keyed by un-namespaced key, plus '*' for every change. Returns an unwatch() function
import { useStorage } from 'extforge/storage/react';
function Counter() {
const { value, setValue, remove, isLoading } = useStorage<number>('count', 0);
if (isLoading) return null;
return (
<button onClick={() => setValue((value ?? 0) + 1)}>
Clicked {value} times
</button>
);
}

The hook reuses a singleton Storage instance per (area, namespace) so re-mounts don’t tear down the watch subscription.

If chrome.storage isn’t available (e.g. content script on a page where the API isn’t injected, or jsdom test runners), Storage writes to localStorage with the same namespacing rules. Cross-context sync via the storage window event isn’t wired in by default — keep the issue tracker updated if you need it.

Every value is JSON.stringify’d on write (including strings), so a JSON-shaped string like '{"a":1}' round-trips back as the same string rather than being parsed into {a:1}. Legacy entries that aren’t valid JSON still read back as the raw string.

When the localStorage fallback’s setItem rejects for quota reasons, Storage.set throws a typed error you can catch and act on (evict, warn, fall through). The underlying DOMException is attached as cause.

import { Storage, StorageQuotaExceededError } from 'extforge/storage';
const store = new Storage();
try {
await store.set('big', huge);
} catch (err) {
if (err instanceof StorageQuotaExceededError) {
console.warn(`Quota exceeded for ${err.key}`);
await store.clear(); // or evict selectively
} else {
throw err;
}
}

The chrome.storage path doesn’t throw StorageQuotaExceededError — Chrome surfaces its own quota error via chrome.runtime.lastError. Wrap calls accordingly if you target both.