diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ab2d2c5d..76e21651d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ on: - module-solid - module-svelte - module-vue + - storage - unocss - wxt diff --git a/.github/workflows/sync-releases.yml b/.github/workflows/sync-releases.yml index 4287f089d..d8f8ed834 100644 --- a/.github/workflows/sync-releases.yml +++ b/.github/workflows/sync-releases.yml @@ -7,13 +7,14 @@ on: default: wxt type: choice options: - - wxt - - module-react - - module-vue - - module-svelte - - module-solid - auto-icons - i18n + - module-react + - module-solid + - module-svelte + - module-vue + - storage + - wxt jobs: sync: diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1a575731b..0eb1aef03 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -13,6 +13,7 @@ import { version as wxtVersion } from '../../packages/wxt/package.json'; import { version as i18nVersion } from '../../packages/i18n/package.json'; import { version as autoIconsVersion } from '../../packages/auto-icons/package.json'; import { version as unocssVersion } from '../../packages/unocss/package.json'; +import { version as storageVersion } from '../../packages/storage/package.json'; const title = 'Next-gen Web Extension Framework'; const titleSuffix = ' – WXT'; @@ -87,7 +88,7 @@ export default defineConfig({ ), ]), navItem('Other Packages', [ - navItem(`wxt/storage — ${wxtVersion}`, '/storage'), + navItem(`@wxt-dev/storage — ${storageVersion}`, '/storage'), navItem(`@wxt-dev/auto-icons — ${autoIconsVersion}`, '/auto-icons'), navItem(`@wxt-dev/i18n — ${i18nVersion}`, '/i18n'), navItem(`@wxt-dev/unocss — ${unocssVersion}`, '/unocss'), diff --git a/docs/storage.md b/docs/storage.md index cb215b1c1..8e03045b4 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -6,23 +6,47 @@ outline: deep [Changelog](https://github.com/wxt-dev/wxt/blob/main/packages/wxt/CHANGELOG.md) -WXT provides a simplified API to replace the `browser.storage.*` APIs. Use the `storage` auto-import from `wxt/storage` or import it manually to get started: +A simplified wrapper around the extension storage APIs. + +## Installation + +### With WXT + +This module is built-in to WXT, so you don't need to install anything. ```ts import { storage } from 'wxt/storage'; ``` -> [!IMPORTANT] -> To use the `wxt/storage` API, the `"storage"` permission must be added to the manifest: -> -> ```ts -> // wxt.config.ts -> export default defineConfig({ -> manifest: { -> permissions: ['storage'], -> }, -> }); -> ``` +If you use auto-imports, `storage` is auto-imported for you, so you don't even need to import it! + +### Without WXT + +Install the NPM package: + +```sh +npm i @wxt-dev/storage +pnpm add @wxt-dev/storage +yarn add @wxt-dev/storage +bun add @wxt-dev/storage +``` + +```ts +import { storage } from '@wxt-dev/storage'; +``` + +## Storage Permission + +To use the `wxt/storage` API, the `"storage"` permission must be added to the manifest: + +```ts +// wxt.config.ts +export default defineConfig({ + manifest: { + permissions: ['storage'], + }, +}); +``` ## Basic Usage @@ -51,7 +75,7 @@ await storage.watch( await storage.getMeta<{ v: number }>('local:installDate'); ``` -For a full list of methods available, see the [API reference](/api/reference/wxt/storage/interfaces/WxtStorage). +For a full list of methods available, see the [API reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorage). ## Watchers @@ -134,7 +158,7 @@ const unwatch = showChangelogOnUpdate.watch((newValue) => { }); ``` -For a full list of properties and methods available, see the [API reference](/api/reference/wxt/storage/interfaces/WxtStorageItem). +For a full list of properties and methods available, see the [API reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorageItem). ### Versioning @@ -330,4 +354,4 @@ await storage.setItems([ ]); ``` -Refer to the [API Reference](/api/reference/wxt/storage/interfaces/WxtStorage) for types and examples of how to use all the bulk APIs. +Refer to the [API Reference](/api/reference/@wxt-dev/storage/interfaces/WxtStorage) for types and examples of how to use all the bulk APIs. diff --git a/packages/storage/README.md b/packages/storage/README.md new file mode 100644 index 000000000..2770193cd --- /dev/null +++ b/packages/storage/README.md @@ -0,0 +1,36 @@ +# WXT Storage + +[Changelog](https://github.com/wxt-dev/wxt/blob/main/packages/storage/CHANGELOG.md) • [Docs](https://wxt.dev/storage.html) + +A simplified wrapper around the extension storage APIs. + +## Installation + +### With WXT + +This module is built-in to WXT, so you don't need to install anything. + +```ts +import { storage } from 'wxt/storage'; +``` + +If you use auto-imports, `storage` is auto-imported for you, so you don't even need to import it! + +### Without WXT + +Install the NPM package: + +```sh +npm i @wxt-dev/storage +pnpm add @wxt-dev/storage +yarn add @wxt-dev/storage +bun add @wxt-dev/storage +``` + +```ts +import { storage } from '@wxt-dev/storage'; +``` + +## Usage + +Read full docs on the [documentation website](https://wxt.dev/storage.html). diff --git a/packages/storage/package.json b/packages/storage/package.json new file mode 100644 index 000000000..4412f6624 --- /dev/null +++ b/packages/storage/package.json @@ -0,0 +1,56 @@ +{ + "name": "@wxt-dev/storage", + "description": "Web extension storage API provided by WXT, supports all browsers.", + "version": "1.0.0", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/wxt-dev/wxt.git", + "directory": "packages/storage" + }, + "homepage": "https://wxt.dev/storage.html", + "keywords": [ + "wxt", + "storage", + "extension", + "addon", + "chrome", + "firefox", + "edge" + ], + "author": { + "name": "Aaron Klinker", + "email": "aaronklinker1+wxt@gmail.com" + }, + "license": "MIT", + "scripts": { + "build": "buildc -- unbuild", + "check": "buildc --deps-only -- check", + "test": "buildc --deps-only -- vitest" + }, + "dependencies": { + "async-mutex": "^0.5.0", + "dequal": "^2.0.3" + }, + "devDependencies": { + "@aklinker1/check": "^1.4.5", + "@types/chrome": "^0.0.268", + "@webext-core/fake-browser": "^1.3.1", + "oxlint": "^0.9.9", + "publint": "^0.2.11", + "typescript": "^5.6.2", + "unbuild": "^2.0.0", + "vitest": "^2.0.0" + }, + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ] +} diff --git a/packages/wxt/src/__tests__/storage.test.ts b/packages/storage/src/__tests__/index.test.ts similarity index 99% rename from packages/wxt/src/__tests__/storage.test.ts rename to packages/storage/src/__tests__/index.test.ts index b0fb0ed38..6a5f5b4f4 100644 --- a/packages/wxt/src/__tests__/storage.test.ts +++ b/packages/storage/src/__tests__/index.test.ts @@ -1,7 +1,6 @@ import { fakeBrowser } from '@webext-core/fake-browser'; import { describe, it, expect, beforeEach, vi, expectTypeOf } from 'vitest'; -import { browser } from 'wxt/browser'; -import { MigrationError, WxtStorageItem, storage } from '../storage'; +import { MigrationError, type WxtStorageItem, storage } from '../index'; /** * This works because fakeBrowser is synchronous, and is will finish any number of chained @@ -223,7 +222,7 @@ describe('Storage Utils', () => { describe('setMeta', () => { it('should set metadata at key+$', async () => { const existing = { v: 1 }; - await browser.storage[storageArea].set({ count$: existing }); + await chrome.storage[storageArea].set({ count$: existing }); const newValues = { date: Date.now(), }; @@ -239,7 +238,7 @@ describe('Storage Utils', () => { 'should remove any properties set to %s', async (version) => { const existing = { v: 1 }; - await browser.storage[storageArea].set({ count$: existing }); + await chrome.storage[storageArea].set({ count$: existing }); const expected = {}; await storage.setMeta(`${storageArea}:count`, { v: version }); @@ -1163,7 +1162,7 @@ describe('Storage Utils', () => { await item.removeValue(); // Make sure it's actually blank before running the test - expect(await browser.storage.local.get()).toEqual({}); + expect(await chrome.storage.local.get()).toEqual({}); init.mockClear(); const [value1, value2] = await Promise.all([ diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts new file mode 100644 index 000000000..e0cc39437 --- /dev/null +++ b/packages/storage/src/index.ts @@ -0,0 +1,871 @@ +/// +/** + * Simplified storage APIs with support for versioned fields, snapshots, metadata, and item definitions. + * + * See [the guide](https://wxt.dev/storage.html) for more information. + * @module @wxt-dev/storage + */ +import { dequal } from 'dequal/lite'; +import { Mutex } from 'async-mutex'; + +export const storage = createStorage(); + +function createStorage(): WxtStorage { + const drivers: Record = { + local: createDriver('local'), + session: createDriver('session'), + sync: createDriver('sync'), + managed: createDriver('managed'), + }; + const getDriver = (area: StorageArea) => { + 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: StorageItemKey) => { + const deliminatorIndex = key.indexOf(':'); + const driverArea = key.substring(0, deliminatorIndex) as StorageArea; + const driverKey = key.substring(deliminatorIndex + 1); + if (driverKey == null) + throw Error( + `Storage key should be in the form of "area:key", but received "${key}"`, + ); + + return { + driverArea, + driverKey, + driver: getDriver(driverArea), + }; + }; + const getMetaKey = (key: string) => key + '$'; + const mergeMeta = (oldMeta: any, newMeta: any): any => { + const newFields = { ...oldMeta }; + Object.entries(newMeta).forEach(([key, value]) => { + if (value == null) delete newFields[key]; + else newFields[key] = value; + }); + return newFields; + }; + const getValueOrFallback = (value: any, fallback: any) => + value ?? fallback ?? null; + const getMetaValue = (properties: any) => + typeof properties === 'object' && !Array.isArray(properties) + ? properties + : {}; + + const getItem = async ( + driver: WxtStorageDriver, + driverKey: string, + opts: GetItemOptions | undefined, + ) => { + const res = await driver.getItem(driverKey); + return getValueOrFallback(res, opts?.fallback ?? opts?.defaultValue); + }; + const getMeta = async (driver: WxtStorageDriver, driverKey: string) => { + const metaKey = getMetaKey(driverKey); + const res = await driver.getItem(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)); + await driver.setItem(metaKey, mergeMeta(existingFields, properties)); + }; + 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, + ) => { + 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(); + const keyToOptsMap = new Map | undefined>(); + const orderedKeys: StorageItemKey[] = []; + + keys.forEach((key) => { + let keyStr: StorageItemKey; + let opts: GetItemOptions | undefined; + if (typeof key === 'string') { + // key: string + keyStr = key; + } else if ('getValue' in key) { + // key: WxtStorageItem + keyStr = key.key; + opts = { fallback: key.fallback }; + } else { + // key: { key, options } + keyStr = key.key; + opts = key.options; + } + orderedKeys.push(keyStr); + const { driverArea, driverKey } = resolveKey(keyStr); + const areaKeys = areaToKeyMap.get(driverArea) ?? []; + areaToKeyMap.set(driverArea, areaKeys.concat(driverKey)); + keyToOptsMap.set(keyStr, opts); + }); + + const resultsMap = new Map(); + await Promise.all( + Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => { + const driverResults = await drivers[driverArea].getItems(keys); + driverResults.forEach((driverResult) => { + const key = `${driverArea}:${driverResult.key}` as StorageItemKey; + const opts = keyToOptsMap.get(key); + const value = getValueOrFallback( + driverResult.value, + opts?.fallback ?? opts?.defaultValue, + ); + resultsMap.set(key, value); + }); + }), + ); + + return orderedKeys.map((key) => ({ + key, + value: resultsMap.get(key), + })); + }, + getMeta: async (key) => { + const { driver, driverKey } = resolveKey(key); + return await getMeta(driver, driverKey); + }, + getMetas: async (args) => { + const keys = args.map((arg) => { + const key = typeof arg === 'string' ? arg : arg.key; + const { driverArea, driverKey } = resolveKey(key); + return { + key, + driverArea, + driverKey, + driverMetaKey: getMetaKey(driverKey), + }; + }); + const areaToDriverMetaKeysMap = keys.reduce< + Partial> + >((map, key) => { + map[key.driverArea] ??= []; + map[key.driverArea]!.push(key); + return map; + }, {}); + + const resultsMap: Record = {}; + await Promise.all( + Object.entries(areaToDriverMetaKeysMap).map(async ([area, keys]) => { + const areaRes = await chrome.storage[area as StorageArea].get( + keys.map((key) => key.driverMetaKey), + ); + keys.forEach((key) => { + resultsMap[key.key] = areaRes[key.driverMetaKey] ?? {}; + }); + }), + ); + + return keys.map((key) => ({ + key: key.key, + meta: resultsMap[key.key], + })); + }, + setItem: async (key, value) => { + const { driver, driverKey } = resolveKey(key); + await setItem(driver, driverKey, value); + }, + setItems: async (items) => { + const areaToKeyValueMap: Partial< + Record> + > = {}; + items.forEach((item) => { + const { driverArea, driverKey } = resolveKey( + 'key' in item ? item.key : item.item.key, + ); + areaToKeyValueMap[driverArea] ??= []; + areaToKeyValueMap[driverArea].push({ + key: driverKey, + value: item.value, + }); + }); + await Promise.all( + Object.entries(areaToKeyValueMap).map(async ([driverArea, values]) => { + const driver = getDriver(driverArea as StorageArea); + await driver.setItems(values); + }), + ); + }, + setMeta: async (key, properties) => { + const { driver, driverKey } = resolveKey(key); + await setMeta(driver, driverKey, properties); + }, + setMetas: async (items) => { + const areaToMetaUpdatesMap: Partial< + Record + > = {}; + items.forEach((item) => { + const { driverArea, driverKey } = resolveKey( + 'key' in item ? item.key : item.item.key, + ); + areaToMetaUpdatesMap[driverArea] ??= []; + areaToMetaUpdatesMap[driverArea].push({ + key: driverKey, + properties: item.meta, + }); + }); + + await Promise.all( + Object.entries(areaToMetaUpdatesMap).map( + async ([storageArea, updates]) => { + const driver = getDriver(storageArea as StorageArea); + const metaKeys = updates.map(({ key }) => getMetaKey(key)); + console.log(storageArea, metaKeys); + const existingMetas = await driver.getItems(metaKeys); + const existingMetaMap = Object.fromEntries( + existingMetas.map(({ key, value }) => [key, getMetaValue(value)]), + ); + + const metaUpdates = updates.map(({ key, properties }) => { + const metaKey = getMetaKey(key); + return { + key: metaKey, + value: mergeMeta(existingMetaMap[metaKey] ?? {}, properties), + }; + }); + + await driver.setItems(metaUpdates); + }, + ), + ); + }, + removeItem: async (key, opts) => { + const { driver, driverKey } = resolveKey(key); + await removeItem(driver, driverKey, opts); + }, + removeItems: async (keys) => { + const areaToKeysMap: Partial> = {}; + + keys.forEach((key) => { + let keyStr: StorageItemKey; + let opts: RemoveItemOptions | undefined; + if (typeof key === 'string') { + // key: string + keyStr = key; + } else if ('getValue' in key) { + // key: WxtStorageItem + keyStr = key.key; + } else if ('item' in key) { + // key: { item, options } + keyStr = key.item.key; + opts = key.options; + } else { + // key: { key, options } + keyStr = key.key; + opts = key.options; + } + const { driverArea, driverKey } = resolveKey(keyStr); + areaToKeysMap[driverArea] ??= []; + areaToKeysMap[driverArea].push(driverKey); + if (opts?.removeMeta) { + areaToKeysMap[driverArea].push(getMetaKey(driverKey)); + } + }); + + await Promise.all( + Object.entries(areaToKeysMap).map(async ([driverArea, keys]) => { + const driver = getDriver(driverArea as StorageArea); + 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?: WxtStorageItemOptions) => { + 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 migrate = async () => { + const driverMetaKey = getMetaKey(driverKey); + const [{ value }, { value: meta }] = await driver.getItems([ + driverKey, + driverMetaKey, + ]); + if (value == null) return; + + const currentVersion = meta?.v ?? 1; + if (currentVersion > targetVersion) { + throw Error( + `Version downgrade detected (v${currentVersion} -> v${targetVersion}) for "${key}"`, + ); + } + + console.debug( + `[@wxt-dev/storage] Running storage migration for ${key}: v${currentVersion} -> v${targetVersion}`, + ); + const migrationsToRun = Array.from( + { length: targetVersion - currentVersion }, + (_, i) => currentVersion + i + 1, + ); + let migratedValue = value; + for (const migrateToVersion of migrationsToRun) { + try { + migratedValue = + (await migrations?.[migrateToVersion]?.(migratedValue)) ?? + migratedValue; + } catch (err) { + throw Error(`v${migrateToVersion} migration failed for "${key}"`, { + cause: err, + }); + } + } + await driver.setItems([ + { key: driverKey, value: migratedValue }, + { key: driverMetaKey, value: { ...meta, v: targetVersion } }, + ]); + console.debug( + `[@wxt-dev/storage] Storage migration completed for ${key} v${targetVersion}`, + { migratedValue }, + ); + }; + const migrationsDone = + opts?.migrations == null + ? Promise.resolve() + : migrate().catch((err) => { + console.error( + `[@wxt-dev/storage] Migration failed for ${key}`, + err, + ); + }); + + const initMutex = new Mutex(); + + const getFallback = () => opts?.fallback ?? opts?.defaultValue ?? null; + + const getOrInitValue = () => + initMutex.runExclusive(async () => { + const value = await driver.getItem(driverKey); + // Don't init value if it already exists or the init function isn't provided + if (value != null || opts?.init == null) return value; + + const newValue = await opts.init(); + await driver.setItem(driverKey, newValue); + return newValue; + }); + + // Initialize the value once migrations have finished + migrationsDone.then(getOrInitValue); + + return { + key, + get defaultValue() { + return getFallback(); + }, + get fallback() { + return getFallback(); + }, + getValue: async () => { + await migrationsDone; + if (opts?.init) { + return await getOrInitValue(); + } else { + return await getItem(driver, driverKey, opts); + } + }, + getMeta: async () => { + await migrationsDone; + return await getMeta(driver, driverKey); + }, + setValue: async (value) => { + await migrationsDone; + return await setItem(driver, driverKey, value); + }, + setMeta: async (properties) => { + await migrationsDone; + return await setMeta(driver, driverKey, properties); + }, + removeValue: async (opts) => { + await migrationsDone; + return await removeItem(driver, driverKey, opts); + }, + removeMeta: async (properties) => { + await migrationsDone; + return await removeMeta(driver, driverKey, properties); + }, + watch: (cb) => + watch(driver, driverKey, (newValue, oldValue) => + cb(newValue ?? getFallback(), oldValue ?? getFallback()), + ), + migrate, + }; + }, + }; + return storage; +} + +function createDriver(storageArea: StorageArea): WxtStorageDriver { + const getStorageArea = () => { + if (chrome.runtime == null) { + throw Error( + [ + "'wxt/storage' must be loaded in a web extension environment", + '\n - If thrown during a build, see https://github.com/wxt-dev/wxt/issues/371', + " - If thrown during tests, mock 'wxt/browser' correctly. See https://wxt.dev/guide/go-further/testing.html\n", + ].join('\n'), + ); + } + if (chrome.storage == null) { + throw Error( + "You must add the 'storage' permission to your manifest to use 'wxt/storage'", + ); + } + + const area = chrome.storage[storageArea]; + if (area == null) + throw Error(`"chrome.storage.${storageArea}" is undefined`); + return area; + }; + const watchListeners = new Set<(changes: StorageAreaChanges) => 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>( + (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: StorageAreaChanges) => { + 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 interface WxtStorage { + /** + * Get an item from storage, or return `null` if it doesn't exist. + * + * @example + * await storage.getItem("local:installDate"); + */ + getItem( + key: StorageItemKey, + opts: GetItemOptions & { fallback: TValue }, + ): Promise; + + getItem( + key: StorageItemKey, + opts?: GetItemOptions, + ): Promise; + + /** + * Get multiple items from storage. The return order is guaranteed to be the same as the order + * requested. + * + * @example + * await storage.getItems(["local:installDate", "session:someCounter"]); + */ + getItems( + keys: Array< + | StorageItemKey + | WxtStorageItem + | { key: StorageItemKey; options?: GetItemOptions } + >, + ): Promise>; + /** + * 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>(key: StorageItemKey): Promise; + /** + * Get the metadata of multiple storage items. + * + * @param items List of keys or items to get the metadata of. + * @returns An array containing storage keys and their metadata. + */ + getMetas( + keys: Array>, + ): Promise>; + /** + * Set a value in storage. Setting a value to `null` or `undefined` is equivalent to calling + * `removeItem`. + * + * @example + * await storage.setItem("local:installDate", Date.now()); + */ + setItem(key: StorageItemKey, value: T | null): Promise; + /** + * 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: StorageItemKey; value: any } + | { item: WxtStorageItem; value: any } + >, + ): Promise; + /** + * 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>( + key: StorageItemKey, + properties: T | null, + ): Promise; + /** + * Set the metadata of multiple storage items. + * + * @param items List of storage keys or items and metadata to set for each. + */ + setMetas( + metas: Array< + | { key: StorageItemKey; meta: Record } + | { item: WxtStorageItem; meta: Record } + >, + ): Promise; + /** + * Removes an item from storage. + * + * @example + * await storage.removeItem("local:installDate"); + */ + removeItem(key: StorageItemKey, opts?: RemoveItemOptions): Promise; + /** + * Remove a list of keys from storage. + */ + removeItems( + keys: Array< + | StorageItemKey + | WxtStorageItem + | { key: StorageItemKey; options?: RemoveItemOptions } + | { item: WxtStorageItem; options?: RemoveItemOptions } + >, + ): Promise; + /** + * 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: StorageItemKey, + properties?: string | string[], + ): Promise; + /** + * Return all the items in storage. + */ + snapshot( + base: StorageArea, + opts?: SnapshotOptions, + ): Promise>; + /** + * 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 overridden. + */ + restoreSnapshot(base: StorageArea, data: any): Promise; + /** + * Watch for changes to a specific key in storage. + */ + watch(key: StorageItemKey, cb: WatchCallback): Unwatch; + /** + * Remove all watch listeners. + */ + unwatch(): void; + + /** + * Define a storage item with a default value, type, or versioning. + * + * Read full docs: https://wxt.dev/guide/extension-apis/storage.html#defining-storage-items + */ + defineItem = {}>( + key: StorageItemKey, + ): WxtStorageItem; + defineItem = {}>( + key: StorageItemKey, + options: WxtStorageItemOptions, + ): WxtStorageItem; +} + +interface WxtStorageDriver { + getItem(key: string): Promise; + getItems(keys: string[]): Promise<{ key: string; value: any }[]>; + setItem(key: string, value: T | null): Promise; + setItems(values: Array<{ key: string; value: any }>): Promise; + removeItem(key: string): Promise; + removeItems(keys: string[]): Promise; + snapshot(): Promise>; + restoreSnapshot(data: Record): Promise; + watch(key: string, cb: WatchCallback): Unwatch; + unwatch(): void; +} + +export interface WxtStorageItem< + TValue, + TMetadata extends Record, +> { + /** + * The storage key passed when creating the storage item. + */ + key: StorageItemKey; + /** + * @deprecated Renamed to fallback, use it instead. + */ + defaultValue: TValue; + /** + * The value provided by the `fallback` option. + */ + fallback: TValue; + /** + * Get the latest value from storage. + */ + getValue(): Promise; + /** + * Get metadata. + */ + getMeta(): Promise>; + /** + * Set the value in storage. + */ + setValue(value: TValue): Promise; + /** + * Set metadata properties. + */ + setMeta(properties: NullablePartial): Promise; + /** + * Remove the value from storage. + */ + removeValue(opts?: RemoveItemOptions): Promise; + /** + * Remove all metadata or certain properties from metadata. + */ + removeMeta(properties?: string[]): Promise; + /** + * Listen for changes to the value in storage. + */ + watch(cb: WatchCallback): Unwatch; + /** + * If there are migrations defined on the storage item, migrate to the latest version. + * + * **This function is ran automatically whenever the extension updates**, so you don't have to call it + * manually. + */ + migrate(): Promise; +} + +export type StorageArea = 'local' | 'session' | 'sync' | 'managed'; +export type StorageItemKey = `${StorageArea}:${string}`; + +export interface GetItemOptions { + /** + * @deprecated Renamed to `fallback`, use it instead. + */ + defaultValue?: T; + /** + * Default value returned when `getItem` would otherwise return `null`. + */ + fallback?: 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 { + /** + * @deprecated Renamed to `fallback`, use it instead. + */ + defaultValue?: T; + /** + * Default value returned when `getValue` would otherwise return `null`. + */ + fallback?: T; + /** + * If passed, a value in storage will be initialized immediately after + * defining the storage item. This function returns the value that will be + * saved to storage during the initialization process if a value doesn't + * already exist. + */ + init?: () => T | Promise; + /** + * 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 any>; +} + +export type StorageAreaChanges = { + [key: string]: chrome.storage.StorageChange; +}; + +/** + * Same as `Partial`, but includes `| null`. It makes all the properties of an object optional and + * nullable. + */ +type NullablePartial = { + [key in keyof T]+?: T[key] | undefined | null; +}; +/** + * Callback called when a value in storage is changed. + */ +export type WatchCallback = (newValue: T, oldValue: T) => void; +/** + * Call to remove a watch listener + */ +export type Unwatch = () => void; + +export class MigrationError extends Error { + constructor( + public key: string, + public version: number, + options?: ErrorOptions, + ) { + super(`v${version} migration failed for "${key}"`, options); + } +} diff --git a/packages/storage/tsconfig.json b/packages/storage/tsconfig.json new file mode 100644 index 000000000..1d1ddb7f3 --- /dev/null +++ b/packages/storage/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "verbatimModuleSyntax": true, + "types": ["chrome"] + }, + "exclude": ["node_modules/**", "dist/**"] +} diff --git a/packages/storage/vitest.config.ts b/packages/storage/vitest.config.ts new file mode 100644 index 000000000..3980191bc --- /dev/null +++ b/packages/storage/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + mockReset: true, + restoreMocks: true, + setupFiles: ['vitest.setup.ts'], + }, +}); diff --git a/packages/storage/vitest.setup.ts b/packages/storage/vitest.setup.ts new file mode 100644 index 000000000..c2c31b3fb --- /dev/null +++ b/packages/storage/vitest.setup.ts @@ -0,0 +1,4 @@ +import { fakeBrowser } from '@webext-core/fake-browser'; +import { vi } from 'vitest'; + +vi.stubGlobal('chrome', fakeBrowser); diff --git a/packages/wxt/package.json b/packages/wxt/package.json index 4df0fce41..5e2cd4d36 100644 --- a/packages/wxt/package.json +++ b/packages/wxt/package.json @@ -87,6 +87,7 @@ "@webext-core/fake-browser": "^1.3.1", "@webext-core/isolated-element": "^1.1.2", "@webext-core/match-patterns": "^1.0.3", + "@wxt-dev/storage": "workspace:^1.0.0", "async-mutex": "^0.5.0", "c12": "^1.11.2", "cac": "^6.7.14", @@ -94,7 +95,6 @@ "ci-info": "^4.1.0", "consola": "^3.2.3", "defu": "^6.1.4", - "dequal": "^2.0.3", "dotenv": "^16.4.5", "esbuild": "^0.21.5", "fast-glob": "^3.3.2", diff --git a/packages/wxt/src/storage.ts b/packages/wxt/src/storage.ts index bad01977e..cefb3a957 100644 --- a/packages/wxt/src/storage.ts +++ b/packages/wxt/src/storage.ts @@ -1,869 +1,4 @@ /** - * Simplified storage APIs with support for versioned fields, snapshots, metadata, and item definitions. - * - * See [the guide](https://wxt.dev/guide/extension-apis/storage.html) for more information. - * - * @module wxt/storage + * @module @wxt-dev/storage */ -import { Storage, browser } from 'wxt/browser'; -import { dequal } from 'dequal/lite'; -import { logger } from './sandbox/utils/logger'; -import { toArray } from './core/utils/arrays'; -import { Mutex } from 'async-mutex'; - -export const storage = createStorage(); - -function createStorage(): WxtStorage { - const drivers: Record = { - local: createDriver('local'), - session: createDriver('session'), - sync: createDriver('sync'), - managed: createDriver('managed'), - }; - const getDriver = (area: StorageArea) => { - 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: StorageItemKey) => { - const deliminatorIndex = key.indexOf(':'); - const driverArea = key.substring(0, deliminatorIndex) as StorageArea; - const driverKey = key.substring(deliminatorIndex + 1); - if (driverKey == null) - throw Error( - `Storage key should be in the form of "area:key", but received "${key}"`, - ); - - return { - driverArea, - driverKey, - driver: getDriver(driverArea), - }; - }; - const getMetaKey = (key: string) => key + '$'; - const mergeMeta = (oldMeta: any, newMeta: any): any => { - const newFields = { ...oldMeta }; - Object.entries(newMeta).forEach(([key, value]) => { - if (value == null) delete newFields[key]; - else newFields[key] = value; - }); - return newFields; - }; - const getValueOrFallback = (value: any, fallback: any) => - value ?? fallback ?? null; - const getMetaValue = (properties: any) => - typeof properties === 'object' && !Array.isArray(properties) - ? properties - : {}; - - const getItem = async ( - driver: WxtStorageDriver, - driverKey: string, - opts: GetItemOptions | undefined, - ) => { - const res = await driver.getItem(driverKey); - return getValueOrFallback(res, opts?.fallback ?? opts?.defaultValue); - }; - const getMeta = async (driver: WxtStorageDriver, driverKey: string) => { - const metaKey = getMetaKey(driverKey); - const res = await driver.getItem(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)); - await driver.setItem(metaKey, mergeMeta(existingFields, properties)); - }; - 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)); - toArray(properties).forEach((field) => delete newFields[field]); - await driver.setItem(metaKey, newFields); - } - }; - const watch = ( - driver: WxtStorageDriver, - driverKey: string, - cb: WatchCallback, - ) => { - 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(); - const keyToOptsMap = new Map | undefined>(); - const orderedKeys: StorageItemKey[] = []; - - keys.forEach((key) => { - let keyStr: StorageItemKey; - let opts: GetItemOptions | undefined; - if (typeof key === 'string') { - // key: string - keyStr = key; - } else if ('getValue' in key) { - // key: WxtStorageItem - keyStr = key.key; - opts = { fallback: key.fallback }; - } else { - // key: { key, options } - keyStr = key.key; - opts = key.options; - } - orderedKeys.push(keyStr); - const { driverArea, driverKey } = resolveKey(keyStr); - const areaKeys = areaToKeyMap.get(driverArea) ?? []; - areaToKeyMap.set(driverArea, areaKeys.concat(driverKey)); - keyToOptsMap.set(keyStr, opts); - }); - - const resultsMap = new Map(); - await Promise.all( - Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => { - const driverResults = await drivers[driverArea].getItems(keys); - driverResults.forEach((driverResult) => { - const key = `${driverArea}:${driverResult.key}` as StorageItemKey; - const opts = keyToOptsMap.get(key); - const value = getValueOrFallback( - driverResult.value, - opts?.fallback ?? opts?.defaultValue, - ); - resultsMap.set(key, value); - }); - }), - ); - - return orderedKeys.map((key) => ({ - key, - value: resultsMap.get(key), - })); - }, - getMeta: async (key) => { - const { driver, driverKey } = resolveKey(key); - return await getMeta(driver, driverKey); - }, - getMetas: async (args) => { - const keys = args.map((arg) => { - const key = typeof arg === 'string' ? arg : arg.key; - const { driverArea, driverKey } = resolveKey(key); - return { - key, - driverArea, - driverKey, - driverMetaKey: getMetaKey(driverKey), - }; - }); - const areaToDriverMetaKeysMap = keys.reduce< - Partial> - >((map, key) => { - map[key.driverArea] ??= []; - map[key.driverArea]!.push(key); - return map; - }, {}); - - const resultsMap: Record = {}; - await Promise.all( - Object.entries(areaToDriverMetaKeysMap).map(async ([area, keys]) => { - const areaRes = await browser.storage[area as StorageArea].get( - keys.map((key) => key.driverMetaKey), - ); - keys.forEach((key) => { - resultsMap[key.key] = areaRes[key.driverMetaKey] ?? {}; - }); - }), - ); - - return keys.map((key) => ({ - key: key.key, - meta: resultsMap[key.key], - })); - }, - setItem: async (key, value) => { - const { driver, driverKey } = resolveKey(key); - await setItem(driver, driverKey, value); - }, - setItems: async (items) => { - const areaToKeyValueMap: Partial< - Record> - > = {}; - items.forEach((item) => { - const { driverArea, driverKey } = resolveKey( - 'key' in item ? item.key : item.item.key, - ); - areaToKeyValueMap[driverArea] ??= []; - areaToKeyValueMap[driverArea].push({ - key: driverKey, - value: item.value, - }); - }); - await Promise.all( - Object.entries(areaToKeyValueMap).map(async ([driverArea, values]) => { - const driver = getDriver(driverArea as StorageArea); - await driver.setItems(values); - }), - ); - }, - setMeta: async (key, properties) => { - const { driver, driverKey } = resolveKey(key); - await setMeta(driver, driverKey, properties); - }, - setMetas: async (items) => { - const areaToMetaUpdatesMap: Partial< - Record - > = {}; - items.forEach((item) => { - const { driverArea, driverKey } = resolveKey( - 'key' in item ? item.key : item.item.key, - ); - areaToMetaUpdatesMap[driverArea] ??= []; - areaToMetaUpdatesMap[driverArea].push({ - key: driverKey, - properties: item.meta, - }); - }); - - await Promise.all( - Object.entries(areaToMetaUpdatesMap).map( - async ([storageArea, updates]) => { - const driver = getDriver(storageArea as StorageArea); - const metaKeys = updates.map(({ key }) => getMetaKey(key)); - console.log(storageArea, metaKeys); - const existingMetas = await driver.getItems(metaKeys); - const existingMetaMap = Object.fromEntries( - existingMetas.map(({ key, value }) => [key, getMetaValue(value)]), - ); - - const metaUpdates = updates.map(({ key, properties }) => { - const metaKey = getMetaKey(key); - return { - key: metaKey, - value: mergeMeta(existingMetaMap[metaKey] ?? {}, properties), - }; - }); - - await driver.setItems(metaUpdates); - }, - ), - ); - }, - removeItem: async (key, opts) => { - const { driver, driverKey } = resolveKey(key); - await removeItem(driver, driverKey, opts); - }, - removeItems: async (keys) => { - const areaToKeysMap: Partial> = {}; - - keys.forEach((key) => { - let keyStr: StorageItemKey; - let opts: RemoveItemOptions | undefined; - if (typeof key === 'string') { - // key: string - keyStr = key; - } else if ('getValue' in key) { - // key: WxtStorageItem - keyStr = key.key; - } else if ('item' in key) { - // key: { item, options } - keyStr = key.item.key; - opts = key.options; - } else { - // key: { key, options } - keyStr = key.key; - opts = key.options; - } - const { driverArea, driverKey } = resolveKey(keyStr); - areaToKeysMap[driverArea] ??= []; - areaToKeysMap[driverArea].push(driverKey); - if (opts?.removeMeta) { - areaToKeysMap[driverArea].push(getMetaKey(driverKey)); - } - }); - - await Promise.all( - Object.entries(areaToKeysMap).map(async ([driverArea, keys]) => { - const driver = getDriver(driverArea as StorageArea); - 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?: WxtStorageItemOptions) => { - 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 migrate = async () => { - const driverMetaKey = getMetaKey(driverKey); - const [{ value }, { value: meta }] = await driver.getItems([ - driverKey, - driverMetaKey, - ]); - if (value == null) return; - - const currentVersion = meta?.v ?? 1; - if (currentVersion > targetVersion) { - throw Error( - `Version downgrade detected (v${currentVersion} -> v${targetVersion}) for "${key}"`, - ); - } - - logger.debug( - `Running storage migration for ${key}: v${currentVersion} -> v${targetVersion}`, - ); - const migrationsToRun = Array.from( - { length: targetVersion - currentVersion }, - (_, i) => currentVersion + i + 1, - ); - let migratedValue = value; - for (const migrateToVersion of migrationsToRun) { - try { - migratedValue = - (await migrations?.[migrateToVersion]?.(migratedValue)) ?? - migratedValue; - } catch (err) { - throw Error(`v${migrateToVersion} migration failed for "${key}"`, { - cause: err, - }); - } - } - await driver.setItems([ - { key: driverKey, value: migratedValue }, - { key: driverMetaKey, value: { ...meta, v: targetVersion } }, - ]); - logger.debug( - `Storage migration completed for ${key} v${targetVersion}`, - { migratedValue }, - ); - }; - const migrationsDone = - opts?.migrations == null - ? Promise.resolve() - : migrate().catch((err) => { - logger.error(`Migration failed for ${key}`, err); - }); - - const initMutex = new Mutex(); - - const getFallback = () => opts?.fallback ?? opts?.defaultValue ?? null; - - const getOrInitValue = () => - initMutex.runExclusive(async () => { - const value = await driver.getItem(driverKey); - // Don't init value if it already exists or the init function isn't provided - if (value != null || opts?.init == null) return value; - - const newValue = await opts.init(); - await driver.setItem(driverKey, newValue); - return newValue; - }); - - // Initialize the value once migrations have finished - migrationsDone.then(getOrInitValue); - - return { - key, - get defaultValue() { - return getFallback(); - }, - get fallback() { - return getFallback(); - }, - getValue: async () => { - await migrationsDone; - if (opts?.init) { - return await getOrInitValue(); - } else { - return await getItem(driver, driverKey, opts); - } - }, - getMeta: async () => { - await migrationsDone; - return await getMeta(driver, driverKey); - }, - setValue: async (value) => { - await migrationsDone; - return await setItem(driver, driverKey, value); - }, - setMeta: async (properties) => { - await migrationsDone; - return await setMeta(driver, driverKey, properties); - }, - removeValue: async (opts) => { - await migrationsDone; - return await removeItem(driver, driverKey, opts); - }, - removeMeta: async (properties) => { - await migrationsDone; - return await removeMeta(driver, driverKey, properties); - }, - watch: (cb) => - watch(driver, driverKey, (newValue, oldValue) => - cb(newValue ?? getFallback(), oldValue ?? getFallback()), - ), - migrate, - }; - }, - }; - return storage; -} - -function createDriver(storageArea: StorageArea): WxtStorageDriver { - const getStorageArea = () => { - if (browser.runtime == null) { - throw Error( - [ - "'wxt/storage' must be loaded in a web extension environment", - '\n - If thrown during a build, see https://github.com/wxt-dev/wxt/issues/371', - " - If thrown during tests, mock 'wxt/browser' correctly. See https://wxt.dev/guide/go-further/testing.html\n", - ].join('\n'), - ); - } - if (browser.storage == null) { - throw Error( - "You must add the 'storage' permission to your manifest to use 'wxt/storage'", - ); - } - - const area = browser.storage[storageArea]; - if (area == null) - throw Error(`"browser.storage.${storageArea}" is undefined`); - return area; - }; - const watchListeners = new Set< - (changes: Storage.StorageAreaOnChangedChangesType) => void - >(); - return { - getItem: async (key) => { - const res = await getStorageArea().get(key); - return res[key] as any; - }, - 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>( - (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] as { newValue: any; oldValue: any }; - 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 interface WxtStorage { - /** - * Get an item from storage, or return `null` if it doesn't exist. - * - * @example - * await storage.getItem("local:installDate"); - */ - getItem( - key: StorageItemKey, - opts: GetItemOptions & { fallback: TValue }, - ): Promise; - - getItem( - key: StorageItemKey, - opts?: GetItemOptions, - ): Promise; - - /** - * Get multiple items from storage. The return order is guaranteed to be the same as the order - * requested. - * - * @example - * await storage.getItems(["local:installDate", "session:someCounter"]); - */ - getItems( - keys: Array< - | StorageItemKey - | WxtStorageItem - | { key: StorageItemKey; options?: GetItemOptions } - >, - ): Promise>; - /** - * 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>(key: StorageItemKey): Promise; - /** - * Get the metadata of multiple storage items. - * - * @param items List of keys or items to get the metadata of. - * @returns An array containing storage keys and their metadata. - */ - getMetas( - keys: Array>, - ): Promise>; - /** - * Set a value in storage. Setting a value to `null` or `undefined` is equivalent to calling - * `removeItem`. - * - * @example - * await storage.setItem("local:installDate", Date.now()); - */ - setItem(key: StorageItemKey, value: T | null): Promise; - /** - * 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: StorageItemKey; value: any } - | { item: WxtStorageItem; value: any } - >, - ): Promise; - /** - * 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>( - key: StorageItemKey, - properties: T | null, - ): Promise; - /** - * Set the metadata of multiple storage items. - * - * @param items List of storage keys or items and metadata to set for each. - */ - setMetas( - metas: Array< - | { key: StorageItemKey; meta: Record } - | { item: WxtStorageItem; meta: Record } - >, - ): Promise; - /** - * Removes an item from storage. - * - * @example - * await storage.removeItem("local:installDate"); - */ - removeItem(key: StorageItemKey, opts?: RemoveItemOptions): Promise; - /** - * Remove a list of keys from storage. - */ - removeItems( - keys: Array< - | StorageItemKey - | WxtStorageItem - | { key: StorageItemKey; options?: RemoveItemOptions } - | { item: WxtStorageItem; options?: RemoveItemOptions } - >, - ): Promise; - /** - * 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: StorageItemKey, - properties?: string | string[], - ): Promise; - /** - * Return all the items in storage. - */ - snapshot( - base: StorageArea, - opts?: SnapshotOptions, - ): Promise>; - /** - * 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 overridden. - */ - restoreSnapshot(base: StorageArea, data: any): Promise; - /** - * Watch for changes to a specific key in storage. - */ - watch(key: StorageItemKey, cb: WatchCallback): Unwatch; - /** - * Remove all watch listeners. - */ - unwatch(): void; - - /** - * Define a storage item with a default value, type, or versioning. - * - * Read full docs: https://wxt.dev/guide/extension-apis/storage.html#defining-storage-items - */ - defineItem = {}>( - key: StorageItemKey, - ): WxtStorageItem; - defineItem = {}>( - key: StorageItemKey, - options: WxtStorageItemOptions, - ): WxtStorageItem; -} - -interface WxtStorageDriver { - getItem(key: string): Promise; - getItems(keys: string[]): Promise<{ key: string; value: any }[]>; - setItem(key: string, value: T | null): Promise; - setItems(values: Array<{ key: string; value: any }>): Promise; - removeItem(key: string): Promise; - removeItems(keys: string[]): Promise; - snapshot(): Promise>; - restoreSnapshot(data: Record): Promise; - watch(key: string, cb: WatchCallback): Unwatch; - unwatch(): void; -} - -export interface WxtStorageItem< - TValue, - TMetadata extends Record, -> { - /** - * The storage key passed when creating the storage item. - */ - key: StorageItemKey; - /** - * @deprecated Renamed to fallback, use it instead. - */ - defaultValue: TValue; - /** - * The value provided by the `fallback` option. - */ - fallback: TValue; - /** - * Get the latest value from storage. - */ - getValue(): Promise; - /** - * Get metadata. - */ - getMeta(): Promise>; - /** - * Set the value in storage. - */ - setValue(value: TValue): Promise; - /** - * Set metadata properties. - */ - setMeta(properties: NullablePartial): Promise; - /** - * Remove the value from storage. - */ - removeValue(opts?: RemoveItemOptions): Promise; - /** - * Remove all metadata or certain properties from metadata. - */ - removeMeta(properties?: string[]): Promise; - /** - * Listen for changes to the value in storage. - */ - watch(cb: WatchCallback): Unwatch; - /** - * If there are migrations defined on the storage item, migrate to the latest version. - * - * **This function is ran automatically whenever the extension updates**, so you don't have to call it - * manually. - */ - migrate(): Promise; -} - -export type StorageArea = 'local' | 'session' | 'sync' | 'managed'; -export type StorageItemKey = `${StorageArea}:${string}`; - -export interface GetItemOptions { - /** - * @deprecated Renamed to `fallback`, use it instead. - */ - defaultValue?: T; - /** - * Default value returned when `getItem` would otherwise return `null`. - */ - fallback?: 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 { - /** - * @deprecated Renamed to `fallback`, use it instead. - */ - defaultValue?: T; - /** - * Default value returned when `getValue` would otherwise return `null`. - */ - fallback?: T; - /** - * If passed, a value in storage will be initialized immediately after - * defining the storage item. This function returns the value that will be - * saved to storage during the initialization process if a value doesn't - * already exist. - */ - init?: () => T | Promise; - /** - * 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 any>; -} - -/** - * Same as `Partial`, but includes `| null`. It makes all the properties of an object optional and - * nullable. - */ -export type NullablePartial = { - [key in keyof T]+?: T[key] | undefined | null; -}; -/** - * Callback called when a value in storage is changed. - */ -export type WatchCallback = (newValue: T, oldValue: T) => void; -/** - * Call to remove a watch listener - */ -export type Unwatch = () => void; - -export class MigrationError extends Error { - constructor( - public key: string, - public version: number, - options?: ErrorOptions, - ) { - super(`v${version} migration failed for "${key}"`, options); - } -} +export * from '@wxt-dev/storage'; diff --git a/packages/wxt/typedoc.json b/packages/wxt/typedoc.json index 312472814..d9edaddfc 100644 --- a/packages/wxt/typedoc.json +++ b/packages/wxt/typedoc.json @@ -6,7 +6,7 @@ "src/browser/index.ts", "src/browser/chrome.ts", "src/index.ts", - "src/storage.ts", - "src/modules.ts" + "src/modules.ts", + "src/storage.ts" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c25bff722..c5884d35a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,40 @@ importers: specifier: workspace:* version: link:../wxt + packages/storage: + dependencies: + async-mutex: + specifier: ^0.5.0 + version: 0.5.0 + dequal: + specifier: ^2.0.3 + version: 2.0.3 + devDependencies: + '@aklinker1/check': + specifier: ^1.4.5 + version: 1.4.5(typescript@5.6.3) + '@types/chrome': + specifier: ^0.0.268 + version: 0.0.268 + '@webext-core/fake-browser': + specifier: ^1.3.1 + version: 1.3.1 + oxlint: + specifier: ^0.9.9 + version: 0.9.10 + publint: + specifier: ^0.2.11 + version: 0.2.12 + typescript: + specifier: ^5.6.2 + version: 5.6.3 + unbuild: + specifier: ^2.0.0 + version: 2.0.0(sass@1.80.7)(typescript@5.6.3) + vitest: + specifier: ^2.0.0 + version: 2.1.4(@types/node@20.17.6)(happy-dom@15.11.4)(sass@1.80.7) + packages/unocss: dependencies: defu: @@ -311,6 +345,9 @@ importers: '@webext-core/match-patterns': specifier: ^1.0.3 version: 1.0.3 + '@wxt-dev/storage': + specifier: workspace:^1.0.0 + version: link:../storage async-mutex: specifier: ^0.5.0 version: 0.5.0 @@ -332,9 +369,6 @@ importers: defu: specifier: ^6.1.4 version: 6.1.4 - dequal: - specifier: ^2.0.3 - version: 2.0.3 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -1455,41 +1489,81 @@ packages: cpu: [arm64] os: [darwin] + '@oxlint/darwin-arm64@0.9.10': + resolution: {integrity: sha512-eOXKZYq5bnCSgDefgM5bzAg+4Fc//Rc4yjgKN8iDWUARweCaChiQXb6TXX8MfEfs6qayEMy6yVj0pqoFz0B1aw==} + cpu: [arm64] + os: [darwin] + '@oxlint/darwin-x64@0.11.1': resolution: {integrity: sha512-LPuF0D8uu30KIVEeVuGwIPwHwJRQ1i1otwFFH7tRsNXPgMgZJ4VgriyH22i6RWwBtclJoCSBLtGK6gLZ0oZBvw==} cpu: [x64] os: [darwin] + '@oxlint/darwin-x64@0.9.10': + resolution: {integrity: sha512-UeYICDvLUaUOcY+0ugZUEmBMRLP+x8iTgL7TeY6BlpGw2ahbtUOTbyIIRWtr/0O++TnjZ+v8TzhJ9crw6Ij6dg==} + cpu: [x64] + os: [darwin] + '@oxlint/linux-arm64-gnu@0.11.1': resolution: {integrity: sha512-CYBE+GRIPs5e+raD2pdicuBn6Y6E1xAnyWQ/kHE4GEWDAQZY0Um2VYEUTGH2ObwJ3uXr6jeJ16HOKJvr0S8a8w==} cpu: [arm64] os: [linux] + '@oxlint/linux-arm64-gnu@0.9.10': + resolution: {integrity: sha512-0Zn+vqHhrZyufFBfq9WOgiIool0gCR14BLsdS+0Dwd9o+kNxPGA5q7erQFkiC4rpkxtfBHeD3iIKMMt7d29Kyw==} + cpu: [arm64] + os: [linux] + '@oxlint/linux-arm64-musl@0.11.1': resolution: {integrity: sha512-iYXF5N5Gv+lc2wt90kxXy/W0cn7IEWu3UPzewIjPGDH8ajDckvGzZx6pTGYJnTyMh7U6hUKwOBFPVLMWI7UwKQ==} cpu: [arm64] os: [linux] + '@oxlint/linux-arm64-musl@0.9.10': + resolution: {integrity: sha512-tkQcWpYwF42bA/uRaV2iMFePHkBjTTgomOgeEaiw6XOSJX4nBEqGIIboqqLBWT4JnKCf/L+IG3y/e1MflhKByw==} + cpu: [arm64] + os: [linux] + '@oxlint/linux-x64-gnu@0.11.1': resolution: {integrity: sha512-D0tT8X0CsK/bpdkGdLSmsGftG3VndjyAUJuNGt56JYn0UfuPDkhQcLgUlkANHzNRXJ84tLQKhpf/MUDUHPB5cg==} cpu: [x64] os: [linux] + '@oxlint/linux-x64-gnu@0.9.10': + resolution: {integrity: sha512-JHbkMUnibqaSMBvLHyqTL5cWxcGW+jw+Ppt2baLISpvo34a6fBR+PI7v/A92sEDWe0W1rPhypzCwA8mKpkQ3DA==} + cpu: [x64] + os: [linux] + '@oxlint/linux-x64-musl@0.11.1': resolution: {integrity: sha512-WekaLYk8WLT7Di8+nyPvtqs9OlMoO6KjFDMlqqLDWQTk9ffjn8e76PCRigF3w39jQ70qP3c8k8cNKNw5ROuFcg==} cpu: [x64] os: [linux] + '@oxlint/linux-x64-musl@0.9.10': + resolution: {integrity: sha512-aBBwN7bQzidwHwEXr7BAdVvMTLWstCy5gikerjLnGDeCSXX9r+o6+yUzTOqZvOo66E+XBgOJaVbY8rsL1MLE0g==} + cpu: [x64] + os: [linux] + '@oxlint/win32-arm64@0.11.1': resolution: {integrity: sha512-/CN/bFtI33vB8uemOkZxlNRf6Q7CftP2pSO7a6Q2niG4NC99YRPj7ctXcPF0jGR0NQUhGZk7ajM4G/0MKcRdag==} cpu: [arm64] os: [win32] + '@oxlint/win32-arm64@0.9.10': + resolution: {integrity: sha512-LXDnk7vKHT3IY6G1jq0O7+XMhtcHOYuxLGIx4KP+4xS6vKgBY+Bsq4xV3AtmtKlvnXkP5FxHpfLmcEtm5AWysA==} + cpu: [arm64] + os: [win32] + '@oxlint/win32-x64@0.11.1': resolution: {integrity: sha512-0hLl0z6adYTvLIOPC5uyo+EAwNITkzi4AY4xImykQW8H89GhiV9Xl8MPJeZQMWSz7ajI1I2+hRsvA0QAzeBsxA==} cpu: [x64] os: [win32] + '@oxlint/win32-x64@0.9.10': + resolution: {integrity: sha512-w5XRAV4bhgwenjjpGYZGglqzG9Wv/sI+cjQWJBQsvfDXsr2w4vOBXzt1j3/Z3EcSqf4KtkCa/IIuAhQyeShUbA==} + cpu: [x64] + os: [win32] + '@parcel/watcher-android-arm64@2.5.0': resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==} engines: {node: '>= 10.0.0'} @@ -1800,6 +1874,9 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/chrome@0.0.268': + resolution: {integrity: sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==} + '@types/chrome@0.0.280': resolution: {integrity: sha512-AotSmZrL9bcZDDmSI1D9dE7PGbhOur5L0cKxXd7IqbVizQWCY4gcvupPUVsQ4FfDj3V2tt/iOpomT9EY0s+w1g==} @@ -3790,6 +3867,11 @@ packages: engines: {node: '>=14.*'} hasBin: true + oxlint@0.9.10: + resolution: {integrity: sha512-bKiiFN7Hnoaist/rditTRBXz+GXKYuLd53/NB7Q6zHB/bifELJarSoRLkAUGElIJKl4PSr3lTh1g6zehh+rX0g==} + engines: {node: '>=14.*'} + hasBin: true + p-cancelable@3.0.0: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} @@ -5840,27 +5922,51 @@ snapshots: '@oxlint/darwin-arm64@0.11.1': optional: true + '@oxlint/darwin-arm64@0.9.10': + optional: true + '@oxlint/darwin-x64@0.11.1': optional: true + '@oxlint/darwin-x64@0.9.10': + optional: true + '@oxlint/linux-arm64-gnu@0.11.1': optional: true + '@oxlint/linux-arm64-gnu@0.9.10': + optional: true + '@oxlint/linux-arm64-musl@0.11.1': optional: true + '@oxlint/linux-arm64-musl@0.9.10': + optional: true + '@oxlint/linux-x64-gnu@0.11.1': optional: true + '@oxlint/linux-x64-gnu@0.9.10': + optional: true + '@oxlint/linux-x64-musl@0.11.1': optional: true + '@oxlint/linux-x64-musl@0.9.10': + optional: true + '@oxlint/win32-arm64@0.11.1': optional: true + '@oxlint/win32-arm64@0.9.10': + optional: true + '@oxlint/win32-x64@0.11.1': optional: true + '@oxlint/win32-x64@0.9.10': + optional: true + '@parcel/watcher-android-arm64@2.5.0': optional: true @@ -6134,6 +6240,11 @@ snapshots: dependencies: '@babel/types': 7.25.6 + '@types/chrome@0.0.268': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.15 + '@types/chrome@0.0.280': dependencies: '@types/filesystem': 0.0.36 @@ -8350,6 +8461,17 @@ snapshots: '@oxlint/win32-arm64': 0.11.1 '@oxlint/win32-x64': 0.11.1 + oxlint@0.9.10: + optionalDependencies: + '@oxlint/darwin-arm64': 0.9.10 + '@oxlint/darwin-x64': 0.9.10 + '@oxlint/linux-arm64-gnu': 0.9.10 + '@oxlint/linux-arm64-musl': 0.9.10 + '@oxlint/linux-x64-gnu': 0.9.10 + '@oxlint/linux-x64-musl': 0.9.10 + '@oxlint/win32-arm64': 0.9.10 + '@oxlint/win32-x64': 0.9.10 + p-cancelable@3.0.0: {} package-json-from-dist@1.0.0: {}