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'),
});
Option Type Default Description
area 'local' | 'sync' | 'session' | 'managed' 'local' Chrome storage area to use
namespace string '' Prefix prepended to every key — lets multiple Storage instances coexist in the same area
preferChromeStorage boolean true If false, force the localStorage fallback (mainly useful in tests)
Method Description
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.

Export Kind Notes
Storage class The main wrapper (see above).
StorageQuotaExceededError class Thrown by the localStorage fallback on quota errors; carries key and cause.
StorageArea type 'local' | 'sync' | 'session' | 'managed'.
StorageOptions interface Constructor options: { area?; namespace?; preferChromeStorage? }.
WatchHandler<T> type (newValue: T | undefined, oldValue: T | undefined) => void.
WatchHandlers type Record<string, WatchHandler> — the shape passed to watch().
Unwatch type () => void — the unsubscribe function returned by watch().
UseStorageReturn<T> interface Return type of the extforge/storage/react hook: { value; setValue; remove; isLoading }.