feat!: New wxt/storage APIs (#300)

BREAKING CHANGE: `wxt/storage` no longer relies on `unstorage`. Some `unstorage` APIs, like `prefixStorage`, have been removed, while others, like `snapshot`, are methods on the new `storage` object. Most of the standard usage remains the same.
This commit is contained in:
Aaron
2023-12-27 11:00:10 -06:00
committed by GitHub
parent 86182044d1
commit d1b9e5ded6
6 changed files with 1555 additions and 545 deletions
+730 -9
View File
@@ -1,11 +1,12 @@
import { fakeBrowser } from '@webext-core/fake-browser';
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { browser } from '~/browser';
import { storage } from '~/storage';
describe('Storage Utils', () => {
beforeEach(() => {
fakeBrowser.reset();
storage.unwatch();
});
describe.each(['local', 'sync', 'managed', 'session'] as const)(
@@ -15,33 +16,80 @@ describe('Storage Utils', () => {
it('should return the value from the correct storage area', async () => {
const expected = 123;
await fakeBrowser.storage[storageArea].set({ count: expected });
expect(await storage.getItem(`${storageArea}:count`)).toBe(expected);
const actual = await storage.getItem(`${storageArea}:count`);
expect(actual).toBe(expected);
});
it("should return null if the value doesn't exist", async () => {
expect(await storage.getItem(`${storageArea}:count`)).toBeNull();
const actual = await storage.getItem(`${storageArea}:count`);
expect(actual).toBeNull();
});
it('should return the default value if passed in options', async () => {
const expected = 0;
const actual = await storage.getItem(`${storageArea}:count`, {
defaultValue: expected,
});
expect(actual).toBe(expected);
});
});
describe('getItems', () => {
it('should return an array of values in the same order as the keys passed in', async () => {
it('should return an array of values', async () => {
const expected = [
{ key: `${storageArea}:count`, value: 234 },
{ key: `${storageArea}:installDate`, value: null },
{ key: `${storageArea}:otherValue`, value: 345 },
];
const params = [
expected[0].key,
expected[1].key,
{
key: expected[2].key,
options: { defaultValue: expected[2].value },
},
];
const keys = expected.map((item) => item.key);
await fakeBrowser.storage[storageArea].set({
count: expected[0].value,
});
const actual = await storage.getItems(keys);
const actual = await storage.getItems(params);
expect(actual).toHaveLength(3);
expected.forEach((item) => {
expect(actual).toContainEqual(item);
});
});
});
describe('getMeta', () => {
it('should return item metadata from key+$', async () => {
const expected = { v: 1 };
await fakeBrowser.storage[storageArea].set({ count$: expected });
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
});
it('should return an empty object if missing', async () => {
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({});
});
});
describe('setItem', () => {
it('should set the value in the correct storage area', () => {});
it('should set the value in the correct storage area', async () => {
const key = `${storageArea}:count`;
const value = 321;
await storage.setItem(key, value);
});
it.each([undefined, null])(
'should remove the item from storage when setting the value to %s',
@@ -57,12 +105,685 @@ describe('Storage Utils', () => {
);
});
describe('setItems', () => {
it('should set multiple items in storage', async () => {
const expected = [
{ key: `${storageArea}:count`, value: 234 },
{ key: `${storageArea}:installDate`, value: null },
];
await fakeBrowser.storage[storageArea].set({
count: 123,
installDate: 321,
});
await storage.setItems(expected);
const actual = await storage.getItems(
expected.map((item) => item.key),
);
expect(actual).toHaveLength(2);
expected.forEach((item) => {
expect(actual).toContainEqual(item);
});
});
});
describe('setMeta', () => {
it('should set metadata at key+$', async () => {
const existing = { v: 1 };
await browser.storage[storageArea].set({ count$: existing });
const newValues = {
date: Date.now(),
};
const expected = { ...existing, ...newValues };
await storage.setMeta(`${storageArea}:count`, newValues);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
});
it.each([undefined, null])(
'should remove any properties set to %s',
async (version) => {
const existing = { v: 1 };
await browser.storage[storageArea].set({ count$: existing });
const expected = {};
await storage.setMeta(`${storageArea}:count`, { v: version });
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
},
);
});
describe('removeItem', () => {
it('should remove the key from storage', async () => {
await fakeBrowser.storage[storageArea].set({ count: 456 });
await storage.removeItem(`${storageArea}:count`);
expect(await browser.storage[storageArea].get()).toEqual({});
await storage.removeItem(`${storageArea}:count`);
const actual = await storage.getItem(`${storageArea}:count`);
expect(actual).toBeNull();
});
it('should not remove the metadata by default', async () => {
const expected = { v: 1 };
await fakeBrowser.storage[storageArea].set({
count$: expected,
count: 3,
});
await storage.removeItem(`${storageArea}:count`);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
});
it('should remove the metadata when requested', async () => {
await fakeBrowser.storage[storageArea].set({
count$: { v: 1 },
count: 3,
});
await storage.removeItem(`${storageArea}:count`, {
removeMeta: true,
});
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({});
});
});
describe('removeItems', () => {
it('should remove multiple items', async () => {
const key1 = `${storageArea}:one`;
const key2 = `${storageArea}:two`;
const key3 = `${storageArea}:three`;
await fakeBrowser.storage[storageArea].set({
['one']: '1',
['two']: null,
['two$']: { v: 1 },
['three']: '1',
['three$']: { v: 1 },
});
await storage.removeItems([
key1,
key2,
{ key: key3, options: { removeMeta: true } },
]);
expect(await storage.getItem(key1)).toBeNull();
expect(await storage.getItem(key2)).toBeNull();
expect(await storage.getMeta(key2)).toEqual({ v: 1 });
expect(await storage.getItem(key3)).toBeNull();
expect(await storage.getMeta(key3)).toEqual({});
});
});
describe('removeMeta', () => {
it('should remove all metadata', async () => {
await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } });
await storage.removeMeta(`${storageArea}:count`);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({});
});
it('should only remove specific properties', async () => {
await fakeBrowser.storage[storageArea].set({
count$: { v: 4, d: Date.now() },
});
await storage.removeMeta(`${storageArea}:count`, ['d']);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({ v: 4 });
});
});
describe('snapshot', () => {
it('should return a snapshot of the entire storage without area prefixes', async () => {
const expected = {
count: 1,
count$: { v: 2 },
example: 'test',
};
await fakeBrowser.storage[storageArea].set(expected);
const actual = await storage.snapshot(storageArea);
expect(actual).toEqual(expected);
});
it('should exclude specific properties and their metadata', async () => {
const input = {
count: 1,
count$: { v: 2 },
example: 'test',
};
const excludeKeys = ['count'];
const expected = {
example: 'test',
};
await fakeBrowser.storage[storageArea].set(input);
const actual = await storage.snapshot(storageArea, { excludeKeys });
expect(actual).toEqual(expected);
});
});
describe('restoreSnapshot', () => {
it('should restore a snapshot object by setting all values in storage', async () => {
const data = {
one: 'one',
two: 'two',
};
const existing = {
two: 'two-two',
three: 'three',
};
await fakeBrowser.storage[storageArea].set(existing);
await storage.restoreSnapshot(storageArea, data);
const actual = await storage.snapshot(storageArea);
expect(actual).toEqual({ ...existing, ...data });
});
it('should overwrite, not merge, any metadata keys in the snapshot', async () => {
const existing = {
count: 1,
count$: {
v: 2,
},
};
const data = {
count$: {
restoredAt: Date.now(),
},
};
const expected = {
...existing,
count$: data.count$,
};
await fakeBrowser.storage[storageArea].set(existing);
await storage.restoreSnapshot(storageArea, data);
const actual = await storage.snapshot(storageArea);
expect(actual).toEqual(expected);
});
});
describe('watch', () => {
it('should not trigger if the changed key is different from the requested key', async () => {
const cb = vi.fn();
storage.watch(`${storageArea}:key`, cb);
await storage.setItem(`${storageArea}:not-the-key`, '123');
expect(cb).not.toBeCalled();
});
it("should not trigger if the value doesn't change", async () => {
const cb = vi.fn();
const value = '123';
await storage.setItem(`${storageArea}:key`, value);
storage.watch(`${storageArea}:key`, cb);
await storage.setItem(`${storageArea}:key`, value);
expect(cb).not.toBeCalled();
});
it('should call the callback when the value changes', async () => {
const cb = vi.fn();
const newValue = '123';
const oldValue = null;
storage.watch(`${storageArea}:key`, cb);
await storage.setItem(`${storageArea}:key`, newValue);
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith(newValue, oldValue);
});
it('should remove the listener when calling the returned function', async () => {
const cb = vi.fn();
const unwatch = storage.watch(`${storageArea}:key`, cb);
unwatch();
await storage.setItem(`${storageArea}:key`, '123');
expect(cb).not.toBeCalled();
});
});
describe('unwatch', () => {
it('should remove all watch listeners', async () => {
const cb = vi.fn();
storage.watch(`${storageArea}:key`, cb);
storage.unwatch();
await storage.setItem(`${storageArea}:key`, '123');
expect(cb).not.toBeCalled();
});
});
describe('defineItem', () => {
describe('versioning', () => {
/**
* This works because fakeBrowser is syncrounous, and is will finish any number of chained
* calls within a single tick of the event loop, ie: a timeout of 0.
*/
async function waitForMigrations() {
return new Promise((res) => setTimeout(res));
}
it('should migrate values to the latest when a version upgrade is detected', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
count$: { v: 1 },
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(12);
expect(actualMeta).toEqual({ v: 3 });
expect(migrateToV2).toBeCalledTimes(1);
expect(migrateToV2).toBeCalledWith(2);
expect(migrateToV3).toBeCalledTimes(1);
expect(migrateToV3).toBeCalledWith(4);
});
it("should not run migrations if the value doesn't exist yet", async () => {
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toBeNull();
expect(actualMeta).toEqual({});
expect(migrateToV2).not.toBeCalled();
expect(migrateToV3).not.toBeCalled();
});
it('should run the v2 migration when converting an unversioned item to a versioned one', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 2,
migrations: {
2: migrateToV2,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(4);
expect(actualMeta).toEqual({ v: 2 });
expect(migrateToV2).toBeCalledTimes(1);
expect(migrateToV2).toBeCalledWith(2);
});
it('Should not run old migrations if the version is unchanged', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
count$: { v: 3 },
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
storage.defineItem<number, { v: number }>(`${storageArea}:count`, {
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
});
await waitForMigrations();
expect(migrateToV2).not.toBeCalled();
expect(migrateToV3).not.toBeCalled();
});
it('should skip missing migration functions', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
count$: { v: 0 },
});
const migrateToV1 = vi.fn((oldCount) => oldCount * 1);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 3,
migrations: {
1: migrateToV1,
3: migrateToV3,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(6);
expect(actualMeta).toEqual({ v: 3 });
expect(migrateToV1).toBeCalledTimes(1);
expect(migrateToV1).toBeCalledWith(2);
expect(migrateToV3).toBeCalledTimes(1);
expect(migrateToV3).toBeCalledWith(2);
});
it('should throw an error if the new version is less than the previous version', async () => {
const prevVersion = 2;
const nextVersion = 1;
await fakeBrowser.storage[storageArea].set({
count: 0,
count$: { v: prevVersion },
});
const item = storage.defineItem(`${storageArea}:count`, {
version: nextVersion,
});
// @ts-expect-error: _migrationsCompleted is returned, but untyped
await expect(item._migrationsCompleted).rejects.toThrow(
'version downgrade detected',
);
});
});
describe('getValue', () => {
it('should return the value from storage', async () => {
const expected = 2;
const item = storage.defineItem<number>(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({ count: expected });
const actual = await item.getValue();
expect(actual).toBe(expected);
});
it('should return null if missing', async () => {
const item = storage.defineItem<number>(`${storageArea}:count`);
const actual = await item.getValue();
expect(actual).toBeNull();
});
it('should return the provided default value if missing', async () => {
const expected = 0;
const item = storage.defineItem(`${storageArea}:count`, {
defaultValue: expected,
});
const actual = await item.getValue();
expect(actual).toEqual(expected);
});
});
describe('getMeta', () => {
it('should return the value from storage at key+$', async () => {
const expected = { v: 2 };
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
);
await fakeBrowser.storage[storageArea].set({ count$: expected });
const actual = await item.getMeta();
expect(actual).toBe(expected);
});
it('should return an empty object if missing', async () => {
const expected = {};
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
);
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
});
describe('setValue', () => {
it('should set the value in storage', async () => {
const expected = 1;
const item = storage.defineItem<number>(`${storageArea}:count`);
await item.setValue(expected);
const actual = await item.getValue();
expect(actual).toBe(expected);
});
it.each([undefined, null])(
'should remove the value in storage when %s is passed in',
async (value) => {
const item = storage.defineItem<number>(`${storageArea}:count`);
// @ts-expect-error: undefined is not assignable to null, but we're testing that case on purpose
await item.setValue(value);
const actual = await item.getValue();
expect(actual).toBeNull();
},
);
});
describe('setMeta', () => {
it('should set metadata at key+$', async () => {
const expected = { date: Date.now() };
const item = storage.defineItem<number, { date: number }>(
`${storageArea}:count`,
);
await item.setMeta(expected);
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
it('should add to metadata if already present', async () => {
const existing = { v: 2 };
const newFields = { date: Date.now() };
const expected = { ...existing, ...newFields };
const item = storage.defineItem<
number,
{ date: number; v: number }
>(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({
count$: existing,
});
await item.setMeta(newFields);
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
});
describe('removeValue', () => {
it('should remove the key from storage', async () => {
const item = storage.defineItem(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({ count: 456 });
await item.removeValue();
const actual = await item.getValue();
expect(actual).toBeNull();
});
it('should not remove the metadata by default', async () => {
const item = storage.defineItem(`${storageArea}:count`);
const expected = { v: 1 };
await fakeBrowser.storage[storageArea].set({
count$: expected,
count: 3,
});
await item.removeValue();
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
it('should remove the metadata when requested', async () => {
const item = storage.defineItem(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({
count$: { v: 1 },
count: 3,
});
await item.removeValue({ removeMeta: true });
const actual = await item.getMeta();
expect(actual).toEqual({});
});
});
describe('removeMeta', () => {
it('should remove all metadata', async () => {
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
);
await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } });
await item.removeMeta();
const actual = await item.getMeta();
expect(actual).toEqual({});
});
it('should only remove specific properties', async () => {
const item = storage.defineItem<number, { v: number; d: number }>(
`${storageArea}:count`,
);
await fakeBrowser.storage[storageArea].set({
count$: { v: 4, d: Date.now() },
});
await item.removeMeta(['d']);
const actual = await item.getMeta();
expect(actual).toEqual({ v: 4 });
});
});
describe('watch', () => {
it("should not trigger if the changed key is different from the item's key", async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
item.watch(cb);
await storage.setItem(`${storageArea}:not-the-key`, '123');
expect(cb).not.toBeCalled();
});
it("should not trigger if the value doesn't change", async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
const value = '123';
await item.setValue(value);
item.watch(cb);
await item.setValue(value);
expect(cb).not.toBeCalled();
});
it('should call the callback when the value changes', async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
const newValue = '123';
const oldValue = null;
item.watch(cb);
await item.setValue(newValue);
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith(newValue, oldValue);
});
it('should remove the listener when calling the returned function', async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
const unwatch = item.watch(cb);
unwatch();
await item.setValue('123');
expect(cb).not.toBeCalled();
});
});
describe('unwatch', () => {
it('should remove all watch listeners', async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
item.watch(cb);
storage.unwatch();
await item.setValue('123');
expect(cb).not.toBeCalled();
});
});
});
},
+570 -101
View File
@@ -1,118 +1,587 @@
/**
* Simplfied storage APIs with support for versioned fields, snapshots, metadata, and item definitions.
*
* See [the guide](https://wxt.dev/guide/storage.html) for more information.
*
* @module wxt/storage
*/
import {
Driver,
WatchCallback,
createStorage,
defineDriver,
Storage,
} from 'unstorage';
import { browser, Storage as BrowserStorage } from '~/browser';
import { Storage, browser } from '~/browser';
import { dequal } from 'dequal/lite';
export interface WebExtensionDriverOptions {
storageArea: 'sync' | 'local' | 'managed' | 'session';
}
export const storage = createStorage();
export const webExtensionDriver: (opts: WebExtensionDriverOptions) => Driver =
defineDriver((opts) => {
const checkPermission = () => {
if (browser.storage == null)
throw Error(
"You must request the 'storage' permission to use webExtensionDriver",
);
};
const _storageListener: (
changes: BrowserStorage.StorageAreaSyncOnChangedChangesType,
) => void = (changes) => {
Object.entries(changes).forEach(([key, { newValue }]) => {
_listeners.forEach((callback) => {
callback(newValue ? 'update' : 'remove', key);
});
});
};
const _listeners = new Set<WatchCallback>();
function createStorage(): WxtStorage {
const drivers: Record<string, WxtStorageDriver> = {
local: createDriver('local'),
session: createDriver('session'),
sync: createDriver('sync'),
managed: createDriver('managed'),
};
const getDriver = (area: string) => {
const driver = drivers[area];
if (driver == null) {
const areaNames = Object.keys(drivers).join(', ');
throw Error(`Invalid area "${area}". Options: ${areaNames}`);
}
return driver;
};
const resolveKey = (key: string) => {
const [driverArea, driverKey] = key.split(':', 2);
if (driverKey == null)
throw Error(
`Storage key should be in the form of "area:key", but recieved "${key}"`,
);
return {
name: 'web-extension:' + opts.storageArea,
async hasItem(key) {
checkPermission();
const res = await browser.storage[opts.storageArea].get(key);
return res[key] != null;
},
async getItem(key) {
checkPermission();
const res = await browser.storage[opts.storageArea].get(key);
return res[key] ?? null;
},
async getItems(items) {
checkPermission();
const res = await browser.storage[opts.storageArea].get(
items.map((item) => item.key),
driverArea,
driverKey,
driver: getDriver(driverArea),
};
};
const getMetaKey = (key: string) => key + '$';
const getValueOrDefault = (value: any, defaultValue: any) =>
value ?? defaultValue ?? null;
const getMetaValue = (properties: any) =>
typeof properties === 'object' && !Array.isArray(properties)
? properties
: {};
const getItem = async (
driver: WxtStorageDriver,
driverKey: string,
opts: GetItemOptions<any> | undefined,
) => {
const res = await driver.getItem<any>(driverKey);
return getValueOrDefault(res, opts?.defaultValue);
};
const getMeta = async (driver: WxtStorageDriver, driverKey: string) => {
const metaKey = getMetaKey(driverKey);
const res = await driver.getItem<any>(metaKey);
return getMetaValue(res);
};
const setItem = async (
driver: WxtStorageDriver,
driverKey: string,
value: any,
) => {
await driver.setItem(driverKey, value ?? null);
};
const setMeta = async (
driver: WxtStorageDriver,
driverKey: string,
properties: any | undefined,
) => {
const metaKey = getMetaKey(driverKey);
const existingFields = getMetaValue(await driver.getItem(metaKey));
const newFields = { ...existingFields };
Object.entries(properties).forEach(([key, value]) => {
if (value == null) {
delete newFields[key];
} else {
newFields[key] = value;
}
});
await driver.setItem(metaKey, newFields);
};
const removeItem = async (
driver: WxtStorageDriver,
driverKey: string,
opts: RemoveItemOptions | undefined,
) => {
await driver.removeItem(driverKey);
if (opts?.removeMeta) {
const metaKey = getMetaKey(driverKey);
await driver.removeItem(metaKey);
}
};
const removeMeta = async (
driver: WxtStorageDriver,
driverKey: string,
properties: string | string[] | undefined,
) => {
const metaKey = getMetaKey(driverKey);
if (properties == null) {
await driver.removeItem(metaKey);
} else {
const newFields = getMetaValue(await driver.getItem(metaKey));
[properties].flat().forEach((field) => delete newFields[field]);
await driver.setItem(metaKey, newFields);
}
};
const watch = (
driver: WxtStorageDriver,
driverKey: string,
cb: WatchCallback<any>,
) => {
return driver.watch(driverKey, cb);
};
const storage: WxtStorage = {
getItem: async (key, opts) => {
const { driver, driverKey } = resolveKey(key);
return await getItem(driver, driverKey, opts);
},
getItems: async (keys) => {
const areaToKeyMap = new Map<string, string[]>();
const keyToOptsMap = new Map<string, GetItemOptions<any> | undefined>();
keys.forEach((key) => {
let keyStr: string;
let opts: GetItemOptions<any> | undefined;
if (typeof key === 'string') {
keyStr = key;
} else {
keyStr = key.key;
opts = key.options;
}
const { driverArea, driverKey } = resolveKey(keyStr);
const keys = areaToKeyMap.get(driverArea) ?? [];
areaToKeyMap.set(driverArea, keys.concat(driverKey));
keyToOptsMap.set(keyStr, opts);
});
const results = await Promise.all(
Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => {
const driverResults = await drivers[driverArea].getItems(keys);
return driverResults.map((driverResult) => {
const key = `${driverArea}:${driverResult.key}`;
const value = getValueOrDefault(
driverResult.value,
keyToOptsMap.get(key)?.defaultValue,
);
return { key, value };
});
}),
);
return results.flat();
},
getMeta: async (key) => {
const { driver, driverKey } = resolveKey(key);
return await getMeta(driver, driverKey);
},
setItem: async (key, value) => {
const { driver, driverKey } = resolveKey(key);
await setItem(driver, driverKey, value);
},
setItems: async (values) => {
const areaToKeyValueMap = new Map<
string,
Array<{ key: string; value: any }>
>();
values.forEach(({ key, value }) => {
const { driverArea, driverKey } = resolveKey(key);
const values = areaToKeyValueMap.get(driverArea) ?? [];
areaToKeyValueMap.set(
driverArea,
values.concat({ key: driverKey, value }),
);
return items.map((item) => ({
key: item.key,
value: res[item.key] ?? null,
}));
},
async setItem(key, value) {
checkPermission();
await browser.storage[opts.storageArea].set({ [key]: value ?? null });
},
async setItems(items) {
checkPermission();
const map = items.reduce<Record<string, any>>((map, item) => {
map[item.key] = item.value ?? null;
return map;
}, {});
await browser.storage[opts.storageArea].set(map);
},
async removeItem(key) {
checkPermission();
await browser.storage[opts.storageArea].remove(key);
},
async getKeys() {
checkPermission();
const all = await browser.storage[opts.storageArea].get();
return Object.keys(all);
},
async clear() {
checkPermission();
await browser.storage[opts.storageArea].clear();
},
watch(callback) {
checkPermission();
_listeners.add(callback);
if (_listeners.size === 1) {
browser.storage[opts.storageArea].onChanged.addListener(
_storageListener,
});
await Promise.all(
Array.from(areaToKeyValueMap.entries()).map(
async ([driverArea, values]) => {
const driver = getDriver(driverArea);
await driver.setItems(values);
},
),
);
},
setMeta: async (key, properties) => {
const { driver, driverKey } = resolveKey(key);
await setMeta(driver, driverKey, properties);
},
removeItem: async (key, opts) => {
const { driver, driverKey } = resolveKey(key);
await removeItem(driver, driverKey, opts);
},
removeItems: async (keys) => {
const areaToKeysMap = new Map<string, string[]>();
keys.forEach((key) => {
let keyStr: string;
let opts: RemoveItemOptions | undefined;
if (typeof key === 'string') {
keyStr = key;
} else {
keyStr = key.key;
opts = key.options;
}
const { driverArea, driverKey } = resolveKey(keyStr);
const areaKeys = areaToKeysMap.get(driverArea) ?? [];
areaKeys.push(driverKey);
if (opts?.removeMeta) {
areaKeys.push(getMetaKey(driverKey));
}
areaToKeysMap.set(driverArea, areaKeys);
});
await Promise.all(
Array.from(areaToKeysMap.entries()).map(async ([driverArea, keys]) => {
const driver = getDriver(driverArea);
await driver.removeItems(keys);
}),
);
},
removeMeta: async (key, properties) => {
const { driver, driverKey } = resolveKey(key);
await removeMeta(driver, driverKey, properties);
},
snapshot: async (base, opts) => {
const driver = getDriver(base);
const data = await driver.snapshot();
opts?.excludeKeys?.forEach((key) => {
delete data[key];
delete data[getMetaKey(key)];
});
return data;
},
restoreSnapshot: async (base, data) => {
const driver = getDriver(base);
await driver.restoreSnapshot(data);
},
watch: (key, cb) => {
const { driver, driverKey } = resolveKey(key);
return watch(driver, driverKey, cb);
},
unwatch() {
Object.values(drivers).forEach((driver) => {
driver.unwatch();
});
},
defineItem: (key, opts) => {
const { driver, driverKey } = resolveKey(key);
const { version: targetVersion = 1, migrations = {} } = opts ?? {};
if (targetVersion < 1) {
throw Error(
'Storage item version cannot be less than 1. Initial versions should be set to 1, not 0.',
);
}
const runMigrations = async () => {
const [value, meta] = await Promise.all([
// TODO: Optimize with getItems
getItem(driver, driverKey, undefined),
getMeta(driver, driverKey),
]);
if (value == null) return;
const currentVersion = meta.v ?? 1;
if (currentVersion > targetVersion) {
throw Error(
`[wxt/storage] Migration ignored for "${key}", version downgrade detected (${currentVersion} -> ${targetVersion})`,
);
}
return () => {
_listeners.delete(callback);
if (_listeners.size === 0) {
browser.storage[opts.storageArea].onChanged.removeListener(
_storageListener,
);
}
};
},
};
});
const migrationsToRun = Array.from(
{ length: targetVersion - currentVersion },
(_, i) => currentVersion + i + 1,
);
let migratedValue = value;
for (const migrateToVersion of migrationsToRun) {
migratedValue =
(await migrations?.[migrateToVersion]?.(migratedValue)) ??
migratedValue;
}
await Promise.all([
// TODO: Optimize with `setItem`
setItem(driver, driverKey, migratedValue),
setMeta(driver, driverKey, { v: targetVersion }),
]);
};
let _migrationsCompleted = runMigrations();
function createWebExtensionStorage() {
const storage = createStorage();
storage.mount('local', webExtensionDriver({ storageArea: 'local' }));
storage.mount('session', webExtensionDriver({ storageArea: 'session' }));
storage.mount('sync', webExtensionDriver({ storageArea: 'sync' }));
storage.mount('managed', webExtensionDriver({ storageArea: 'managed' }));
return {
_migrationsCompleted,
getValue: () => getItem(driver, driverKey, opts),
getMeta: () => getMeta(driver, driverKey),
setValue: (value) => setItem(driver, driverKey, value),
setMeta: (properties) => setMeta(driver, driverKey, properties),
removeValue: (opts) => removeItem(driver, driverKey, opts),
removeMeta: (properties) => removeMeta(driver, driverKey, properties),
watch: (cb) => watch(driver, driverKey, cb),
};
},
};
return storage;
}
export type StorageValue = null | string | number | boolean | object;
function createDriver(
storageArea: 'local' | 'session' | 'sync' | 'managed',
): WxtStorageDriver {
const getStorageArea = () => {
if (browser.storage == null)
throw Error(
"You must add the 'storage' permission to your manifest to use 'wxt/storage'",
);
export const storage: Storage<StorageValue> = createWebExtensionStorage();
return browser.storage[storageArea];
};
const watchListeners = new Set<
(changes: Storage.StorageAreaOnChangedChangesType) => void
>();
return {
getItem: async (key) => {
const res = await getStorageArea().get(key);
return res[key];
},
getItems: async (keys) => {
const result = await getStorageArea().get(keys);
return keys.map((key) => ({ key, value: result[key] ?? null }));
},
setItem: async (key, value) => {
if (value == null) {
await getStorageArea().remove(key);
} else {
await getStorageArea().set({ [key]: value });
}
},
setItems: async (values) => {
const map = values.reduce<Record<string, unknown>>(
(map, { key, value }) => {
map[key] = value;
return map;
},
{},
);
await getStorageArea().set(map);
},
removeItem: async (key) => {
await getStorageArea().remove(key);
},
removeItems: async (keys) => {
await getStorageArea().remove(keys);
},
snapshot: async () => {
return await getStorageArea().get();
},
restoreSnapshot: async (data) => {
await getStorageArea().set(data);
},
watch(key, cb) {
const listener = (changes: Storage.StorageAreaOnChangedChangesType) => {
const change = changes[key];
if (change == null) return;
if (dequal(change.newValue, change.oldValue)) return;
cb(change.newValue ?? null, change.oldValue ?? null);
};
getStorageArea().onChanged.addListener(listener);
watchListeners.add(listener);
return () => {
getStorageArea().onChanged.removeListener(listener);
watchListeners.delete(listener);
};
},
unwatch() {
watchListeners.forEach((listener) => {
getStorageArea().onChanged.removeListener(listener);
});
watchListeners.clear();
},
};
}
export * from 'unstorage';
export interface WxtStorage {
/**
* Get an item from storage, or return `null` if it doesn't exist.
*
* @example
* await storage.getItem<number>("local:installDate");
*/
getItem<T>(key: string, opts?: GetItemOptions<T>): Promise<T | null>;
/**
* Get multiple items from storage. There is no guarentee of order in the returned array.
*
* @example
* await storage.getItems(["local:installDate", "session:someCounter"]);
*/
getItems(
keys: Array<string | { key: string; options?: GetItemOptions<any> }>,
): Promise<Array<{ key: string; value: any }>>;
/**
* Return an object containing metadata about the key. Object is stored at `key + "$"`. If value
* is not an object, it returns an empty object.
*
* @example
* await storage.getMeta("local:installDate");
*/
getMeta<T extends Record<string, unknown>>(key: string): Promise<T>;
/**
* Set a value in storage. Setting a value to `null` or `undefined` is equivalent to calling
* `removeItem`.
*
* @example
* await storage.setItem<number>("local:installDate", Date.now());
*/
setItem<T>(key: string, value: T | null): Promise<void>;
/**
* Set multiple values in storage. If a value is set to `null` or `undefined`, the key is removed.
*
* @example
* await storage.setItem([
* { key: "local:installDate", value: Date.now() },
* { key: "session:someCounter, value: 5 },
* ]);
*/
setItems(values: Array<{ key: string; value: any }>): Promise<void>;
/**
* Sets metadata properties. If some properties are already set, but are not included in the
* `properties` parameter, they will not be removed.
*
* @example
* await storage.setMeta("local:installDate", { appVersion });
*/
setMeta<T extends Record<string, unknown>>(
key: string,
properties: T | null,
): Promise<void>;
/**
* Removes an item from storage.
*
* @example
* await storage.removeItem("local:installDate");
*/
removeItem(key: string, opts?: RemoveItemOptions): Promise<void>;
/**
* Remove a list of keys from storage.
*/
removeItems(
keys: Array<string | { key: string; options?: RemoveItemOptions }>,
): Promise<void>;
/**
* Remove the entire metadata for a key, or specific properties by name.
*
* @example
* // Remove all metadata properties from the item
* await storage.removeMeta("local:installDate");
*
* // Remove only specific the "v" field
* await storage.removeMeta("local:installDate", "v")
*/
removeMeta(key: string, properties?: string | string[]): Promise<void>;
/**
* Return all the items in storage.
*/
snapshot(
base: string,
opts?: SnapshotOptions,
): Promise<Record<string, unknown>>;
/**
* Restores the results of `snapshot`. If new properties have been saved since the snapshot, they are
* not overridden. Only values existing in the snapshot are overritten.
*/
restoreSnapshot(base: string, data: any): Promise<void>;
/**
* Watch for changes to a specific key in storage.
*/
watch<T>(key: string, cb: WatchCallback<T>): Unwatch;
/**
* Remove all watch listeners.
*/
unwatch(): void;
/**
* Define a constant with utilities for reading/writing to a single value in storage.
*
* @example
* export const installDate = storage.defineItem<number>("local:installDate");
*/
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: string,
options?: WxtStorageItemOptions<TValue>,
): WxtStorageItem<TValue, TMetadata>;
}
interface WxtStorageDriver {
getItem<T>(key: string): Promise<T | null>;
getItems(keys: string[]): Promise<{ key: string; value: any }[]>;
setItem<T>(key: string, value: T | null): Promise<void>;
setItems(values: Array<{ key: string; value: any }>): Promise<void>;
removeItem(key: string): Promise<void>;
removeItems(keys: string[]): Promise<void>;
snapshot(): Promise<Record<string, unknown>>;
restoreSnapshot(data: Record<string, unknown>): Promise<void>;
watch<T>(key: string, cb: WatchCallback<T>): Unwatch;
unwatch(): void;
}
export interface WxtStorageItem<
TValue,
TMetadata extends Record<string, unknown>,
> {
/**
* Get the latest value from storage.
*/
getValue(): Promise<TValue>;
/**
* Get metadata.
*/
getMeta(): Promise<NullablePartial<TMetadata>>;
/**
* Set the value in storage.
*/
setValue(value: TValue | null): Promise<void>;
/**
* Set metadata properties.
*/
setMeta(properties: NullablePartial<TMetadata>): Promise<void>;
/**
* Remove the value from storage.
*/
removeValue(opts?: RemoveItemOptions): Promise<void>;
/**
* Remove all metadata or certain properties from metadata.
*/
removeMeta(properties?: string[]): Promise<void>;
/**
* Listen for changes to the value in storage.
*/
watch(cb: WatchCallback<TValue>): Unwatch;
}
export interface GetItemOptions<T> {
/**
* Value returned from `getValue` when it would otherwise return null.
*/
defaultValue?: T;
}
export interface RemoveItemOptions {
/**
* Optionally remove metadata when deleting a key.
*
* @default false
*/
removeMeta?: boolean;
}
export interface SnapshotOptions {
/**
* Exclude a list of keys. The storage area prefix should be removed since the snapshot is for a
* specific storage area already.
*/
excludeKeys?: string[];
}
export interface WxtStorageItemOptions<T> extends GetItemOptions<T> {
/**
* Provide a version number for the storage item to enable migrations. When changing the version
* in the future, migration functions will be ran on application startup.
*/
version?: number;
/**
* A map of version numbers to the functions used to migrate the data to that version.
*/
migrations?: Record<number, (oldValue: any) => any>;
}
/**
* Same as `Partial`, but includes `| null`. It makes all the properties of an object optional and
* nullable.
*/
export type NullablePartial<T> = {
[key in keyof T]+?: T[key] | undefined | null;
};
/**
* Callback called when a value in storage is changed.
*/
export type WatchCallback<T> = (newValue: T | null, oldValue: T | null) => void;
/**
* Call to remove a watch listener
*/
export type Unwatch = () => void;