├── .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 |
--------------------------------------------------------------------------------