├── .github └── SECURITY.md ├── .gitignore ├── LICENSE ├── README.md ├── jest.config.mjs ├── jest.setup.mts ├── package.json ├── src ├── get-quota-warning.ts ├── hook.ts ├── index.test.ts ├── index.ts ├── inspector.ts ├── secure.test.ts ├── secure.ts └── utils.ts └── tsconfig.json /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | Contact: security@plasmo.com 2 | Expires: 2100-01-01T00:00:00.000Z 3 | Acknowledgments: https://www.plasmo.com/security/hall-of-fame 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Lockfiles - See https://github.com/PlasmoHQ/p1asm0 4 | pnpm-lock.yaml 5 | package-lock.json 6 | yarn.lock 7 | 8 | .turbo 9 | 10 | key.json 11 | dist/ 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Plasmo Corp. (https://www.plasmo.com) and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | plasmo logo 4 | 5 |

6 | 7 |

8 | 9 | See License 10 | 11 | 12 | NPM Install 13 | 14 | 15 | Follow PlasmoHQ on Twitter 16 | 17 | 18 | Watch our Live DEMO every Friday 19 | 20 | 21 | Join our Discord for support and chat about our projects 22 | 23 |

24 | 25 | # @plasmohq/storage 26 | 27 | `@plasmohq/storage` is an utility library from [plasmo](https://www.plasmo.com/) that abstract away the persistent storage API available to browser extension. It fallbacks to localstorage in context where the extension storage API is not available, allowing for state sync between popup - options - contents - background. 28 | 29 | > This library will enable the `storage` permission automatically if used with the [Plasmo framework](https://docs.plasmo.com) 30 | 31 | ## Documentation 32 | 33 | Visit: [https://docs.plasmo.com/framework/storage](https://docs.plasmo.com/framework/storage) 34 | 35 | ## Firefox 36 | 37 | To use the storage API on Firefox during development you need to add an addon ID to your manifest, otherwise, you will get this error: 38 | 39 | > Error: The storage API will not work with a temporary addon ID. Please add an explicit addon ID to your manifest. For more information see https://mzl.la/3lPk1aE. 40 | 41 | To add an addon ID to your manifest, add this to your package.json: 42 | 43 | ```JSON 44 | "manifest": { 45 | "browser_specific_settings": { 46 | "gecko": { 47 | "id": "your-id@example.com" 48 | } 49 | } 50 | } 51 | ``` 52 | 53 | During development, you may use any ID. If you have published your extension, you can use the ID assigned by Mozilla Addons. 54 | 55 | ## Usage Examples 56 | 57 | - [MICE](https://github.com/PlasmoHQ/mice) 58 | - [World Edit](https://github.com/PlasmoHQ/world-edit) 59 | - [with-storage](https://github.com/PlasmoHQ/examples/tree/main/with-storage) 60 | - [with-redux](https://github.com/PlasmoHQ/examples/tree/main/with-redux) 61 | 62 | ## Why? 63 | 64 | > To boldly go where no one has gone before 65 | 66 | ## License 67 | 68 | [MIT](./LICENSE) 🖖 [Plasmo Corp.](https://plasmo.com) 69 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@jest/types').Config.InitialOptions} 3 | */ 4 | 5 | const config = { 6 | clearMocks: true, 7 | testEnvironment: "jsdom", 8 | setupFilesAfterEnv: ["/jest.setup.mts"], 9 | extensionsToTreatAsEsm: [".ts"], 10 | globals: { 11 | chrome: { 12 | runtime: { 13 | id: "plasmo-storage-test" 14 | } 15 | } 16 | }, 17 | transform: { 18 | "^.+.ts?$": [ 19 | "ts-jest", 20 | { 21 | useESM: true, 22 | isolatedModules: true 23 | } 24 | ] 25 | }, 26 | testMatch: ["**/*.test.ts"], 27 | verbose: true, 28 | moduleNameMapper: { 29 | "^~(.*)$": "/src/$1", 30 | "^(\\.{1,2}/.*)\\.js$": "$1" 31 | } 32 | } 33 | export default config 34 | -------------------------------------------------------------------------------- /jest.setup.mts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals" 2 | 3 | /** 4 | * Mimic the webcrypto API without implementing the actual encryption 5 | * algorithms. Only the mock implementations used by the SecureStorage 6 | */ 7 | export const cryptoMock = { 8 | subtle: { 9 | importKey: jest.fn(), 10 | deriveKey: jest.fn(), 11 | decrypt: jest.fn(), 12 | encrypt: jest.fn(), 13 | digest: jest.fn() 14 | }, 15 | getRandomValues: jest.fn() 16 | } 17 | 18 | cryptoMock.subtle.importKey.mockImplementation( 19 | (format, keyData, algorithm, extractable, keyUsages) => { 20 | return Promise.resolve({ 21 | format, 22 | keyData, 23 | algorithm, 24 | extractable, 25 | keyUsages 26 | }) 27 | } 28 | ) 29 | 30 | cryptoMock.subtle.deriveKey.mockImplementation( 31 | (algorithm, baseKey, derivedKeyAlgorithm, extractable, keyUsages) => { 32 | return Promise.resolve({ 33 | algorithm, 34 | baseKey, 35 | derivedKeyAlgorithm, 36 | extractable, 37 | keyUsages 38 | }) 39 | } 40 | ) 41 | 42 | // @ts-ignore 43 | cryptoMock.subtle.decrypt.mockImplementation((_, __, data: ArrayBufferLike) => { 44 | return Promise.resolve(new Uint8Array(data)) 45 | }) 46 | 47 | // @ts-ignore 48 | cryptoMock.subtle.encrypt.mockImplementation((_, __, data: ArrayBufferLike) => { 49 | return Promise.resolve(new Uint8Array(data)) 50 | }) 51 | 52 | // @ts-ignore 53 | cryptoMock.subtle.digest.mockImplementation((_, __) => { 54 | return Promise.resolve(new Uint8Array([0x01, 0x02, 0x03, 0x04])) 55 | }) 56 | 57 | // @ts-ignore 58 | cryptoMock.getRandomValues.mockImplementation((array: Array) => { 59 | for (let i = 0; i < array.length; i++) { 60 | array[i] = Math.floor(Math.random() * 256) 61 | } 62 | return array 63 | }) 64 | 65 | // The globalThis does not define crypto by default 66 | Object.defineProperty(globalThis, "crypto", { 67 | value: cryptoMock, 68 | writable: true, 69 | enumerable: true, 70 | configurable: true 71 | }) 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@plasmohq/storage", 3 | "version": "1.15.0", 4 | "description": "Safely and securely store data and share them across your extension and websites", 5 | "type": "module", 6 | "module": "./src/index.ts", 7 | "types": "./src/index.ts", 8 | "publishConfig": { 9 | "module": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "exports": { 12 | "./inspector": { 13 | "types": "./dist/inspector.d.ts", 14 | "import": "./dist/inspector.js", 15 | "require": "./dist/inspector.cjs" 16 | }, 17 | "./secure": { 18 | "types": "./dist/secure.d.ts", 19 | "import": "./dist/secure.js", 20 | "require": "./dist/secure.cjs" 21 | }, 22 | "./hook": { 23 | "types": "./dist/hook.d.ts", 24 | "import": "./dist/hook.js", 25 | "require": "./dist/hook.cjs" 26 | }, 27 | ".": { 28 | "types": "./dist/index.d.ts", 29 | "import": "./dist/index.js", 30 | "require": "./dist/index.cjs" 31 | } 32 | }, 33 | "typesVersions": { 34 | "*": { 35 | "inspector": [ 36 | "./dist/inspector.d.ts" 37 | ], 38 | "secure": [ 39 | "./dist/secure.d.ts" 40 | ], 41 | "hook": [ 42 | "./dist/hook.d.ts" 43 | ] 44 | } 45 | } 46 | }, 47 | "exports": { 48 | "./inspector": { 49 | "import": "./src/inspector.ts", 50 | "require": "./src/inspector.ts", 51 | "types": "./src/inspector.ts" 52 | }, 53 | "./secure": { 54 | "import": "./src/secure.ts", 55 | "require": "./src/secure.ts", 56 | "types": "./src/secure.ts" 57 | }, 58 | "./hook": { 59 | "import": "./src/hook.ts", 60 | "require": "./src/hook.ts", 61 | "types": "./src/hook.ts" 62 | }, 63 | ".": { 64 | "import": "./src/index.ts", 65 | "require": "./src/index.ts", 66 | "types": "./src/index.ts" 67 | } 68 | }, 69 | "typesVersions": { 70 | "*": { 71 | "inspector": [ 72 | "./src/inspector.d.ts" 73 | ], 74 | "secure": [ 75 | "./src/secure.d.ts" 76 | ], 77 | "hook": [ 78 | "./src/hook.d.ts" 79 | ] 80 | } 81 | }, 82 | "files": [ 83 | "dist" 84 | ], 85 | "tsup": { 86 | "entry": [ 87 | "src/index.ts", 88 | "src/hook.ts", 89 | "src/secure.ts", 90 | "src/inspector.ts" 91 | ], 92 | "format": [ 93 | "esm", 94 | "cjs" 95 | ], 96 | "target": "esnext", 97 | "platform": "node", 98 | "splitting": false, 99 | "bundle": true 100 | }, 101 | "scripts": { 102 | "dev": "run-p dev:*", 103 | "dev:compile": "tsup --watch --sourcemap", 104 | "dev:test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch", 105 | "build": "tsup --dts-resolve --minify --clean", 106 | "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", 107 | "prepublishOnly": "pnpm build" 108 | }, 109 | "author": "Plasmo Corp. ", 110 | "contributors": [ 111 | "@louisgv", 112 | "@ColdSauce", 113 | "@vantezzen" 114 | ], 115 | "repository": { 116 | "type": "git", 117 | "url": "https://github.com/PlasmoHQ/storage.git" 118 | }, 119 | "license": "MIT", 120 | "keywords": [ 121 | "localstorage", 122 | "react-hook", 123 | "browser-extension", 124 | "chrome-storage" 125 | ], 126 | "peerDependencies": { 127 | "react": "^16.8.6 || ^17 || ^18 || ^19.0.0" 128 | }, 129 | "peerDependenciesMeta": { 130 | "react": { 131 | "optional": true 132 | } 133 | }, 134 | "dependencies": { 135 | "pify": "6.1.0" 136 | }, 137 | "devDependencies": { 138 | "@jest/globals": "29.7.0", 139 | "@jest/types": "29.6.3", 140 | "@plasmohq/rps": "workspace:*", 141 | "@testing-library/react": "16.2.0", 142 | "@types/chrome": "0.0.258", 143 | "@types/node": "20.11.5", 144 | "@types/react": "19.0.8", 145 | "canvas": "3.1.0", 146 | "cross-env": "7.0.3", 147 | "jest": "29.7.0", 148 | "jest-environment-jsdom": "29.7.0", 149 | "prettier": "3.2.4", 150 | "react": "19.0.0", 151 | "react-dom": "19.0.0", 152 | "rimraf": "5.0.5", 153 | "ts-jest": "29.1.1", 154 | "tsup": "8.0.1", 155 | "typescript": "5.3.3" 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/get-quota-warning.ts: -------------------------------------------------------------------------------- 1 | import type { BaseStorage } from "./index" 2 | 3 | // https://stackoverflow.com/a/23329386/3151192 4 | function byteLengthCharCode(str: string) { 5 | // returns the byte length of an utf8 string 6 | let s = str.length 7 | for (var i = str.length - 1; i >= 0; i--) { 8 | const code = str.charCodeAt(i) 9 | if (code > 0x7f && code <= 0x7ff) s++ 10 | else if (code > 0x7ff && code <= 0xffff) s += 2 11 | if (code >= 0xdc00 && code <= 0xdfff) i-- //trail surrogate 12 | } 13 | return s 14 | } 15 | 16 | export const getQuotaWarning = async ( 17 | storage: BaseStorage, 18 | key: string, 19 | value: string 20 | ) => { 21 | let warning = "" 22 | 23 | checkQuota: if (storage.area !== "managed") { 24 | // Explicit access to the un-polyfilled version is used here 25 | // as the polyfill might override the non-existent function 26 | if (!chrome?.storage?.[storage.area].getBytesInUse) { 27 | break checkQuota 28 | } 29 | 30 | const client = storage.primaryClient 31 | 32 | // Firefox doesn't support quota bytes so the defined value at 33 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/sync#storage_quotas_for_sync_data 34 | // is used 35 | const quota: number = client["QUOTA_BYTES"] || 102400 36 | 37 | const newValueByteSize = byteLengthCharCode(value) 38 | const [byteInUse, oldValueByteSize] = await Promise.all([ 39 | client.getBytesInUse(), 40 | client.getBytesInUse(key) 41 | ]) 42 | 43 | const newByteInUse = byteInUse + newValueByteSize - oldValueByteSize 44 | 45 | // if used 80% of quota, show warning 46 | const usedPercentage = newByteInUse / quota 47 | if (usedPercentage > 0.8) { 48 | warning = `Storage quota is almost full. ${newByteInUse}/${quota}, ${ 49 | usedPercentage * 100 50 | }%` 51 | } 52 | 53 | if (usedPercentage > 1.0) { 54 | throw new Error(`ABORTED - New value would exceed storage quota.`) 55 | } 56 | } 57 | 58 | return warning 59 | } 60 | -------------------------------------------------------------------------------- /src/hook.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 Plasmo Corp. (https://www.plasmo.com) and contributors 3 | * Licensed under the MIT license. 4 | * This module share storage between chrome storage and local storage. 5 | */ 6 | import { useCallback, useEffect, useRef, useState } from "react" 7 | 8 | import { BaseStorage, Storage, type StorageCallbackMap } from "./index" 9 | 10 | type Setter = ((v?: T, isHydrated?: boolean) => T) | T 11 | 12 | /** 13 | * isPublic: If true, the value will be synced with web API Storage 14 | */ 15 | export type RawKey = 16 | | string 17 | | { 18 | key: string 19 | instance: BaseStorage 20 | } 21 | 22 | /** 23 | * https://docs.plasmo.com/framework/storage 24 | * @param onInit If it is a function, the returned value will be rendered and persisted. If it is a static value, it will only be rendered, not persisted 25 | * @returns 26 | */ 27 | export function useStorage( 28 | rawKey: RawKey, 29 | onInit: Setter 30 | ): [ 31 | T, 32 | (setter: Setter) => Promise, 33 | { 34 | readonly setRenderValue: React.Dispatch> 35 | readonly setStoreValue: (v: T) => Promise 36 | readonly remove: () => void 37 | readonly isLoading: boolean 38 | } 39 | ] 40 | export function useStorage( 41 | rawKey: RawKey 42 | ): [ 43 | T | undefined, 44 | (setter: Setter) => Promise, 45 | { 46 | readonly setRenderValue: React.Dispatch> 47 | readonly setStoreValue: (v?: T) => Promise 48 | readonly remove: () => void 49 | readonly isLoading: boolean 50 | } 51 | ] 52 | export function useStorage(rawKey: RawKey, onInit?: Setter) { 53 | const isObjectKey = typeof rawKey === "object" 54 | 55 | const key = isObjectKey ? rawKey.key : rawKey 56 | 57 | // Render state 58 | const [renderValue, setRenderValue] = useState(onInit) 59 | const [isLoading, setIsLoading] = useState(true) 60 | 61 | // Use to ensure we don't set render state after unmounted 62 | const isMounted = useRef(false) 63 | 64 | // Ref that stores the render state, in order to minimize dependencies of callbacks below 65 | const renderValueRef = useRef(onInit instanceof Function ? onInit() : onInit) 66 | useEffect(() => { 67 | renderValueRef.current = renderValue 68 | }, [renderValue]) 69 | 70 | // Storage state 71 | const storageRef = useRef(isObjectKey ? rawKey.instance : new Storage()) 72 | 73 | // Save the value OR current rendering value into chrome storage 74 | const setStoreValue = useCallback( 75 | (v?: T) => 76 | storageRef.current.set(key, v !== undefined ? v : renderValueRef.current), 77 | [key] 78 | ) 79 | 80 | // Store the value into chrome storage, then set its render state 81 | const persistValue = useCallback( 82 | async (setter: Setter) => { 83 | const newValue = 84 | setter instanceof Function ? setter(renderValueRef.current) : setter 85 | 86 | await setStoreValue(newValue) 87 | 88 | if (isMounted.current) { 89 | setRenderValue(newValue) 90 | } 91 | }, 92 | [setStoreValue] 93 | ) 94 | 95 | useEffect(() => { 96 | isMounted.current = true 97 | const watchConfig: StorageCallbackMap = { 98 | [key]: (change) => { 99 | if (isMounted.current) { 100 | setRenderValue(change.newValue) 101 | setIsLoading(false) 102 | } 103 | } 104 | } 105 | 106 | storageRef.current.watch(watchConfig) 107 | 108 | const initializeStorage = async () => { 109 | const storedValue = await storageRef.current.get(key) 110 | 111 | if (onInit instanceof Function) { 112 | const initValue = onInit?.(storedValue, true) 113 | if (initValue !== undefined) { 114 | await persistValue(initValue) 115 | } 116 | } else { 117 | setRenderValue(storedValue !== undefined ? storedValue : onInit) 118 | } 119 | 120 | setIsLoading(false) 121 | } 122 | 123 | initializeStorage() 124 | 125 | return () => { 126 | isMounted.current = false 127 | storageRef.current.unwatch(watchConfig) 128 | if (onInit instanceof Function) { 129 | setRenderValue(onInit) 130 | } 131 | } 132 | }, [key, persistValue]) 133 | 134 | const remove = useCallback(() => { 135 | storageRef.current.remove(key) 136 | setRenderValue(undefined) 137 | }, [key]) 138 | 139 | return [ 140 | renderValue, 141 | persistValue, 142 | { 143 | setRenderValue, 144 | setStoreValue, 145 | remove, 146 | isLoading 147 | } 148 | ] as const 149 | } 150 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 Plasmo Corp. (https://www.plasmo.com) and contributors 3 | * Licensed under the MIT license. 4 | * This module share storage between chrome storage and local storage. 5 | */ 6 | import { beforeEach, describe, expect, jest, test } from "@jest/globals" 7 | import { act, renderHook, waitFor } from "@testing-library/react" 8 | 9 | import type { StorageWatchEventListener } from "~index" 10 | 11 | const { Storage } = await import("~index") 12 | const { useStorage } = await import("~hook") 13 | 14 | const mockStorage = { 15 | data: {}, 16 | get(key?: string) { 17 | if (!key) { 18 | return { ...this.data } 19 | } 20 | return { 21 | [key]: this.data[key] 22 | } 23 | }, 24 | getMany(keys?: string[]) { 25 | if (!keys) { 26 | return { ...this.data } 27 | } 28 | return keys.reduce((acc, key) => { 29 | acc[key] = this.data[key] 30 | return acc 31 | }, {}) 32 | }, 33 | set(key = "", value = "") { 34 | this.data[key] = value 35 | }, 36 | remove(key: string) { 37 | delete this.data[key] 38 | }, 39 | removeMany(keys: string[]) { 40 | keys.forEach((key) => { 41 | delete this.data[key] 42 | }) 43 | }, 44 | clear() { 45 | this.data = {} 46 | } 47 | } 48 | 49 | beforeEach(() => { 50 | mockStorage.clear() 51 | jest.fn().mockReset() 52 | }) 53 | 54 | export const createStorageMock = (): { 55 | mockStorage: typeof mockStorage 56 | addListener: jest.Mock 57 | removeListener: jest.Mock 58 | getTriggers: jest.Mock 59 | setTriggers: jest.Mock 60 | removeTriggers: jest.Mock 61 | } => { 62 | let onChangedCallback: StorageWatchEventListener 63 | 64 | const mockOutput = { 65 | mockStorage, 66 | addListener: jest.fn(), 67 | removeListener: jest.fn(), 68 | getTriggers: jest.fn(), 69 | setTriggers: jest.fn(), 70 | removeTriggers: jest.fn() 71 | } 72 | 73 | const storage: typeof chrome.storage = { 74 | //@ts-ignore 75 | onChanged: { 76 | addListener: mockOutput.addListener.mockImplementation( 77 | (d: StorageWatchEventListener) => { 78 | onChangedCallback = d 79 | } 80 | ), 81 | removeListener: mockOutput.removeListener 82 | }, 83 | sync: { 84 | // Needed because react hook tries to directly read the value 85 | //@ts-ignore 86 | get: mockOutput.getTriggers.mockImplementation((keys: any) => 87 | mockStorage.getMany(keys) 88 | ), 89 | //@ts-ignore 90 | set: mockOutput.setTriggers.mockImplementation( 91 | (changes: { [key: string]: any }) => { 92 | Object.entries(changes).forEach(([key, value]) => { 93 | mockStorage.set(key, value) 94 | 95 | onChangedCallback && 96 | onChangedCallback( 97 | { 98 | [key]: { 99 | oldValue: undefined, 100 | newValue: value 101 | } 102 | }, 103 | "sync" 104 | ) 105 | }) 106 | } 107 | ), 108 | //@ts-ignore 109 | remove: mockOutput.removeTriggers.mockImplementation((keys: string[]) => { 110 | mockStorage.removeMany(keys) 111 | 112 | onChangedCallback && keys.forEach((key) => 113 | onChangedCallback( 114 | { 115 | [key]: { 116 | oldValue: mockStorage.data[key], 117 | newValue: undefined 118 | } 119 | }, 120 | "sync" 121 | ) 122 | ) 123 | }) 124 | } 125 | } 126 | 127 | globalThis.chrome.storage = storage 128 | 129 | return mockOutput 130 | } 131 | 132 | describe("react hook", () => { 133 | test("stores basic text data ", async () => { 134 | const { setTriggers } = createStorageMock() 135 | 136 | const key = "test" 137 | const value = "hello world" 138 | 139 | const { result, unmount } = renderHook(() => useStorage(key)) 140 | 141 | await act(async () => { 142 | await result.current[1](value) 143 | }) 144 | 145 | expect(setTriggers).toHaveBeenCalledWith({ 146 | [key]: JSON.stringify(value) 147 | }) 148 | 149 | // await waitFor(() => expect(result.current[0]).toBe(value)) 150 | 151 | unmount() 152 | }) 153 | 154 | test("mutate with setter function ", async () => { 155 | const { setTriggers } = createStorageMock() 156 | 157 | const key = "test" 158 | 159 | const value = "hello" 160 | 161 | const setter = (prev: string) => prev + " world" 162 | 163 | const { result, unmount } = renderHook(() => 164 | useStorage( 165 | { 166 | key, 167 | instance: new Storage({ allCopied: true }) 168 | }, 169 | value 170 | ) 171 | ) 172 | 173 | await act(async () => { 174 | await result.current[1](setter) 175 | }) 176 | 177 | const newValue = setter(value) 178 | 179 | expect(setTriggers).toHaveBeenCalledWith({ 180 | [key]: JSON.stringify(newValue) 181 | }) 182 | 183 | // expect(result.current[0]).toBe(newValue) 184 | unmount() 185 | }) 186 | 187 | test("removes watch listener when unmounting", () => { 188 | const { addListener, removeListener } = createStorageMock() 189 | 190 | const { result, unmount } = renderHook(() => useStorage("stuff")) 191 | 192 | expect(addListener).toHaveBeenCalled() 193 | 194 | expect(result.current[0]).toBeUndefined() 195 | 196 | unmount() 197 | 198 | expect(removeListener).toHaveBeenCalled() 199 | }) 200 | 201 | test("is reactive to key changes", async () => { 202 | const { setTriggers, getTriggers } = createStorageMock() 203 | 204 | const key1 = "key1" 205 | const key2 = "key2" 206 | const initValue = "hello" 207 | const key1Value = "hello world" 208 | const key2Value = "hello world 2" 209 | 210 | const { result, rerender, unmount } = renderHook( 211 | ({ key }) => useStorage(key, initValue), 212 | { 213 | initialProps: { key: key1 } 214 | } 215 | ) 216 | 217 | // with initial key, set new value 218 | await act(async () => { 219 | await result.current[1](key1Value) 220 | }) 221 | expect(setTriggers).toHaveBeenCalledWith({ 222 | key1: JSON.stringify(key1Value) 223 | }) 224 | 225 | // re-render with new key, and ensure new key is looked up from storage and that we reset to initial value 226 | await act(async () => { 227 | rerender({ key: key2 }) 228 | }) 229 | expect(getTriggers).toHaveBeenCalledWith([key2]) 230 | await waitFor(() => expect(result.current[0]).toBe(initValue)) 231 | 232 | // set new key to new value 233 | await act(async () => { 234 | await result.current[1](key2Value) 235 | }) 236 | expect(setTriggers).toHaveBeenCalledWith({ 237 | key2: JSON.stringify(key2Value) 238 | }) 239 | await waitFor(() => expect(result.current[0]).toBe(key2Value)) 240 | 241 | // re-render with old key, and ensure old key's up-to-date value is fetched 242 | await act(async () => { 243 | rerender({ key: key1 }) 244 | }) 245 | await waitFor(() => expect(result.current[0]).toBe(key1Value)) 246 | 247 | unmount() 248 | }) 249 | }) 250 | 251 | describe("watch/unwatch", () => { 252 | test("attaches storage listener when watch listener is added", () => { 253 | const storageMock = createStorageMock() 254 | 255 | const storage = new Storage() 256 | storage.watch({ key: () => {} }) 257 | 258 | expect(storageMock.addListener).toHaveBeenCalled() 259 | expect(storageMock.removeListener).not.toHaveBeenCalled() 260 | }) 261 | 262 | test("removes storage listener when all watch listener is removed", () => { 263 | const storageMock = createStorageMock() 264 | 265 | const storage = new Storage() 266 | const watchConfig = { key: () => {} } 267 | storage.watch(watchConfig) 268 | storage.unwatch(watchConfig) 269 | 270 | expect(storageMock.addListener).toHaveBeenCalled() 271 | expect(storageMock.removeListener).toHaveBeenCalled() 272 | }) 273 | 274 | test("doesn't remove storage listener given wrong reference", () => { 275 | const storageMock = createStorageMock() 276 | 277 | const storage = new Storage() 278 | 279 | const watchConfig = { key: () => {} } 280 | storage.watch({ key: () => {} }) 281 | storage.unwatch(watchConfig) 282 | 283 | expect(storageMock.addListener).toHaveBeenCalled() 284 | expect(storageMock.removeListener).not.toHaveBeenCalled() 285 | }) 286 | 287 | test("should call watch listeners", () => { 288 | const storageMock = createStorageMock() 289 | 290 | const storage = new Storage() 291 | 292 | const watchFn1 = jest.fn() 293 | const watchFn2 = jest.fn() 294 | 295 | expect(storage.watch({ key: watchFn1 })).toBeTruthy() 296 | expect(storage.watch({ key: watchFn2 })).toBeTruthy() 297 | expect(storage.watch({ key2: watchFn2 })).toBeTruthy() 298 | 299 | expect(storageMock.addListener).toHaveBeenCalledTimes(2) 300 | }) 301 | 302 | test("doesn't call unwatched listeners", () => { 303 | const storageMock = createStorageMock() 304 | 305 | const storage = new Storage() 306 | 307 | const watchFn1 = jest.fn() 308 | const watchFn2 = jest.fn() 309 | 310 | expect(storage.watch({ key1: watchFn1 })).toBeTruthy() 311 | expect(storage.watch({ key2: watchFn2 })).toBeTruthy() 312 | expect(storage.unwatch({ key1: watchFn1 })).toBeTruthy() 313 | 314 | expect(storageMock.addListener).toHaveBeenCalled() 315 | expect(storageMock.removeListener).toHaveBeenCalled() 316 | }) 317 | }) 318 | 319 | // Create a new describe block for CRUD operations with namespace 320 | describe("Storage - Basic CRUD operations with namespace", () => { 321 | // Declare the storage and namespace variables 322 | let storage = new Storage() 323 | let storageMock: ReturnType 324 | const namespace = "testNamespace:" 325 | 326 | // Initialize storage and storageMock before each test case 327 | beforeEach(() => { 328 | storageMock = createStorageMock() 329 | storage = new Storage() 330 | storage.setNamespace(namespace) 331 | }) 332 | 333 | // Test set operation with namespace 334 | test("set operation", async () => { 335 | // Test data 336 | const testKey = "key" 337 | const testValue = "value" 338 | 339 | // Perform set operation 340 | await storage.set(testKey, testValue) 341 | 342 | // Check if storageMock.setTriggers is called with the correct parameters 343 | expect(storageMock.setTriggers).toHaveBeenCalledWith({ 344 | [`${namespace}${testKey}`]: JSON.stringify(testValue) 345 | }) 346 | }) 347 | 348 | test("setMany operation", async () => { 349 | const testData = { 350 | "key1": "value1", 351 | "key2": "value2" 352 | } 353 | 354 | await storage.setMany(testData) 355 | 356 | expect(storageMock.setTriggers).toHaveBeenCalledWith({ 357 | [`${namespace}key1`]: JSON.stringify("value1"), 358 | [`${namespace}key2`]: JSON.stringify("value2") 359 | }) 360 | }) 361 | 362 | // Test get operation with namespace 363 | test("get operation", async () => { 364 | // Test data 365 | const testKey = "key" 366 | const testValue = "value" 367 | 368 | // Perform set operation 369 | await storage.set(testKey, testValue) 370 | 371 | // Perform get operation 372 | const getValue = await storage.get(testKey) 373 | 374 | // Check if storageMock.getTriggers is called with the correct parameter 375 | expect(storageMock.getTriggers).toHaveBeenCalledWith( 376 | [`${namespace}${testKey}`] 377 | ) 378 | 379 | // Check if the returned value is correct 380 | expect(getValue).toEqual(testValue) 381 | }) 382 | 383 | test("getMany operation", async () => { 384 | const testData = { 385 | "key1": "value1", 386 | "key2": "value2" 387 | } 388 | 389 | await storage.setMany(testData) 390 | 391 | const getValue = await storage.getMany(Object.keys(testData)) 392 | 393 | expect(storageMock.getTriggers).toHaveBeenCalledWith( 394 | [`${namespace}key1`, `${namespace}key2`] 395 | ) 396 | 397 | expect(getValue).toMatchObject(testData) 398 | }) 399 | 400 | // Test getAll operation with namespace 401 | test("getAll operation", async () => { 402 | // Test data 403 | const testKey1 = "key1" 404 | const testValue1 = "value1" 405 | const testKey2 = "key2" 406 | const testValue2 = "value2" 407 | 408 | // Perform set operations for two keys 409 | await storage.set(testKey1, testValue1) 410 | await storage.set(testKey2, testValue2) 411 | 412 | // Perform getAll operation 413 | const allData = await storage.getAll() 414 | 415 | // Check if the returned object has the correct keys 416 | // and ensure the keys are without namespace 417 | expect(Object.keys(allData)).toEqual([testKey1, testKey2]) 418 | }) 419 | 420 | // Test remove operation with namespace 421 | test("remove operation", async () => { 422 | // Test data 423 | const testKey = "key" 424 | const testValue = "value" 425 | 426 | // Perform set operation 427 | await storage.set(testKey, testValue) 428 | 429 | // Perform remove operation 430 | await storage.remove(testKey) 431 | 432 | // Check if storageMock.removeListener is called with the correct parameter 433 | expect(storageMock.removeTriggers).toHaveBeenCalledWith( 434 | [`${namespace}${testKey}`] 435 | ) 436 | }) 437 | 438 | test("removeMany operation", async () => { 439 | const testData = { 440 | "key1": "value1", 441 | "key2": "value2" 442 | } 443 | 444 | await storage.setMany(testData) 445 | 446 | await storage.removeMany(Object.keys(testData)) 447 | 448 | expect(storageMock.removeTriggers).toHaveBeenCalledWith( 449 | [`${namespace}key1`, `${namespace}key2`] 450 | ) 451 | }) 452 | 453 | // Test removeAll operation with namespace 454 | test("removeAll operation", async () => { 455 | // Test data 456 | const testKey1 = "key1" 457 | const testValue1 = "value1" 458 | const testKey2 = "key2" 459 | const testValue2 = "value2" 460 | 461 | // Perform set operations for two keys 462 | await storage.set(testKey1, testValue1) 463 | await storage.set(testKey2, testValue2) 464 | 465 | // Perform removeAll operation 466 | await storage.removeAll() 467 | 468 | expect(storageMock.removeTriggers).toHaveBeenCalledWith( 469 | [`${namespace}${testKey1}`, `${namespace}${testKey2}`] 470 | ) 471 | }) 472 | }) 473 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 Plasmo Corp. (https://www.plasmo.com) and contributors 3 | * Licensed under the MIT license. 4 | * This module share storage between chrome storage and local storage. 5 | */ 6 | 7 | import pify from "pify" 8 | 9 | import { isChromeBelow100 } from "./utils" 10 | 11 | export type StorageWatchEventListener = Parameters< 12 | typeof chrome.storage.onChanged.addListener 13 | >[0] 14 | 15 | export type StorageAreaName = Parameters[1] 16 | export type StorageWatchCallback = ( 17 | change: chrome.storage.StorageChange, 18 | area: StorageAreaName 19 | ) => void 20 | 21 | export type StorageCallbackMap = Record 22 | 23 | export type StorageArea = chrome.storage.StorageArea 24 | 25 | export type InternalStorage = typeof chrome.storage 26 | 27 | export type SerdeOptions = { 28 | serializer: (value: T) => string 29 | deserializer: (rawValue: string) => T 30 | } 31 | 32 | export abstract class BaseStorage { 33 | #extStorageEngine: InternalStorage 34 | 35 | #primaryClient: StorageArea 36 | get primaryClient() { 37 | return this.#primaryClient 38 | } 39 | 40 | #secondaryClient: globalThis.Storage 41 | get secondaryClient() { 42 | return this.#secondaryClient 43 | } 44 | 45 | #area: StorageAreaName 46 | get area() { 47 | return this.#area 48 | } 49 | 50 | get hasWebApi() { 51 | try { 52 | return typeof window !== "undefined" && !!window.localStorage 53 | } catch (error) { 54 | console.error(error) 55 | return false 56 | } 57 | } 58 | 59 | #watchMap = new Map< 60 | string, 61 | { 62 | callbackSet: Set 63 | listener: StorageWatchEventListener 64 | } 65 | >() 66 | 67 | #copiedKeySet: Set 68 | get copiedKeySet() { 69 | return this.#copiedKeySet 70 | } 71 | 72 | /** 73 | * the key is copied to the webClient 74 | */ 75 | isCopied = (key: string) => 76 | this.hasWebApi && (this.allCopied || this.copiedKeySet.has(key)) 77 | 78 | #allCopied = false 79 | get allCopied() { 80 | return this.#allCopied 81 | } 82 | 83 | getExtStorageApi = () => { 84 | return globalThis.browser?.storage || globalThis.chrome?.storage 85 | } 86 | 87 | get hasExtensionApi() { 88 | try { 89 | return !!this.getExtStorageApi() 90 | } catch (error) { 91 | console.error(error) 92 | return false 93 | } 94 | } 95 | 96 | isWatchSupported = () => this.hasExtensionApi 97 | 98 | protected keyNamespace = "" 99 | isValidKey = (nsKey: string) => nsKey.startsWith(this.keyNamespace) 100 | getNamespacedKey = (key: string) => `${this.keyNamespace}${key}` 101 | getUnnamespacedKey = (nsKey: string) => nsKey.slice(this.keyNamespace.length) 102 | 103 | serde: SerdeOptions = { 104 | serializer: JSON.stringify, 105 | deserializer: JSON.parse 106 | } 107 | 108 | constructor({ 109 | area = "sync" as StorageAreaName, 110 | allCopied = false, 111 | copiedKeyList = [] as string[], 112 | serde = {} as SerdeOptions 113 | } = {}) { 114 | this.setCopiedKeySet(copiedKeyList) 115 | this.#area = area 116 | this.#allCopied = allCopied 117 | this.serde = { ...this.serde, ...serde } 118 | 119 | try { 120 | if (this.hasWebApi && (allCopied || copiedKeyList.length > 0)) { 121 | this.#secondaryClient = window.localStorage 122 | } 123 | } catch {} 124 | 125 | try { 126 | if (this.hasExtensionApi) { 127 | this.#extStorageEngine = this.getExtStorageApi() 128 | 129 | if (isChromeBelow100()) { 130 | this.#primaryClient = pify(this.#extStorageEngine[this.area], { 131 | exclude: ["getBytesInUse"], 132 | errorFirst: false 133 | }) 134 | } else { 135 | this.#primaryClient = this.#extStorageEngine[this.area] 136 | } 137 | } 138 | } catch {} 139 | } 140 | 141 | setCopiedKeySet(keyList: string[]) { 142 | this.#copiedKeySet = new Set(keyList) 143 | } 144 | 145 | rawGetAll = () => this.#primaryClient?.get() 146 | 147 | getAll = async () => { 148 | const allData = await this.rawGetAll() 149 | return Object.entries(allData) 150 | .filter(([key]) => this.isValidKey(key)) 151 | .reduce( 152 | (acc, [key, value]) => { 153 | acc[this.getUnnamespacedKey(key)] = value as string 154 | return acc 155 | }, 156 | {} as Record 157 | ) 158 | } 159 | 160 | /** 161 | * Copy the key/value between extension storage and web storage. 162 | * @param key if undefined, copy all keys between storages. 163 | * @returns false if the value is unchanged or it is a secret key. 164 | */ 165 | copy = async (key?: string) => { 166 | const syncAll = key === undefined 167 | if ( 168 | (!syncAll && !this.copiedKeySet.has(key)) || 169 | !this.allCopied || 170 | !this.hasExtensionApi 171 | ) { 172 | return false 173 | } 174 | 175 | const dataMap = this.allCopied 176 | ? await this.rawGetAll() 177 | : await this.#primaryClient.get( 178 | (syncAll ? [...this.copiedKeySet] : [key]).map(this.getNamespacedKey) 179 | ) 180 | 181 | if (!dataMap) { 182 | return false 183 | } 184 | 185 | let changed = false 186 | 187 | for (const pKey in dataMap) { 188 | const value = dataMap[pKey] as string 189 | const previousValue = this.#secondaryClient?.getItem(pKey) 190 | this.#secondaryClient?.setItem(pKey, value) 191 | changed ||= value !== previousValue 192 | } 193 | 194 | return changed 195 | } 196 | 197 | protected rawGet = async ( 198 | key: string 199 | ): Promise => { 200 | const results = await this.rawGetMany([key]) 201 | return results[key] 202 | } 203 | 204 | protected rawGetMany = async ( 205 | keys: string[] 206 | ): Promise> => { 207 | if (this.hasExtensionApi) { 208 | return await this.#primaryClient.get(keys) 209 | } 210 | 211 | return keys.filter(this.isCopied).reduce((dataMap, copiedKey) => { 212 | dataMap[copiedKey] = this.#secondaryClient?.getItem(copiedKey) 213 | return dataMap 214 | }, {}) 215 | } 216 | 217 | protected rawSet = async (key: string, value: string): Promise => { 218 | return await this.rawSetMany({ [key]: value }) 219 | } 220 | 221 | protected rawSetMany = async (items: Record): Promise => { 222 | if (this.#secondaryClient) { 223 | Object.entries(items) 224 | .filter(([key]) => this.isCopied(key)) 225 | .forEach(([key, value]) => this.#secondaryClient.setItem(key, value)) 226 | } 227 | 228 | if (this.hasExtensionApi) { 229 | await this.#primaryClient.set(items) 230 | } 231 | 232 | return null 233 | } 234 | 235 | /** 236 | * @param includeCopies Also cleanup copied data from secondary storage 237 | */ 238 | clear = async (includeCopies = false) => { 239 | if (includeCopies) { 240 | this.#secondaryClient?.clear() 241 | } 242 | 243 | await this.#primaryClient.clear() 244 | } 245 | 246 | protected rawRemove = async (key: string) => { 247 | await this.rawRemoveMany([key]) 248 | } 249 | 250 | protected rawRemoveMany = async (keys: string[]) => { 251 | if (this.#secondaryClient) { 252 | keys.filter(this.isCopied).forEach((key) => this.#secondaryClient.removeItem(key)) 253 | } 254 | 255 | if (this.hasExtensionApi) { 256 | await this.#primaryClient.remove(keys) 257 | } 258 | } 259 | 260 | removeAll = async () => { 261 | const allData = await this.getAll() 262 | const keyList = Object.keys(allData) 263 | await this.removeMany(keyList) 264 | } 265 | 266 | watch = (callbackMap: StorageCallbackMap) => { 267 | const canWatch = this.isWatchSupported() 268 | if (canWatch) { 269 | this.#addListener(callbackMap) 270 | } 271 | return canWatch 272 | } 273 | 274 | #addListener = (callbackMap: StorageCallbackMap) => { 275 | for (const cbKey in callbackMap) { 276 | const nsKey = this.getNamespacedKey(cbKey) 277 | const callbackSet = this.#watchMap.get(nsKey)?.callbackSet || new Set() 278 | callbackSet.add(callbackMap[cbKey]) 279 | 280 | if (callbackSet.size > 1) { 281 | continue 282 | } 283 | 284 | const chromeStorageListener = ( 285 | changes: { 286 | [key: string]: chrome.storage.StorageChange 287 | }, 288 | areaName: StorageAreaName 289 | ) => { 290 | if (areaName !== this.area || !changes[nsKey]) { 291 | return 292 | } 293 | 294 | const storageComms = this.#watchMap.get(nsKey) 295 | if (!storageComms) { 296 | throw new Error(`Storage comms does not exist for nsKey: ${nsKey}`) 297 | } 298 | 299 | Promise.all([ 300 | this.parseValue(changes[nsKey].newValue), 301 | this.parseValue(changes[nsKey].oldValue) 302 | ]).then(([newValue, oldValue]) => { 303 | for (const cb of storageComms.callbackSet) { 304 | cb({ newValue, oldValue }, areaName) 305 | } 306 | }) 307 | } 308 | 309 | this.#extStorageEngine.onChanged.addListener(chromeStorageListener) 310 | 311 | this.#watchMap.set(nsKey, { 312 | callbackSet, 313 | listener: chromeStorageListener 314 | }) 315 | } 316 | } 317 | 318 | unwatch = (callbackMap: StorageCallbackMap) => { 319 | const canWatch = this.isWatchSupported() 320 | if (canWatch) { 321 | this.#removeListener(callbackMap) 322 | } 323 | return canWatch 324 | } 325 | 326 | #removeListener(callbackMap: StorageCallbackMap) { 327 | for (const cbKey in callbackMap) { 328 | const nsKey = this.getNamespacedKey(cbKey) 329 | const callback = callbackMap[cbKey] 330 | const storageComms = this.#watchMap.get(nsKey) 331 | if (!storageComms) { 332 | continue 333 | } 334 | 335 | storageComms.callbackSet.delete(callback) 336 | if (storageComms.callbackSet.size === 0) { 337 | this.#watchMap.delete(nsKey) 338 | this.#extStorageEngine.onChanged.removeListener(storageComms.listener) 339 | } 340 | } 341 | } 342 | 343 | unwatchAll = () => this.#removeAllListener() 344 | 345 | #removeAllListener() { 346 | this.#watchMap.forEach(({ listener }) => 347 | this.#extStorageEngine.onChanged.removeListener(listener) 348 | ) 349 | 350 | this.#watchMap.clear() 351 | } 352 | 353 | /** 354 | * Get value from either local storage or chrome storage. 355 | */ 356 | abstract get: (key: string) => Promise 357 | abstract getMany: (keys: string[]) => Promise> 358 | 359 | /** 360 | * Set the value. If it is a secret, it will only be set in extension storage. 361 | * Returns a warning if storage capacity is almost full. 362 | * Throws error if the new item will make storage full 363 | */ 364 | abstract set: (key: string, rawValue: any) => Promise 365 | abstract setMany: (items: Record) => Promise 366 | 367 | abstract remove: (key: string) => Promise 368 | abstract removeMany: (keys: string[]) => Promise 369 | 370 | /** 371 | * Parse the value into its original form from storage raw value. 372 | */ 373 | protected abstract parseValue: (rawValue: any) => Promise 374 | 375 | /** 376 | * Alias for get 377 | */ 378 | async getItem(key: string) { 379 | return this.get(key) 380 | } 381 | 382 | async getItems(keys: string[]) { 383 | return await this.getMany(keys) 384 | } 385 | 386 | /** 387 | * Alias for set, but returns void instead 388 | */ 389 | async setItem(key: string, rawValue: any) { 390 | await this.set(key, rawValue) 391 | } 392 | 393 | async setItems(items: Record) { 394 | await await this.setMany(items) 395 | } 396 | 397 | /** 398 | * Alias for remove 399 | */ 400 | async removeItem(key: string) { 401 | return this.remove(key) 402 | } 403 | 404 | async removeItems(keys: string[]) { 405 | return await this.removeMany(keys) 406 | } 407 | } 408 | 409 | export type StorageOptions = ConstructorParameters[0] 410 | 411 | /** 412 | * https://docs.plasmo.com/framework/storage 413 | */ 414 | export class Storage extends BaseStorage { 415 | get = async (key: string) => { 416 | const nsKey = this.getNamespacedKey(key) 417 | const rawValue = await this.rawGet(nsKey) 418 | return this.parseValue(rawValue) 419 | } 420 | 421 | getMany = async (keys: string[]) => { 422 | const nsKeys = keys.map(this.getNamespacedKey) 423 | const rawValues = await this.rawGetMany(nsKeys) 424 | const parsedValues = await Promise.all( 425 | Object.values(rawValues).map(this.parseValue) 426 | ) 427 | return Object.keys(rawValues).reduce((results, key, i) => { 428 | results[this.getUnnamespacedKey(key)] = parsedValues[i] 429 | return results 430 | }, {} as Record) 431 | } 432 | 433 | set = async (key: string, rawValue: any) => { 434 | const nsKey = this.getNamespacedKey(key) 435 | const value = this.serde.serializer(rawValue) 436 | return this.rawSet(nsKey, value) 437 | } 438 | 439 | setMany = async (items: Record) => { 440 | const nsItems = Object.entries(items).reduce((nsItems, [key, value]) => { 441 | nsItems[this.getNamespacedKey(key)] = this.serde.serializer(value) 442 | return nsItems 443 | }, {}); 444 | return await this.rawSetMany(nsItems) 445 | } 446 | 447 | remove = async (key: string) => { 448 | const nsKey = this.getNamespacedKey(key) 449 | return this.rawRemove(nsKey) 450 | } 451 | 452 | removeMany = async (keys: string[]) => { 453 | const nsKeys = keys.map(this.getNamespacedKey) 454 | return await this.rawRemoveMany(nsKeys) 455 | } 456 | 457 | setNamespace = (namespace: string) => { 458 | this.keyNamespace = namespace 459 | } 460 | 461 | protected parseValue = async (rawValue: any): Promise => { 462 | try { 463 | if (rawValue !== undefined) { 464 | return this.serde.deserializer(rawValue) 465 | } 466 | } catch (e) { 467 | // ignore error. TODO: debug log them maybe 468 | console.error(e) 469 | } 470 | return undefined 471 | } 472 | } 473 | -------------------------------------------------------------------------------- /src/inspector.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from "./index" 2 | 3 | export const table = async ({ 4 | storage = new Storage(), 5 | printer = console.table 6 | }) => { 7 | const itemMap = await storage.getAll() 8 | printer(itemMap) 9 | } 10 | 11 | export const startChangeReporter = ({ 12 | storage = new Storage(), 13 | printer = console.table 14 | }) => { 15 | chrome.storage.onChanged.addListener((changes, area) => { 16 | console.log("Storage Changed:", changes) 17 | if (area === storage.area) { 18 | table({ storage, printer }) 19 | } 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/secure.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, jest, test } from "@jest/globals" 2 | 3 | import { createStorageMock } from "~index.test" 4 | 5 | import { SecureStorage } from "./secure" 6 | 7 | const createEncoderMock = () => { 8 | const mockFns = { 9 | encode: jest.fn(), 10 | decode: jest.fn() 11 | } 12 | 13 | // @ts-ignore 14 | globalThis.TextEncoder = jest.fn().mockImplementation(() => ({ 15 | encode: mockFns.encode 16 | })) 17 | 18 | // @ts-ignore 19 | globalThis.TextDecoder = jest.fn().mockImplementation(() => ({ 20 | decode: mockFns.decode 21 | })) 22 | 23 | mockFns.encode.mockImplementation((data: string) => { 24 | return new Uint8Array(data.split("").map((c) => c.charCodeAt(0))) 25 | }) 26 | 27 | mockFns.decode.mockImplementation((data: Uint8Array) => { 28 | return data.reduce((acc, c) => acc + String.fromCharCode(c), "") 29 | }) 30 | 31 | return mockFns 32 | } 33 | 34 | /** 35 | * This test case only covers interface of the function 36 | * and does not test the actual encryption/decryption. 37 | */ 38 | describe("SecureStorage - Basic CRUD", () => { 39 | let storageMock: ReturnType | undefined 40 | beforeEach(() => { 41 | storageMock?.mockStorage.clear() 42 | jest.clearAllMocks() 43 | createEncoderMock() 44 | }) 45 | 46 | test("should properly set and get data", async () => { 47 | const storageMock = createStorageMock() 48 | 49 | const secureStorage = new SecureStorage({ area: "sync" }) 50 | await secureStorage.setPassword("testPassword") 51 | 52 | await secureStorage.set("testKey", "mockData") 53 | 54 | expect(storageMock.setTriggers).toHaveBeenCalledTimes(1) 55 | 56 | const result = await secureStorage.get("testKey") 57 | 58 | expect(storageMock.getTriggers).toHaveBeenCalledTimes(1) 59 | 60 | // Assert that the decrypted data is returned 61 | expect(result).toEqual("mockData") 62 | }) 63 | 64 | test("should properly setMany and getMany data", async () => { 65 | const storageMock = createStorageMock() 66 | 67 | const secureStorage = new SecureStorage({ area: "sync" }) 68 | await secureStorage.setPassword("testPassword") 69 | 70 | await secureStorage.setMany({ 71 | testKey1: "mockData1", 72 | testKey2: "mockData2" 73 | }) 74 | 75 | expect(storageMock.setTriggers).toHaveBeenCalledTimes(1) 76 | 77 | const result = await secureStorage.getMany(["testKey1", "testKey2"]) 78 | 79 | expect(storageMock.getTriggers).toHaveBeenCalledTimes(1) 80 | 81 | // Assert that the decrypted data is returned 82 | expect(result).toEqual({ 83 | testKey1: "mockData1", 84 | testKey2: "mockData2" 85 | }) 86 | }); 87 | 88 | test("should properly remove data", async () => { 89 | const storageMock = createStorageMock() 90 | 91 | // Initialize SecureStorage instance and set the password 92 | const secureStorage = new SecureStorage({ area: "sync" }) 93 | await secureStorage.setPassword("testPassword") 94 | 95 | // Test the 'remove' method 96 | await secureStorage.remove("testKey") 97 | 98 | // Assert that the underlying storage layer is called with the correct arguments 99 | expect(storageMock.removeTriggers).toHaveBeenCalledTimes(1) 100 | 101 | // Empty implies that correct key is used behind the scenes 102 | // as the storage mock checks for the key before removing it 103 | expect(storageMock.mockStorage.data).toEqual({}) 104 | }) 105 | 106 | test("should properly removeMany data", async () => { 107 | const storageMock = createStorageMock() 108 | 109 | // Initialize SecureStorage instance and set the password 110 | const secureStorage = new SecureStorage({ area: "sync" }) 111 | await secureStorage.setPassword("testPassword") 112 | 113 | await secureStorage.setMany({ 114 | testKey1: "mockData1", 115 | testKey2: "mockData2" 116 | }) 117 | 118 | expect(storageMock.setTriggers).toHaveBeenCalledTimes(1) 119 | 120 | // Test the 'remove' method 121 | await secureStorage.removeMany(["testKey1", "testKey2"]) 122 | 123 | // Assert that the underlying storage layer is called with the correct arguments 124 | expect(storageMock.removeTriggers).toHaveBeenCalledTimes(1) 125 | 126 | // Empty implies that correct key is used behind the scenes 127 | // as the storage mock checks for the key before removing it 128 | expect(storageMock.mockStorage.data).toEqual({}) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /src/secure.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Resources: 3 | * 4 | * https://www.youtube.com/watch?v=lbt2_M1hZeg 5 | * 6 | */ 7 | 8 | import { BaseStorage } from "./index" 9 | 10 | const { crypto } = globalThis 11 | 12 | const u8ToHex = (a: ArrayBufferLike) => 13 | Array.from(new Uint8Array(a), (v) => v.toString(16).padStart(2, "0")).join("") 14 | 15 | const u8ToBase64 = (a: ArrayBufferLike) => 16 | globalThis.btoa(String.fromCharCode.apply(null, a)) 17 | 18 | const base64ToU8 = (base64: string) => 19 | Uint8Array.from(globalThis.atob(base64), (c) => c.charCodeAt(0)) 20 | 21 | const DEFAULT_ITERATIONS = 147_000 22 | const DEFAULT_SALT_SIZE = 16 23 | const DEFAULT_IV_SIZE = 32 24 | const DEFAULT_NS_SIZE = 8 25 | 26 | export const DEFAULT_NS_SEPARATOR = "|:|" 27 | 28 | /** 29 | * ALPHA API: This API is still in development and may change at any time. 30 | */ 31 | export class SecureStorage extends BaseStorage { 32 | #encoder = new TextEncoder() 33 | #decoder = new TextDecoder() 34 | 35 | #keyFx = "PBKDF2" 36 | #hashAlgo = "SHA-256" 37 | #cipherMode = "AES-GCM" 38 | #cipherSize = 256 39 | 40 | #iterations: number 41 | #saltSize: number 42 | #ivSize: number 43 | 44 | get #prefixSize() { 45 | return this.#saltSize + this.#ivSize 46 | } 47 | 48 | #passwordKey: CryptoKey 49 | private get passwordKey() { 50 | if (!this.#passwordKey) { 51 | throw new Error("Password not set, please first call setPassword.") 52 | } 53 | return this.#passwordKey 54 | } 55 | 56 | setPassword = async ( 57 | password: string, 58 | { 59 | iterations = DEFAULT_ITERATIONS, 60 | saltSize = DEFAULT_SALT_SIZE, 61 | ivSize = DEFAULT_IV_SIZE, 62 | namespace = "", 63 | nsSize = DEFAULT_NS_SIZE, 64 | nsSeparator = DEFAULT_NS_SEPARATOR 65 | } = {} 66 | ) => { 67 | this.#iterations = iterations 68 | this.#saltSize = saltSize 69 | this.#ivSize = ivSize 70 | 71 | const passwordBuffer = this.#encoder.encode(password) 72 | this.#passwordKey = await crypto.subtle.importKey( 73 | "raw", 74 | passwordBuffer, 75 | { name: this.#keyFx }, 76 | false, // Not exportable 77 | ["deriveKey"] 78 | ) 79 | 80 | if (!namespace) { 81 | const hashBuffer = await crypto.subtle.digest( 82 | this.#hashAlgo, 83 | passwordBuffer 84 | ) 85 | 86 | this.keyNamespace = `${u8ToHex(hashBuffer).slice(-nsSize)}${nsSeparator}` 87 | } else { 88 | this.keyNamespace = `${namespace}${nsSeparator}` 89 | } 90 | } 91 | 92 | migrate = async (newInstance: SecureStorage) => { 93 | const storageMap = await this.getAll() 94 | const baseKeyList = Object.keys(storageMap) 95 | .filter((k) => this.isValidKey(k)) 96 | .map((nsKey) => this.getUnnamespacedKey(nsKey)) 97 | 98 | await Promise.all( 99 | baseKeyList.map(async (key) => { 100 | const data = await this.get(key) 101 | await newInstance.set(key, data) 102 | }) 103 | ) 104 | 105 | return newInstance 106 | } 107 | 108 | /** 109 | * 110 | * @param boxBase64 A box contains salt, iv and encrypted data 111 | * @returns decrypted data 112 | */ 113 | decrypt = async (boxBase64: string) => { 114 | const passKey = this.passwordKey 115 | const boxBuffer = base64ToU8(boxBase64) 116 | 117 | const salt = boxBuffer.slice(0, this.#saltSize) 118 | const iv = boxBuffer.slice(this.#saltSize, this.#prefixSize) 119 | const encryptedDataBuffer = boxBuffer.slice(this.#prefixSize) 120 | const aesKey = await this.#deriveKey(salt, passKey, ["decrypt"]) 121 | 122 | const decryptedDataBuffer = await crypto.subtle.decrypt( 123 | { 124 | name: this.#cipherMode, 125 | iv 126 | }, 127 | aesKey, 128 | encryptedDataBuffer 129 | ) 130 | return this.#decoder.decode(decryptedDataBuffer) 131 | } 132 | 133 | encrypt = async (rawData: string) => { 134 | const passKey = this.passwordKey 135 | const salt = crypto.getRandomValues(new Uint8Array(this.#saltSize)) 136 | const iv = crypto.getRandomValues(new Uint8Array(this.#ivSize)) 137 | const aesKey = await this.#deriveKey(salt, passKey, ["encrypt"]) 138 | 139 | const encryptedDataBuffer = new Uint8Array( 140 | await crypto.subtle.encrypt( 141 | { 142 | name: this.#cipherMode, 143 | iv 144 | }, 145 | aesKey, 146 | this.#encoder.encode(rawData) 147 | ) 148 | ) 149 | 150 | const boxBuffer = new Uint8Array( 151 | this.#prefixSize + encryptedDataBuffer.byteLength 152 | ) 153 | 154 | boxBuffer.set(salt, 0) 155 | boxBuffer.set(iv, this.#saltSize) 156 | boxBuffer.set(encryptedDataBuffer, this.#prefixSize) 157 | 158 | const boxBase64 = u8ToBase64(boxBuffer) 159 | return boxBase64 160 | } 161 | 162 | get = async (key: string) => { 163 | const nsKey = this.getNamespacedKey(key) 164 | const boxBase64 = await this.rawGet(nsKey) 165 | return this.parseValue(boxBase64) 166 | } 167 | 168 | getMany = async (keys: string[]) => { 169 | const nsKeys = keys.map(this.getNamespacedKey) 170 | const rawValues = await this.rawGetMany(nsKeys) 171 | const parsedValues = await Promise.all( 172 | Object.values(rawValues).map(this.parseValue) 173 | ) 174 | return Object.keys(rawValues).reduce((results, key, i) => { 175 | results[this.getUnnamespacedKey(key)] = parsedValues[i] 176 | return results 177 | }, {} as Record) 178 | } 179 | 180 | set = async (key: string, rawValue: any) => { 181 | const nsKey = this.getNamespacedKey(key) 182 | const value = this.serde.serializer(rawValue) 183 | const boxBase64 = await this.encrypt(value) 184 | return await this.rawSet(nsKey, boxBase64) 185 | } 186 | 187 | setMany = async (items: Record) => { 188 | const encryptedValues = await Promise.all( 189 | Object.values(items).map((rawValue) => this.encrypt( 190 | this.serde.serializer(rawValue) 191 | )) 192 | ) 193 | 194 | const nsItems = Object.keys(items).reduce((nsItems, key, i) => { 195 | nsItems[this.getNamespacedKey(key)] = encryptedValues[i] 196 | return nsItems 197 | }, {}) 198 | 199 | return await this.rawSetMany(nsItems) 200 | } 201 | 202 | remove = async (key: string) => { 203 | const nsKey = this.getNamespacedKey(key) 204 | return await this.rawRemove(nsKey) 205 | } 206 | 207 | removeMany = async (keys: string[]) => { 208 | const nsKeys = keys.map(this.getNamespacedKey) 209 | return await this.rawRemoveMany(nsKeys) 210 | } 211 | 212 | protected parseValue = async (boxBase64: string | null | undefined) => { 213 | if (boxBase64 !== undefined && boxBase64 !== null) { 214 | const rawValue = await this.decrypt(boxBase64) 215 | return this.serde.deserializer(rawValue) 216 | } 217 | return undefined 218 | } 219 | 220 | #deriveKey = ( 221 | salt: Uint8Array, 222 | passwordKey: CryptoKey, 223 | keyUsage: KeyUsage[] 224 | ) => 225 | crypto.subtle.deriveKey( 226 | { 227 | name: this.#keyFx, 228 | hash: this.#hashAlgo, 229 | salt, 230 | iterations: this.#iterations 231 | }, 232 | passwordKey, 233 | { 234 | name: this.#cipherMode, 235 | length: this.#cipherSize 236 | }, 237 | false, 238 | keyUsage 239 | ) 240 | } 241 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const isChromeBelow100 = () => { 2 | try { 3 | const ua = globalThis.navigator?.userAgent 4 | 5 | const browserMatch = 6 | ua.match( 7 | /(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i 8 | ) || [] 9 | 10 | if (browserMatch[1] === "Chrome") { 11 | return ( 12 | parseInt(browserMatch[2]) < 100 || 13 | globalThis.chrome.runtime?.getManifest()?.manifest_version === 2 14 | ) 15 | } 16 | } catch { 17 | return false 18 | } 19 | 20 | return false 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "baseUrl": ".", 5 | "paths": { 6 | "~*": ["./src/*"] 7 | }, 8 | "resolveJsonModule": true, 9 | "sourceMap": true, 10 | "strict": false, 11 | "declaration": true, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "esModuleInterop": true, 16 | "noImplicitAny": false, 17 | "moduleResolution": "node", 18 | "module": "ESNext", 19 | "target": "ESNext", 20 | "allowJs": true, 21 | "verbatimModuleSyntax": true, 22 | "strictNullChecks": true 23 | }, 24 | "include": ["src/**/*.ts"] 25 | } 26 | --------------------------------------------------------------------------------