├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── esm-lint.yml ├── .gitignore ├── .npmignore ├── license ├── package-lock.json ├── package.json ├── readme.md ├── source ├── index.ts ├── storage-item-map.md ├── storage-item-map.test.js ├── storage-item-map.ts ├── storage-item.md ├── storage-item.test-d.ts ├── storage-item.test.js └── storage-item.ts ├── tsconfig.json ├── vitest.config.js └── vitest.setup.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | Lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version-file: package.json 15 | - run: npm ci 16 | - run: npx xo 17 | 18 | Build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version-file: package.json 25 | - run: npm ci 26 | - run: npm run prepack 27 | 28 | Test: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version-file: package.json 35 | - run: npm ci 36 | - run: npm run build 37 | - run: npx vitest 38 | -------------------------------------------------------------------------------- /.github/workflows/esm-lint.yml: -------------------------------------------------------------------------------- 1 | env: 2 | IMPORT_STATEMENT: import "webext-storage" 3 | NODE_VERSION: 18 4 | 5 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/esm-lint 6 | # SOURCE: https://github.com/fregante/ghatemplates 7 | 8 | name: ESM 9 | on: 10 | pull_request: 11 | branches: 12 | - '*' 13 | push: 14 | branches: 15 | - master 16 | - main 17 | jobs: 18 | Pack: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - run: npm install 23 | - run: npm run build --if-present 24 | - run: npm pack --dry-run 25 | - run: npm pack | tail -1 | xargs -n1 tar -xzf 26 | - uses: actions/upload-artifact@v4 27 | with: 28 | path: package 29 | Publint: 30 | runs-on: ubuntu-latest 31 | needs: Pack 32 | steps: 33 | - uses: actions/download-artifact@v4 34 | - run: npx publint ./artifact 35 | Webpack: 36 | runs-on: ubuntu-latest 37 | needs: Pack 38 | steps: 39 | - uses: actions/download-artifact@v4 40 | - run: npm install --omit=dev ./artifact 41 | - run: echo "$IMPORT_STATEMENT" > index.js 42 | - run: webpack --entry ./index.js 43 | - run: cat dist/main.js 44 | Parcel: 45 | runs-on: ubuntu-latest 46 | needs: Pack 47 | steps: 48 | - uses: actions/download-artifact@v4 49 | - run: npm install --omit=dev ./artifact 50 | - run: echo "$IMPORT_STATEMENT" > index.js 51 | - run: > 52 | echo '{"@parcel/resolver-default": {"packageExports": true}}' > 53 | package.json 54 | - run: npx parcel@2 build index.js 55 | - run: cat dist/index.js 56 | Rollup: 57 | runs-on: ubuntu-latest 58 | needs: Pack 59 | steps: 60 | - uses: actions/download-artifact@v4 61 | - run: npm install --omit=dev ./artifact rollup@4 @rollup/plugin-node-resolve 62 | - run: echo "$IMPORT_STATEMENT" > index.js 63 | - run: npx rollup -p node-resolve index.js 64 | Vite: 65 | runs-on: ubuntu-latest 66 | needs: Pack 67 | steps: 68 | - uses: actions/download-artifact@v4 69 | - run: npm install --omit=dev ./artifact 70 | - run: echo '' > index.html 71 | - run: npx vite build 72 | - run: cat dist/assets/* 73 | esbuild: 74 | runs-on: ubuntu-latest 75 | needs: Pack 76 | steps: 77 | - uses: actions/download-artifact@v4 78 | - run: echo '{}' > package.json 79 | - run: echo "$IMPORT_STATEMENT" > index.js 80 | - run: npm install --omit=dev ./artifact 81 | - run: npx esbuild --bundle index.js 82 | TypeScript: 83 | runs-on: ubuntu-latest 84 | needs: Pack 85 | steps: 86 | - uses: actions/download-artifact@v4 87 | - run: echo '{"type":"module"}' > package.json 88 | - run: npm install --omit=dev ./artifact @sindresorhus/tsconfig 89 | - run: echo "$IMPORT_STATEMENT" > index.mts 90 | - run: > 91 | echo '{"extends":"@sindresorhus/tsconfig","files":["index.mts"]}' > 92 | tsconfig.json 93 | - run: npx --package typescript -- tsc 94 | - run: cat distribution/index.mjs 95 | Node: 96 | runs-on: ubuntu-latest 97 | needs: Pack 98 | steps: 99 | - uses: actions/download-artifact@v4 100 | - uses: actions/setup-node@v4 101 | with: 102 | node-version-file: artifact/package.json 103 | - run: echo "$IMPORT_STATEMENT" > index.mjs 104 | - run: npm install --omit=dev ./artifact 105 | - run: node index.mjs 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | Desktop.ini 4 | ._* 5 | Thumbs.db 6 | *.tmp 7 | *.bak 8 | *.log 9 | logs 10 | *.map 11 | dist 12 | distribution 13 | .built 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !distribution/* 3 | *.test.* 4 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Federico Brigante (https://fregante.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webext-storage", 3 | "version": "2.0.1", 4 | "description": "A more usable typed storage API for Web Extensions", 5 | "keywords": [ 6 | "browser", 7 | "extension", 8 | "chrome", 9 | "firefox", 10 | "safari", 11 | "webextension", 12 | "storage", 13 | "session", 14 | "sync", 15 | "local", 16 | "event", 17 | "web-ext" 18 | ], 19 | "repository": "fregante/webext-storage", 20 | "funding": "https://github.com/sponsors/fregante", 21 | "license": "MIT", 22 | "author": "Federico Brigante (https://fregante.com)", 23 | "type": "module", 24 | "exports": "./distribution/index.js", 25 | "main": "./distribution/index.js", 26 | "types": "./distribution/index.d.ts", 27 | "scripts": { 28 | "build": "tsc", 29 | "prepack": "tsc --sourceMap false", 30 | "test": "tsc && xo && tsd && vitest run", 31 | "test:watch": "vitest", 32 | "watch": "tsc --watch" 33 | }, 34 | "xo": { 35 | "envs": [ 36 | "browser" 37 | ], 38 | "globals": [ 39 | "chrome" 40 | ] 41 | }, 42 | "dependencies": { 43 | "webext-polyfill-kinda": "^1.0.2" 44 | }, 45 | "devDependencies": { 46 | "@sindresorhus/tsconfig": "^7.0.0", 47 | "@types/chrome": "^0.0.300", 48 | "@types/sinon-chrome": "^2.2.15", 49 | "jest-chrome": "^0.8.0", 50 | "sinon-chrome": "^3.0.1", 51 | "tsd": "^0.31.2", 52 | "typescript": "^5.7.3", 53 | "vitest": "^3.0.4", 54 | "xo": "^0.60.0" 55 | }, 56 | "engines": { 57 | "node": ">=20" 58 | }, 59 | "tsd": { 60 | "directory": "source" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # webext-storage [![][badge-gzip]][link-bundlephobia] 2 | 3 | [badge-gzip]: https://img.shields.io/bundlephobia/minzip/webext-storage.svg?label=gzipped 4 | [link-bundlephobia]: https://bundlephobia.com/result?p=webext-storage 5 | 6 | > A more usable typed storage API for Web Extensions 7 | 8 | **Sponsored by [PixieBrix](https://www.pixiebrix.com)** :tada: 9 | 10 | `chrome.storage.local.get()` is very inconvenient to use and it's not type-safe. This module provides a better API: 11 | 12 |
Comparison 💥 13 | 14 | ```ts 15 | const options = new StorageItem>('user-options'); 16 | const value = await options.get(); // The type is `Record | undefined` 17 | await options.set({color: 'red'}) // Type-checked 18 | options.onChanged(newValue => { 19 | console.log('New options', newValue) 20 | }); 21 | ``` 22 | 23 | - The storage item is defined in a single place, including its storageArea, its types and default value 24 | - `item.get()` returns the raw value instead of an object 25 | - Every `get` and `set` operation is type-safe 26 | - If you provide a `defaultValue`, the return type will not be ` | undefined` 27 | - Calling `.set(undefined)` will unset the value instead of the call being ignored 28 | - The `onChanged` example speaks for itself 29 | 30 | Now compare it to the native API: 31 | 32 | ```ts 33 | const storage = await chrome.storage.local.get('user-options'); 34 | const value = storage['user-options']; // The type is `any` 35 | await chrome.storage.local.set({['user-options']: {color: 'red'}}); // Not type-checked 36 | chrome.storage.onChanged.addListener((storageArea, change) => { 37 | if (storageArea === 'local' && change['user-options']) { // Repetitive 38 | console.log('New options', change['user-options'].newValue) 39 | } 40 | }); 41 | ``` 42 | 43 |
44 | 45 | ## Install 46 | 47 | ```sh 48 | npm install webext-storage 49 | ``` 50 | 51 | Or download the [standalone bundle](https://bundle.fregante.com/?pkg=webext-storage&name=window) to include in your `manifest.json`. 52 | 53 | ## Usage 54 | 55 | The package exports two classes: 56 | 57 | - [StorageItem](./source/storage-item.md) - Store a single value in storage 58 | - [StorageItemMap](./source/storage-item-map.md) - Store multiple related values in storage with the same type, similar to `new Map()` 59 | 60 | Support: 61 | 62 | - Browsers: Chrome, Firefox, and Safari 63 | - Manifest: v2 and v3 64 | - Permissions: `storage` or `unlimitedStorage` 65 | - Context: They can be called from any context 66 | 67 | ## Related 68 | 69 | - [webext-storage-cache](https://github.com/fregante/webext-storage-cache) - Cache values in your Web Extension and clear them on expiration. 70 | - [webext-tools](https://github.com/fregante/webext-tools) - Utility functions for Web Extensions. 71 | - [webext-content-scripts](https://github.com/fregante/webext-content-scripts) - Utility functions to inject content scripts in WebExtensions. 72 | - [webext-base-css](https://github.com/fregante/webext-base-css) - Extremely minimal stylesheet/setup for Web Extensions’ options pages (also dark mode) 73 | - [webext-options-sync](https://github.com/fregante/webext-options-sync) - Helps you manage and autosave your extension's options. 74 | - [More…](https://github.com/fregante/webext-fun) 75 | 76 | ## License 77 | 78 | MIT © [Federico Brigante](https://fregante.com) 79 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage-item.js'; 2 | export * from './storage-item-map.js'; 3 | -------------------------------------------------------------------------------- /source/storage-item-map.md: -------------------------------------------------------------------------------- 1 | # StorageItemMap 2 | 3 | Like an async `Map`, it stores multiple related values that have the same type. 4 | 5 | ```ts 6 | import {StorageItemMap} from "webext-storage"; 7 | 8 | const names = new StorageItemMap('names') 9 | // Or 10 | const names = new StorageItemMap('names', {defaultValue: '##unknown'}) 11 | 12 | await names.set('theslayer83', 'Mark Twain'); 13 | // Promise 14 | 15 | await names.get('theslayer83'); 16 | // Promise 17 | 18 | await names.remove('theslayer83'); 19 | // Promise 20 | 21 | await names.has('theslayer83'); 22 | // Promise 23 | 24 | await names.set({name: 'Ugo'}); 25 | // TypeScript Error: Argument of type '{ name: string; }' is not assignable to parameter of type 'string'. 26 | 27 | names.onChanged(username, newName => { 28 | console.log('The user', username, 'set their new name to', newName); 29 | }); 30 | ``` 31 | ## [StorageItem ↗️](./storage-item.md) 32 | 33 | ## [Main page ⏎](../readme.md) 34 | -------------------------------------------------------------------------------- /source/storage-item-map.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | test, beforeEach, assert, expect, vi, 3 | } from 'vitest'; 4 | import {StorageItemMap} from 'webext-storage'; 5 | 6 | const testItem = new StorageItemMap('height'); 7 | 8 | function createStorage(wholeCache, area = 'local') { 9 | for (const [key, data] of Object.entries(wholeCache)) { 10 | chrome.storage[area].get 11 | .withArgs(key) 12 | .yields({[key]: data}); 13 | } 14 | } 15 | 16 | beforeEach(() => { 17 | chrome.flush(); 18 | chrome.storage.local.get.yields({}); 19 | chrome.storage.local.set.yields(undefined); 20 | chrome.storage.local.remove.yields(undefined); 21 | chrome.storage.sync.get.yields({}); 22 | }); 23 | 24 | test('get() with empty storage', async () => { 25 | assert.equal(await testItem.get('rico'), undefined); 26 | }); 27 | 28 | test('get() with storage', async () => { 29 | createStorage({ 30 | 'height:::rico': 220, 31 | }); 32 | assert.equal(await testItem.get('rico'), 220); 33 | assert.equal(await (0, testItem.get)('rico'), 220, 'get method should be bound'); 34 | }); 35 | 36 | test('get() with default', async () => { 37 | const testItem = new StorageItemMap('sign', {defaultValue: 'unknown'}); 38 | assert.equal(await testItem.get('december'), 'unknown'); 39 | createStorage({ 40 | 'sign:::december': 'sagittarius', 41 | }); 42 | assert.equal(await testItem.get('december'), 'sagittarius'); 43 | }); 44 | 45 | test('get() with `sync` storage', async () => { 46 | const sync = new StorageItemMap('brands', {area: 'sync'}); 47 | await sync.get('MacBook'); 48 | 49 | assert.equal(chrome.storage.local.get.lastCall, undefined); 50 | 51 | const [argument] = chrome.storage.sync.get.lastCall.args; 52 | assert.deepEqual(argument, 'brands:::MacBook'); 53 | }); 54 | 55 | test('set(x, undefined) will unset the value', async () => { 56 | createStorage({ 57 | 'height:::rico': 220, 58 | }); 59 | assert.equal(await testItem.set('rico'), undefined); 60 | assert.equal(chrome.storage.local.set.lastCall, undefined); 61 | const [argument] = chrome.storage.local.remove.lastCall.args; 62 | assert.deepEqual(argument, 'height:::rico'); 63 | }); 64 | 65 | test('set() with value', async () => { 66 | await testItem.set('rico', 250); 67 | const [argument1] = chrome.storage.local.set.lastCall.args; 68 | assert.deepEqual(Object.keys(argument1), ['height:::rico']); 69 | assert.equal(argument1['height:::rico'], 250); 70 | 71 | await (0, testItem.set)('luigi', 120); 72 | const [argument2] = chrome.storage.local.set.lastCall.args; 73 | assert.equal(argument2['height:::luigi'], 120, 'get method should be bound'); 74 | }); 75 | 76 | test('remove()', async () => { 77 | await testItem.remove('mario'); 78 | const [argument] = chrome.storage.local.remove.lastCall.args; 79 | assert.equal(argument, 'height:::mario'); 80 | }); 81 | 82 | test('has() returns false', async () => { 83 | assert.equal(await testItem.has('rico'), false); 84 | }); 85 | 86 | test('has() returns true', async () => { 87 | createStorage({ 88 | 'height:::rico': 220, 89 | }); 90 | assert.equal(await testItem.has('rico'), true); 91 | assert.equal(await (0, testItem.has)('rico'), true, 'get method should be bound'); 92 | }); 93 | 94 | test('onChanged() is called for the correct item', async () => { 95 | const name = new StorageItemMap('distance'); 96 | const spy = vi.fn(); 97 | name.onChanged(spy); 98 | chrome.storage.onChanged.trigger({unrelatedKey: 123}, 'local'); 99 | expect(spy).not.toHaveBeenCalled(); 100 | chrome.storage.onChanged.trigger({'distance:::jupiter': 10e10}, 'sync'); 101 | expect(spy).not.toHaveBeenCalled(); 102 | chrome.storage.onChanged.trigger({'distance:::jupiter': 10e10}, 'local'); 103 | expect(spy).toHaveBeenCalled(); 104 | }); 105 | -------------------------------------------------------------------------------- /source/storage-item-map.ts: -------------------------------------------------------------------------------- 1 | import chromeP from 'webext-polyfill-kinda'; 2 | 3 | export type StorageItemMapOptions = { 4 | area?: chrome.storage.AreaName; 5 | defaultValue?: T; 6 | }; 7 | 8 | export class StorageItemMap< 9 | /** Only specify this if you don't have a default value */ 10 | Base, 11 | 12 | /** The return type will be undefined unless you provide a default value */ 13 | Return = Base | undefined, 14 | > { 15 | readonly prefix: `${string}:::`; 16 | readonly areaName: chrome.storage.AreaName; 17 | readonly defaultValue?: Return; 18 | 19 | constructor( 20 | key: string, 21 | { 22 | area = 'local', 23 | defaultValue, 24 | }: StorageItemMapOptions> = {}, 25 | ) { 26 | this.prefix = `${key}:::`; 27 | this.areaName = area; 28 | this.defaultValue = defaultValue; 29 | } 30 | 31 | has = async (secondaryKey: string): Promise => { 32 | const rawStorageKey = this.getRawStorageKey(secondaryKey); 33 | const result = await chromeP.storage[this.areaName].get(rawStorageKey); 34 | // Do not use Object.hasOwn() due to https://github.com/RickyMarou/jest-webextension-mock/issues/20 35 | return result[rawStorageKey] !== undefined; 36 | }; 37 | 38 | get = async (secondaryKey: string): Promise => { 39 | const rawStorageKey = this.getRawStorageKey(secondaryKey); 40 | const result = await chromeP.storage[this.areaName].get(rawStorageKey); 41 | // Do not use Object.hasOwn() due to https://github.com/RickyMarou/jest-webextension-mock/issues/20 42 | if (result[rawStorageKey] === undefined) { 43 | return this.defaultValue as Return; 44 | } 45 | 46 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- Assumes the user never uses the Storage API directly for this key 47 | return result[rawStorageKey]; 48 | }; 49 | 50 | set = async (secondaryKey: string, value: Exclude): Promise => { 51 | const rawStorageKey = this.getRawStorageKey(secondaryKey); 52 | // eslint-disable-next-line unicorn/prefer-ternary -- ur rong 53 | if (value === undefined) { 54 | await chromeP.storage[this.areaName].remove(rawStorageKey); 55 | } else { 56 | await chromeP.storage[this.areaName].set({[rawStorageKey]: value}); 57 | } 58 | }; 59 | 60 | remove = async (secondaryKey: string): Promise => { 61 | const rawStorageKey = this.getRawStorageKey(secondaryKey); 62 | await chromeP.storage[this.areaName].remove(rawStorageKey); 63 | }; 64 | 65 | /** @deprecated Only here to match the Map API; use `remove` instead */ 66 | // eslint-disable-next-line @typescript-eslint/member-ordering -- invalid 67 | delete = this.remove; 68 | 69 | onChanged( 70 | callback: (key: string, value: Exclude) => void, 71 | signal?: AbortSignal, 72 | ): void { 73 | const changeHandler = ( 74 | changes: Record, 75 | area: chrome.storage.AreaName, 76 | ) => { 77 | if (area !== this.areaName) { 78 | return; 79 | } 80 | 81 | for (const rawKey of Object.keys(changes)) { 82 | const secondaryKey = this.getSecondaryStorageKey(rawKey); 83 | if (secondaryKey) { 84 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Assumes the user never uses the Storage API directly 85 | callback(secondaryKey, changes[rawKey]!.newValue); 86 | } 87 | } 88 | }; 89 | 90 | chrome.storage.onChanged.addListener(changeHandler); 91 | signal?.addEventListener('abort', () => { 92 | chrome.storage.onChanged.removeListener(changeHandler); 93 | }, { 94 | once: true, 95 | }); 96 | } 97 | 98 | private getRawStorageKey(secondaryKey: string): string { 99 | return this.prefix + secondaryKey; 100 | } 101 | 102 | private getSecondaryStorageKey(rawKey: string): string | false { 103 | return rawKey.startsWith(this.prefix) && rawKey.slice(this.prefix.length); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /source/storage-item.md: -------------------------------------------------------------------------------- 1 | # StorageItem 2 | 3 | It stores a single value in storage. 4 | 5 | ```ts 6 | import {StorageItem} from "webext-storage"; 7 | 8 | const username = new StorageItem('username') 9 | // Or 10 | const username = new StorageItem('username', {defaultValue: 'admin'}) 11 | 12 | await username.set('Ugo'); 13 | // Promise 14 | 15 | await username.get(); 16 | // Promise 17 | 18 | await username.remove(); 19 | // Promise 20 | 21 | await names.has(); 22 | // Promise 23 | 24 | await username.set({name: 'Ugo'}); 25 | // TypeScript Error: Argument of type '{ name: string; }' is not assignable to parameter of type 'string'. 26 | 27 | username.onChanged(newName => { 28 | console.log('The user’s new name is', newName); 29 | }); 30 | ``` 31 | 32 | ## [StorageItemMap ↗️](./storage-item-map.md) 33 | 34 | ## [Main page ⏎](../readme.md) 35 | -------------------------------------------------------------------------------- /source/storage-item.test-d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | /* eslint-disable no-new -- Type tests only */ 3 | import {expectType, expectNotAssignable, expectAssignable} from 'tsd'; 4 | import {StorageItem} from './storage-item.js'; 5 | 6 | type Primitive = boolean | number | string; 7 | type Value = Primitive | Primitive[] | Record; 8 | 9 | new StorageItem('key', {area: 'local'}); 10 | new StorageItem('key', {area: 'sync'}); 11 | 12 | // No type, no default, return `unknown` 13 | const unknownItem = new StorageItem('key'); 14 | expectAssignable>(unknownItem.get()); 15 | await unknownItem.set(1); 16 | await unknownItem.set(null); 17 | 18 | // Explicit type, no default, return `T | undefined` 19 | const objectNoDefault = new StorageItem<{name: string}>('key'); 20 | expectType>(objectNoDefault.get()); 21 | expectType>(objectNoDefault.set({name: 'new name'})); 22 | 23 | // NonNullable from default 24 | const stringDefault = new StorageItem('key', {defaultValue: 'SMASHING'}); 25 | expectAssignable>(stringDefault.get()); 26 | expectNotAssignable>(stringDefault.get()); 27 | expectType>(stringDefault.get()); 28 | expectType>(stringDefault.set('some string')); 29 | 30 | // NonNullable from default, includes broader type as generic 31 | // The second type parameter must be re-specified because TypeScript stops inferring it 32 | // https://github.com/microsoft/TypeScript/issues/26242 33 | const broadGeneric = new StorageItem, Record>('key', {defaultValue: {a: 1}}); 34 | expectAssignable>>(broadGeneric.get()); 35 | 36 | // Allows null as a value via default value 37 | const storeNull = new StorageItem('key', {defaultValue: null}); 38 | await storeNull.set(null); 39 | expectType>(storeNull.get()); 40 | 41 | // Allows null as a value type parameters 42 | const storeSomeNull = new StorageItem('key'); 43 | await storeSomeNull.set(1); 44 | await storeSomeNull.set(null); 45 | expectType>(storeSomeNull.get()); 46 | 47 | // @ts-expect-error Type is string 48 | await stringDefault.set(1); 49 | 50 | // @ts-expect-error Type is string 51 | await stringDefault.set(true); 52 | 53 | // @ts-expect-error Type is string 54 | await stringDefault.set([true, 'string']); 55 | 56 | // @ts-expect-error Type is string 57 | await stringDefault.set({wow: [true, 'string']}); 58 | 59 | // @ts-expect-error Type is string 60 | await stringDefault.set(1, {days: 1}); 61 | 62 | stringDefault.onChanged(value => { 63 | expectType(value); 64 | }); 65 | 66 | objectNoDefault.onChanged(value => { 67 | expectType<{name: string}>(value); 68 | }); 69 | 70 | // @ts-expect-error Don't allow mismatched types 71 | new StorageItem('key', {defaultValue: 'SMASHING'}); 72 | -------------------------------------------------------------------------------- /source/storage-item.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | test, beforeEach, assert, expect, vi, 3 | } from 'vitest'; 4 | import {StorageItem} from 'webext-storage'; 5 | 6 | const testItem = new StorageItem('name'); 7 | 8 | function createStorage(wholeCache, area = 'local') { 9 | for (const [key, data] of Object.entries(wholeCache)) { 10 | chrome.storage[area].get 11 | .withArgs(key) 12 | .yields({[key]: data}); 13 | } 14 | } 15 | 16 | beforeEach(() => { 17 | chrome.flush(); 18 | chrome.storage.local.get.yields({}); 19 | chrome.storage.local.set.yields(undefined); 20 | chrome.storage.local.remove.yields(undefined); 21 | chrome.storage.sync.get.yields({}); 22 | }); 23 | 24 | test('get() with empty storage', async () => { 25 | assert.equal(await testItem.get(), undefined); 26 | }); 27 | 28 | test('get() with storage', async () => { 29 | createStorage({ 30 | name: 'Rico', 31 | }); 32 | assert.equal(await testItem.get(), 'Rico'); 33 | assert.equal(await (0, testItem.get)(), 'Rico', 'get method should be bound'); 34 | }); 35 | 36 | test('get() with default', async () => { 37 | const testItem = new StorageItem('name', {defaultValue: 'Anne'}); 38 | assert.equal(await testItem.get(), 'Anne'); 39 | createStorage({ 40 | name: 'Rico', 41 | }); 42 | assert.equal(await testItem.get(), 'Rico'); 43 | }); 44 | 45 | test('get() with `sync` storage', async () => { 46 | const sync = new StorageItem('name', {area: 'sync'}); 47 | await sync.get(); 48 | 49 | assert.equal(chrome.storage.local.get.lastCall, undefined); 50 | 51 | const [argument] = chrome.storage.sync.get.lastCall.args; 52 | assert.deepEqual(argument, 'name'); 53 | }); 54 | 55 | test('set(undefined) will unset the value', async () => { 56 | createStorage({ 57 | name: 'Rico', 58 | }); 59 | assert.equal(await testItem.set(), undefined); 60 | assert.equal(chrome.storage.local.set.lastCall, undefined); 61 | const [argument] = chrome.storage.local.remove.lastCall.args; 62 | assert.deepEqual(argument, 'name'); 63 | }); 64 | 65 | test('set() with value', async () => { 66 | await testItem.set('Anne'); 67 | const [argument1] = chrome.storage.local.set.lastCall.args; 68 | assert.deepEqual(Object.keys(argument1), ['name']); 69 | assert.equal(argument1.name, 'Anne'); 70 | 71 | await (0, testItem.set)('Rico'); 72 | const [argument2] = chrome.storage.local.set.lastCall.args; 73 | assert.equal(argument2.name, 'Rico', 'get method should be bound'); 74 | }); 75 | 76 | test('remove()', async () => { 77 | await testItem.remove(); 78 | const [argument] = chrome.storage.local.remove.lastCall.args; 79 | assert.equal(argument, 'name'); 80 | }); 81 | 82 | test('has() returns false', async () => { 83 | createStorage({}); 84 | assert.equal(await testItem.has(), false); 85 | }); 86 | 87 | test('has() returns true', async () => { 88 | createStorage({ 89 | name: 'Rico', 90 | }); 91 | assert.equal(await testItem.has(), true); 92 | assert.equal(await (0, testItem.has)(), true, 'get method should be bound'); 93 | }); 94 | 95 | test('onChanged() is called for the correct item', async () => { 96 | const name = new StorageItem('name'); 97 | const spy = vi.fn(); 98 | name.onChanged(spy); 99 | chrome.storage.onChanged.trigger({unrelatedKey: 123}, 'local'); 100 | expect(spy).not.toHaveBeenCalled(); 101 | chrome.storage.onChanged.trigger({name: 'Anne'}, 'sync'); 102 | expect(spy).not.toHaveBeenCalled(); 103 | chrome.storage.onChanged.trigger({name: 'Anne'}, 'local'); 104 | expect(spy).toHaveBeenCalled(); 105 | }); 106 | -------------------------------------------------------------------------------- /source/storage-item.ts: -------------------------------------------------------------------------------- 1 | import chromeP from 'webext-polyfill-kinda'; 2 | 3 | export type StorageItemOptions = { 4 | area?: chrome.storage.AreaName; 5 | defaultValue?: T; 6 | }; 7 | 8 | export class StorageItem< 9 | /** Only specify this if you don't have a default value */ 10 | Base, 11 | 12 | /** The return type will be undefined unless you provide a default value */ 13 | Return = Base | undefined, 14 | > { 15 | readonly area: chrome.storage.AreaName; 16 | readonly defaultValue?: Return; 17 | 18 | /** @deprecated Use `onChanged` instead */ 19 | onChange = this.onChanged; 20 | 21 | constructor( 22 | readonly key: string, 23 | { 24 | area = 'local', 25 | defaultValue, 26 | }: StorageItemOptions> = {}, 27 | ) { 28 | this.area = area; 29 | this.defaultValue = defaultValue; 30 | } 31 | 32 | get = async (): Promise => { 33 | const result = await chromeP.storage[this.area].get(this.key); 34 | // Do not use Object.hasOwn() due to https://github.com/RickyMarou/jest-webextension-mock/issues/20 35 | if (result[this.key] === undefined) { 36 | return this.defaultValue as Return; 37 | } 38 | 39 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- Assumes the user never uses the Storage API directly 40 | return result[this.key]; 41 | }; 42 | 43 | set = async (value: Exclude): Promise => { 44 | // eslint-disable-next-line unicorn/prefer-ternary -- ur rong 45 | if (value === undefined) { 46 | await chromeP.storage[this.area].remove(this.key); 47 | } else { 48 | await chromeP.storage[this.area].set({[this.key]: value}); 49 | } 50 | }; 51 | 52 | has = async (): Promise => { 53 | const result = await chromeP.storage[this.area].get(this.key); 54 | // Do not use Object.hasOwn() due to https://github.com/RickyMarou/jest-webextension-mock/issues/20 55 | return result[this.key] !== undefined; 56 | }; 57 | 58 | remove = async (): Promise => { 59 | await chromeP.storage[this.area].remove(this.key); 60 | }; 61 | 62 | onChanged( 63 | callback: (value: Exclude) => void, 64 | signal?: AbortSignal, 65 | ): void { 66 | const changeHandler = ( 67 | changes: Record, 68 | area: chrome.storage.AreaName, 69 | ) => { 70 | const changedItem = changes[this.key]; 71 | if (area === this.area && changedItem) { 72 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Assumes the user never uses the Storage API directly 73 | callback(changedItem.newValue); 74 | } 75 | }; 76 | 77 | chrome.storage.onChanged.addListener(changeHandler); 78 | signal?.addEventListener('abort', () => { 79 | chrome.storage.onChanged.removeListener(changeHandler); 80 | }, { 81 | once: true, 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "source" 5 | }, 6 | "include": [ 7 | "source", 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | setupFiles: [ 6 | './vitest.setup.js', 7 | ], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /vitest.setup.js: -------------------------------------------------------------------------------- 1 | import chrome from 'sinon-chrome'; 2 | 3 | globalThis.chrome = chrome; 4 | --------------------------------------------------------------------------------