UNPKG

@wxt-dev/storage

Version:

Web extension storage API provided by WXT, supports all browsers.

426 lines (423 loc) 15 kB
import { browser } from "@wxt-dev/browser"; import { Mutex } from "async-mutex"; import { dequal } from "dequal/lite"; //#region src/index.ts /** * 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 */ const storage = createStorage(); function createStorage() { const drivers = { local: createDriver("local"), session: createDriver("session"), sync: createDriver("sync"), managed: createDriver("managed") }; const getDriver = (area) => { 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) => { const deliminatorIndex = key.indexOf(":"); const driverArea = key.substring(0, deliminatorIndex); 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) => key + "$"; const mergeMeta = (oldMeta, newMeta) => { const newFields = { ...oldMeta }; Object.entries(newMeta).forEach(([key, value]) => { if (value == null) delete newFields[key]; else newFields[key] = value; }); return newFields; }; const getValueOrFallback = (value, fallback) => value ?? fallback ?? null; const getMetaValue = (properties) => typeof properties === "object" && !Array.isArray(properties) ? properties : {}; const getItem = async (driver, driverKey, opts) => { return getValueOrFallback(await driver.getItem(driverKey), opts?.fallback ?? opts?.defaultValue); }; const getMeta = async (driver, driverKey) => { const metaKey = getMetaKey(driverKey); return getMetaValue(await driver.getItem(metaKey)); }; const setItem = async (driver, driverKey, value) => { await driver.setItem(driverKey, value ?? null); }; const setMeta = async (driver, driverKey, properties) => { const metaKey = getMetaKey(driverKey); const existingFields = getMetaValue(await driver.getItem(metaKey)); await driver.setItem(metaKey, mergeMeta(existingFields, properties)); }; const removeItem = async (driver, driverKey, opts) => { await driver.removeItem(driverKey); if (opts?.removeMeta) { const metaKey = getMetaKey(driverKey); await driver.removeItem(metaKey); } }; const removeMeta = async (driver, driverKey, properties) => { 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, driverKey, cb) => driver.watch(driverKey, cb); return { getItem: async (key, opts) => { const { driver, driverKey } = resolveKey(key); return await getItem(driver, driverKey, opts); }, getItems: async (keys) => { const areaToKeyMap = /* @__PURE__ */ new Map(); const keyToOptsMap = /* @__PURE__ */ new Map(); const orderedKeys = []; keys.forEach((key) => { let keyStr; let opts; if (typeof key === "string") keyStr = key; else if ("getValue" in key) { keyStr = key.key; opts = { fallback: key.fallback }; } else { 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 = /* @__PURE__ */ new Map(); await Promise.all(Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => { (await drivers[driverArea].getItems(keys)).forEach((driverResult) => { const key = `${driverArea}:${driverResult.key}`; 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((map, key) => { map[key.driverArea] ??= []; map[key.driverArea].push(key); return map; }, {}); const resultsMap = {}; await Promise.all(Object.entries(areaToDriverMetaKeysMap).map(async ([area, keys]) => { const areaRes = await browser.storage[area].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 = {}; 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]) => { await getDriver(driverArea).setItems(values); })); }, setMeta: async (key, properties) => { const { driver, driverKey } = resolveKey(key); await setMeta(driver, driverKey, properties); }, setMetas: async (items) => { const areaToMetaUpdatesMap = {}; 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); const metaKeys = updates.map(({ key }) => getMetaKey(key)); 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 = {}; keys.forEach((key) => { let keyStr; let opts; if (typeof key === "string") keyStr = key; else if ("getValue" in key) keyStr = key.key; else if ("item" in key) { keyStr = key.item.key; opts = key.options; } else { 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]) => { await getDriver(driverArea).removeItems(keys); })); }, clear: async (base) => { await getDriver(base).clear(); }, removeMeta: async (key, properties) => { const { driver, driverKey } = resolveKey(key); await removeMeta(driver, driverKey, properties); }, snapshot: async (base, opts) => { const data = await getDriver(base).snapshot(); opts?.excludeKeys?.forEach((key) => { delete data[key]; delete data[getMetaKey(key)]; }); return data; }, restoreSnapshot: async (base, data) => { await getDriver(base).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 = {}, onMigrationComplete, debug = false } = opts ?? {}; if (targetVersion < 1) throw Error("Storage item version cannot be less than 1. Initial versions should be set to 1, not 0."); let needsVersionSet = false; const migrate = async () => { const driverMetaKey = getMetaKey(driverKey); const [{ value }, { value: meta }] = await driver.getItems([driverKey, driverMetaKey]); needsVersionSet = value == null && meta?.v == null && !!targetVersion; if (value == null) return; const currentVersion = meta?.v ?? 1; if (currentVersion > targetVersion) throw Error(`Version downgrade detected (v${currentVersion} -> v${targetVersion}) for "${key}"`); if (currentVersion === targetVersion) return; if (debug) 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; if (debug) console.debug(`[@wxt-dev/storage] Storage migration processed for version: v${migrateToVersion}`); } catch (err) { throw new MigrationError(key, migrateToVersion, { cause: err }); } await driver.setItems([{ key: driverKey, value: migratedValue }, { key: driverMetaKey, value: { ...meta, v: targetVersion } }]); if (debug) console.debug(`[@wxt-dev/storage] Storage migration completed for ${key} v${targetVersion}`, { migratedValue }); onMigrationComplete?.(migratedValue, targetVersion); }; 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); if (value != null || opts?.init == null) return value; const newValue = await opts.init(); await driver.setItem(driverKey, newValue); if (value == null && targetVersion > 1) await setMeta(driver, driverKey, { v: targetVersion }); return newValue; }); 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; if (needsVersionSet) { needsVersionSet = false; await Promise.all([setItem(driver, driverKey, value), setMeta(driver, driverKey, { v: targetVersion })]); } else 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 }; } }; } function createDriver(storageArea) { const getStorageArea = () => { if (browser.runtime == null) throw Error(`'wxt/storage' must be loaded in a web extension environment - 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 `); 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 = /* @__PURE__ */ new Set(); return { getItem: async (key) => { return (await getStorageArea().get(key))[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); }, clear: async () => { await getStorageArea().clear(); }, snapshot: async () => { return await getStorageArea().get(); }, restoreSnapshot: async (data) => { await getStorageArea().set(data); }, watch(key, cb) { const listener = (changes) => { const change = changes[key]; if (change == null || 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(); } }; } var MigrationError = class extends Error { constructor(key, version, options) { super(`v${version} migration failed for "${key}"`, options); this.key = key; this.version = version; } }; //#endregion export { MigrationError, storage };