@addon-core/storage
A typed, extension-first layer over chrome.storage.
Use concise functional helpers for one-off reads and writes, or create a reusable provider for structured state, batch operations, lock-coordinated updates, namespaces, encrypted values, and change subscriptions. Start without a schema and add strict TypeScript types when your storage shape becomes stable.
Why @addon-core/storage
Browser extension state is often shared between popups, options pages, background workers, and other extension contexts. The native Storage API provides the persistence layer, but leaves typing, namespacing, safe read-modify-write flows, encryption, and logical change handling to application code.
@addon-core/storage keeps the native storage model while adding a consistent
API around it:
- start immediately with functional helpers and no required state interface;
- add strict key and value types without changing application code;
- use the same provider API across local, session, sync, managed, and secure storage;
- read and write multiple values through native batch operations;
- coordinate read-modify-write updates through Web Locks;
- observe individual keys with
watch()or complete events withsubscribe(); - bind values to React through
@addon-core/storage/react.
Installation
npm i @addon-core/storage
With pnpm or Yarn:
pnpm add @addon-core/storage
yarn add @addon-core/storage
Quick start
For a one-off operation, call an area helper directly:
import {storageLocal} from "@addon-core/storage";
await storageLocal("theme", "dark");
const theme = await storageLocal<"light" | "dark">("theme");
// "light" | "dark" | undefined
For a series of operations, create and reuse a typed provider:
import {storageSync} from "@addon-core/storage";
interface Settings {
language?: string;
theme?: "light" | "dark";
}
const settings = storageSync<Settings>({
namespace: "settings",
});
await settings.set({
language: "uk",
theme: "dark",
});
const values = await settings.get(["theme", "language"] as const);
// Partial<Pick<Settings, "theme" | "language">>
A state interface is optional. Without one, keys and values remain intentionally loose. Add a state type when the shape stabilizes and key/value mistakes should be caught by TypeScript.
Choose a storage area
The storage area controls persistence, synchronization, quotas, and whether data can be written. Prefer an area-specific helper so that intent stays visible in the call site.
| Area | Use it for | Helper |
|---|---|---|
local |
Persistent data on the current device | storageLocal() |
session |
Temporary state for the current browser session | storageSession() |
sync |
Small user preferences synchronized between signed-in browsers | storageSync() |
managed |
Read-only policies provided by an administrator | storageManaged() |
Use local when data does not need synchronization or session-only lifetime.
Use sync selectively because browser quotas are much stricter. The managed
area is controlled by the browser and is not writable by the extension.
The general storage() helper uses local by default and accepts an explicit
area when the area must be selected dynamically:
import {storage} from "@addon-core/storage";
const settings = storage<Settings>({
area: "sync",
namespace: "settings",
});
storageSecure() also accepts area because one secure helper covers all
storage areas.
Functional API
The functional API is the recommended entry point.
Available helpers
| Helper | Result |
|---|---|
storage() |
Plain provider; local by default or the supplied area |
storageLocal() |
Plain local provider |
storageSession() |
Plain session provider |
storageSync() |
Plain sync provider |
storageManaged() |
Plain managed provider |
storageSecure() |
Encrypted provider; local by default or the supplied area |
Supported call forms
Every helper supports the same one-shot and provider forms:
storageLocal(); // default StorageProvider
storageLocal({namespace: "settings"}); // configured StorageProvider
storageLocal("theme"); // single get
storageLocal("theme", "dark"); // single set
storageLocal(["theme", "language"]); // batch get
An object argument always means provider options. It is not interpreted as a batch set. Create a provider for object-form writes:
const local = storageLocal<Settings>();
await local.set({
language: "uk",
theme: "dark",
});
Typing helper calls
The meaning of the generic follows the call form.
For a single get or set, it describes the value:
const theme = await storageLocal<"light" | "dark">("theme");
await storageLocal<number>("attempts", 3);
For a batch get, it describes the result map:
const selected = await storageSync<{
language?: string;
theme?: "light" | "dark";
}>(["theme", "language"]);
For provider creation, it describes the complete storage state:
const settings = storageSync<Settings>({
namespace: "settings",
});
const theme = await settings.get("theme");
// "light" | "dark" | undefined
Without a generic, one-shot reads use any and providers accept arbitrary string
keys. Runtime validation still applies: top-level undefined is rejected,
reserved key characters still throw, and browser serialization rules are
unchanged.
Options and provider reuse
Common provider options are:
namespaceto isolate logical keys;lockerto replace the default Web Locks implementation;keyto group the state in one physical MonoStorage bucket;areaonstorage()andstorageSecure();secureKeyonstorageSecure().
const popup = storageLocal<Settings>({
key: "popup",
namespace: "ui",
});
Every helper invocation creates a new provider. Reuse the returned provider for a
series of operations, especially when using namespaces, a custom locker, or
secure storage. Each one-shot storageSecure() call creates a provider and
repeats the SHA-256 digest and AES key import.
Class API
The class API exposes the same providers and methods. Use it when class factories fit the surrounding architecture better.
Storage
import {Storage} from "@addon-core/storage";
const local = Storage.Local<Settings>();
const session = Storage.Session<Settings>();
const sync = Storage.Sync<Settings>({namespace: "settings"});
const managed = Storage.Managed<Settings>();
Storage.make() accepts an explicit area, and the constructor is available for
direct composition:
const dynamic = Storage.make<Settings>({area: "sync"});
const direct = new Storage<Settings>({area: "local"});
SecureStorage
import {SecureStorage} from "@addon-core/storage";
interface AuthState {
accessToken?: string;
refreshToken?: string;
}
const auth = SecureStorage.Session<AuthState>({
namespace: "auth",
secureKey: "AppSecret",
});
SecureStorage provides the same Local, Session, Sync, Managed, and
make factories as Storage.
MonoStorage
Pass key to a package factory to receive a MonoStorage provider backed by one
physical storage entry:
interface PopupState {
search?: string;
selectedTab?: "overview" | "history";
}
const popup = Storage.Local<PopupState>({
key: "popup",
});
The MonoStorage class is also exported for custom provider composition, but the
key factory option is the concise path for normal use.
Reading and writing
Every provider exposes one consistent API regardless of its area or physical storage model.
Read values
const theme = await settings.get("theme");
const selected = await settings.get(["theme", "language"] as const);
const all = await settings.getAll();
A missing single key resolves to undefined. Missing keys are omitted from batch
and getAll() results. An empty batch returns an empty object without native I/O.
Write values
await settings.set("theme", "dark");
await settings.set({
language: "uk",
theme: "dark",
});
The object overload uses one native storage.set() for plain and secure storage.
MonoStorage performs one locked update of its physical bucket.
Both set() forms reject undefined before encryption, locking, or native I/O.
Use remove() or return undefined from an update() updater to delete data.
null remains a valid value.
The object form must be a plain object. Arrays, functions, dates, maps, and class instances are rejected instead of being interpreted as key maps.
set() means “perform this write.” It does not skip a write merely because the
logical value is deeply equal. Change observers still filter logically equal
old and new values.
Remove values
await settings.remove("language");
await settings.remove(["language", "theme"]);
await settings.clear();
Batch remove uses one native removal call after acquiring the relevant locks.
clear() removes only the physical keys owned by that provider; its exact scope
is described under Namespaces and data scope.
Safe updates
Use update() whenever the next value depends on the previous value. A separate
get() followed by set() can lose concurrent changes made by another extension
context.
Single-key update
interface UsageState {
installCount?: number;
}
const usage = storageLocal<UsageState>({
namespace: "usage",
});
const count = await usage.update(
"installCount",
previous => (previous ?? 0) + 1
);
The updater may be synchronous or asynchronous. Returning undefined removes the
key. By default, deeply equal results skip the physical write.
Batch update
The array overload locks the selected keys in a stable order, reads one snapshot, and applies one returned patch:
const next = await settings.update(
["theme", "language"] as const,
previous => ({
language: previous.language ?? "en",
theme: previous.theme === "dark" ? "light" : "dark",
})
);
A selected key omitted from the patch stays unchanged. An own patch property set
to undefined removes that key. Returning an unselected key throws before any
write. The resolved object is the final snapshot of the selected keys, with
missing and removed keys omitted.
Equality comparison
A single-key comparer receives the previous and proposed values:
await settings.update(
"theme",
() => "dark",
{
compare: (previous, next) => previous === next,
}
);
A batch comparer receives the complete previous and proposed snapshots and makes one decision for the whole batch:
await settings.update(
["theme", "language"] as const,
previous => ({
language: previous.language ?? "en",
theme: "dark",
}),
{
compare: (previous, next) =>
previous.theme === next.theme &&
(previous.language ?? "en") === (next.language ?? "en"),
}
);
Returning true skips the update and resolves to the previous value or snapshot.
Returning false applies the explicit patch, including defined patch values that
are deeply equal to their stored values. It does not guarantee a logical
notification: observers filter equal old and new values.
Without a custom batch comparer, each explicit patch value is compared deeply. Only changed values are written, only existing requested keys are removed, and omitted keys remain untouched.
Lock timeout and cancellation
const controller = new AbortController();
await settings.update(
["theme", "language"] as const,
previous => ({
...previous,
theme: "dark",
}),
{
signal: controller.signal,
timeout: 500,
}
);
signal and timeout apply while a lock request is queued. Once a lock has been
granted, aborting the signal does not cancel the running updater. For a batch,
the timeout applies to each selected lock acquisition rather than one deadline
for the entire operation.
Locks coordinate only package operations that use the same lock names. Direct
set() calls on plain or secure storage and raw chrome.storage writes do not
participate, so locking is not a database transaction.
Mixed writes and deletions
For Storage and SecureStorage, a batch patch that both writes and deletes
requires one native set() followed by one native remove(). It can therefore
emit two native change events and invoke subscribe() twice. MonoStorage
changes one physical bucket and emits one bucket event.
If the set phase succeeds and the remove phase fails, update() rejects with
StoragePartialUpdateError. Its appliedSetKeys and
attemptedRemoveKeys contain logical keys, and cause preserves the native
removal error. No rollback is attempted. Extension context termination between
the two native calls can leave the same partial state without a JavaScript error.
Custom locker
Providers use WebLockManager by default. Supply a StorageLocker when another
coordination mechanism is required:
import {storageLocal, type StorageLocker} from "@addon-core/storage";
const locker: StorageLocker = {
async request(name, task) {
return await task();
},
};
const storage = storageLocal<{count?: number}>({
locker,
namespace: "custom-locking",
});
Reads do not require Web Locks. Single and batch update(), remove(),
clear(), and MonoStorage mutations are lock-coordinated. Direct plain and
secure set() calls do not acquire a lock.
Watching changes
Watch individual keys
watch() is key-oriented. A function watcher runs once for every changed
logical key:
const unsubscribe = settings.watch((next, previous, key) => {
console.log(key, previous, "->", next);
});
An object watcher handles only selected keys:
const unsubscribe = settings.watch({
theme(next, previous) {
console.log("theme", previous, "->", next);
},
language(next, previous) {
console.log("language", previous, "->", next);
},
});
Subscribe to complete events
subscribe() receives one logical change map for each matching package-level
event:
const unsubscribe = settings.subscribe(changes => {
if (changes.theme) {
console.log(
"theme",
changes.theme.oldValue,
"->",
changes.theme.newValue
);
}
if (changes.language) {
console.log(
"language",
changes.language.oldValue,
"->",
changes.language.newValue
);
}
});
| Method | Delivery |
|---|---|
watch() |
One callback per changed logical key |
subscribe() |
One callback with the event’s logical change map |
Both APIs filter by area, namespace, physical key shape, and deep equality.
SecureStorage decrypts values before delivery. MonoStorage expands its
physical bucket change into logical key changes. A callback is not invoked when
nothing changed logically.
Delivery order and unsubscribe
Events are formatted in FIFO order for each registration, so an earlier event’s callback is invoked before a later event’s callback. Returned callback promises are observed for rejection but are not awaited; asynchronous callbacks may overlap and cannot block later events.
The returned unsubscribe function is idempotent. It removes the native listener,
clears queued events, and prevents delivery after an in-progress format or
decrypt step finishes. A watch() handler can unsubscribe during a multi-key
event, preventing later handlers in the same fan-out from running.
Error behavior
Handle expected application failures inside the callback:
const unsubscribe = settings.subscribe(async changes => {
try {
await sendChangesToServer(changes);
} catch (error) {
console.error("Could not synchronize storage changes", error);
}
});
A synchronous callback throw or rejected callback promise is surfaced as an
uncaught asynchronous exception. It does not close the registration: sibling
watch() handlers and future events continue to run.
A corruption, decryption, or internal formatting failure is stricter. The
affected registration is disposed, its queued events are cleared, and the
current event is not delivered partially. The error is then surfaced
asynchronously. A try/catch around registration cannot catch an error produced
by a later native event. Separately registered listeners remain independent.
For SecureStorage, one corrupted matching entry in a multi-key native event
rejects that entire logical event, including valid sibling changes in the same
provider scope.
Background context behavior after an internal listener failure
In a persistent Manifest V2 background page, the failed registration stays disposed until the page reloads. A Manifest V3 service worker registers it again when the worker starts later. Repeated corrupted input can therefore fail again after subsequent worker wake-ups.
Namespaces and data scope
Use namespaces when modules may use the same logical key names:
const auth = storageLocal<{token?: string}>({
namespace: "auth",
});
const analytics = storageLocal<{token?: string}>({
namespace: "analytics",
});
The providers use different physical keys and do not observe, enumerate, or clear each other’s values.
A plain Storage provider without a namespace owns every one-segment plain key
in its area. Its getAll() and clear() exclude namespaced and secure entries,
but they do include unnamespaced MonoStorage buckets. A namespaced provider owns
keys with its exact namespace, including MonoStorage buckets created in that
same scope.
MonoStorage has no extra physical tag: its bucket is an ordinary logical key in the underlying plain or secure provider. A broad provider with the same area and namespace can therefore enumerate or clear that bucket. The MonoStorage provider itself reads, observes, and clears only its selected bucket.
The colon (:) is reserved as the physical key separator. Namespaces and
top-level logical keys used by Storage or SecureStorage cannot contain it.
The physical MonoStorage bucket key follows the same rule, while logical field
names inside the bucket may contain colons.
Keys that do not match a provider’s exact codec are ignored by its getAll(),
events, and clear().
SecureStorage
SecureStorage encrypts each logical value with AES-GCM before writing it to
native storage.
The recommended functional form is:
import {storageSecure} from "@addon-core/storage";
interface AuthState {
accessToken?: string;
refreshToken?: string;
}
const auth = storageSecure<AuthState>({
area: "local",
namespace: "auth",
secureKey: "AppSecret",
});
await auth.set("accessToken", "jwt-token");
const token = await auth.get("accessToken");
The class factories provide the same behavior:
import {SecureStorage} from "@addon-core/storage";
const auth = SecureStorage.Local<AuthState>({
namespace: "auth",
secureKey: "AppSecret",
});
Use the same stable secureKey whenever the values must be decrypted later.
The provider hashes it with SHA-256 and imports the result as an AES-GCM key.
The secure key is application-provided; this package does not provide a
hardware-backed secret store.
Physical key format
Secure keys always have three segments:
secure:<namespace-or-empty>:<logical-key>
Examples:
secure::theme
secure:auth:accessToken
This distinguishes unnamespaced secure data from plain
storageLocal({namespace: "secure"}) data such as secure:theme. The
ciphertext format remains iv:ciphertext.
Corrupted values and recovery
A present empty, non-string, or undecipherable value throws
StorageCorruptionError. Its provider and key identify the logical entry,
and cause preserves the format or decryption error.
One corrupted selected key rejects the whole batch get. One corrupted owned key
rejects getAll(). Reads do not silently fall back to defaults.
Known corrupted SecureStorage entries can be recovered without decrypting the old value:
set()replaces a known key;remove()deletes known keys;clear()enumerates and removes all keys owned by that secure provider.
remove() and clear() never decrypt the stored ciphertext.
MonoStorage
MonoStorage groups a feature’s logical state under one physical storage key.
interface PopupState {
filters?: string[];
search?: string;
selectedTab?: "overview" | "history";
}
const popup = storageLocal<PopupState>({
key: "popup",
});
await popup.set({
search: "open tabs",
selectedTab: "overview",
});
await popup.update(
"filters",
previous => [...(previous ?? []), "pinned"]
);
const state = await popup.getAll();
A MonoStorage mutation is a locked read-modify-write of the bucket. Single and
batch sets perform one bucket update and write even when supplied logical values
are deeply equal. A batch update changes the physical bucket once, so one native
event becomes one logical subscribe() callback.
A missing bucket is treated as empty. A present non-plain-object bucket is
corrupted and throws StorageCorruptionError instead of being overwritten as an
empty object. Removing the last logical field removes the physical bucket.
Corruption recovery
Recovery differs because SecureStorage stores values independently while MonoStorage must decode its complete bucket before changing one logical field.
| Provider | set() |
remove() |
clear() |
|---|---|---|---|
SecureStorage |
Replaces a known ciphertext without reading it | Removes known physical entries without decrypting | Removes all owned physical entries without decrypting |
MonoStorage over Storage |
Cannot replace one field in a corrupted bucket | Cannot remove one field from a corrupted bucket | Removes the physical bucket directly |
MonoStorage over SecureStorage |
Cannot decrypt and update a corrupted bucket | Cannot decrypt and update a corrupted bucket | Removes the encrypted physical bucket without decrypting |
Use clear() or an exact native removal to recover a corrupted MonoStorage
bucket. Its logical set(), update(), and remove() operations intentionally
fail instead of coercing damaged data into a new bucket.
React
The React adapter is available through @addon-core/storage/react.
import {useStorage} from "@addon-core/storage/react";
export function ThemeToggle() {
const [theme, setTheme] = useStorage<"light" | "dark">(
"theme",
"light"
);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Theme: {theme}
</button>
);
}
Pass a reusable provider when the hook should use a different area, namespace, or storage model:
import {storageSync} from "@addon-core/storage";
import {useStorage} from "@addon-core/storage/react";
interface Settings {
theme?: "light" | "dark";
}
const settings = storageSync<Settings>({
namespace: "settings",
});
export function ThemeToggle() {
const [theme, setTheme] = useStorage({
defaultValue: "light",
key: "theme",
storage: settings,
});
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Theme: {theme}
</button>
);
}
API reference
Provider methods
Every StorageProvider exposes:
| Method | Purpose |
|---|---|
get(key) |
Read one value |
get(keys) |
Read selected values in one batch |
getAll() |
Read every logical value owned by the provider |
set(key, value) |
Write one value |
set(values) |
Write a plain-object batch |
update(key, updater, options?) |
Lock and update one value |
update(keys, updater, options?) |
Lock and update a selected snapshot |
remove(key | keys, options?) |
Remove one or several values |
clear(options?) |
Remove every value owned by the provider |
watch(callback | handlers) |
Observe changes per logical key |
subscribe(callback) |
Observe one logical change map per event |
Public errors
| Error | Meaning |
|---|---|
StorageCorruptionError |
A stored secure value or MonoStorage bucket cannot be decoded safely |
StoragePartialUpdateError |
A mixed plain/secure batch update wrote values but failed during removal |
Requirements and limits
chrome.storagemust be available in the extension context.- Web Locks are required for lock-coordinated operations unless a custom
StorageLockeris supplied. SecureStoragerequires the Web Crypto API.- Native browser serialization behavior still applies.
- Native storage quotas still apply, especially to the
syncarea. - An object-form
set()uses at most one native set operation, but does not bypass total, per-item, item-count, or write-rate quotas. - A mixed plain or secure batch update uses two native mutation calls.
managedstorage is browser-controlled and read-only. A mutation rejects when it reaches native storage, while a package-level no-op may resolve without a native call.