npm.io
0.7.0 • Published 3d ago

@addon-core/storage

Licence
MIT
Version
0.7.0
Deps
2
Size
185 kB
Vulns
0
Weekly
0
Stars
2

@addon-core/storage

A typed, extension-first layer over chrome.storage.

npm version npm downloads CI License: MIT

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 with subscribe();
  • 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:

  • namespace to isolate logical keys;
  • locker to replace the default Web Locks implementation;
  • key to group the state in one physical MonoStorage bucket;
  • area on storage() and storageSecure();
  • secureKey on storageSecure().
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.storage must be available in the extension context.
  • Web Locks are required for lock-coordinated operations unless a custom StorageLocker is supplied.
  • SecureStorage requires the Web Crypto API.
  • Native browser serialization behavior still applies.
  • Native storage quotas still apply, especially to the sync area.
  • 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.
  • managed storage 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.

License

MIT

Keywords