├── .prettierignore ├── .eslintignore ├── src ├── __mocks__ │ └── index.ts ├── bucket │ ├── package.json │ └── index.ts ├── jest │ ├── package.json │ ├── jest-mock.ts │ └── jest-mock.test.ts ├── guards.ts ├── index.ts ├── validate.ts └── types.ts ├── .gitignore ├── tests ├── jest.setup.test.ts ├── bucket │ ├── get-error.test.ts │ ├── clear.test.ts │ ├── set-empty.test.ts │ ├── setup.ts │ ├── remove.test.ts │ ├── get-empty.test.ts │ ├── get.test.ts │ ├── set-error.test.ts │ └── set.test.ts └── jest.setup.ts ├── tsconfig.tests.json ├── .prettierrc.yaml ├── tsconfig.d.json ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── jest.config.js ├── tsconfig.json ├── CHANGELOG.md ├── LICENSE ├── rollup.config.js ├── package.json ├── .eslintrc.js └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | # playground/ -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /docs 4 | /e2e 5 | /types 6 | /jest 7 | /hooks -------------------------------------------------------------------------------- /src/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../jest/jest-mock' 2 | // module.exports = require('../jest-mock') -------------------------------------------------------------------------------- /src/bucket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./index-cjs.js", 3 | "module": "./index-esm.js", 4 | "types": "../types/bucket" 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | lib/ 3 | hooks/ 4 | jest/ 5 | types/ 6 | node_modules/ 7 | playground/ 8 | TODO 9 | package-lock.json 10 | bucket -------------------------------------------------------------------------------- /src/jest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./jest-mock-cjs.js", 3 | "module": "./jest-mock-esm.js", 4 | "types": "../types/jest/jest-mock.d.ts" 5 | } -------------------------------------------------------------------------------- /tests/jest.setup.test.ts: -------------------------------------------------------------------------------- 1 | test('chrome is mocked', () => { 2 | expect(chrome).toBeDefined() 3 | expect(window.chrome).toBeDefined() 4 | }) 5 | -------------------------------------------------------------------------------- /tsconfig.tests.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noUnusedLocals": false 5 | }, 6 | "exclude": ["lib"] 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | semi: false 2 | tabWidth: 2 3 | trailingComma: 'all' 4 | jsxSingleQuote: true 5 | printWidth: 65 6 | singleQuote: true 7 | arrowParens: 'always' 8 | proseWrap: 'always' 9 | -------------------------------------------------------------------------------- /src/guards.ts: -------------------------------------------------------------------------------- 1 | export function isKeyof(x: any): x is keyof T { 2 | return typeof x === 'string' 3 | } 4 | 5 | export function isNonNull(value: T): value is NonNullable { 6 | return value != null 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.d.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationDir": "types", 6 | "emitDeclarationOnly": true 7 | }, 8 | "include": ["src"], 9 | "exclude": ["**/__mocks__", "**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.codeAction.showDocumentation": { 3 | "enable": true 4 | }, 5 | "eslint.enable": true, 6 | // "eslint.packageManager": "pnpm", 7 | "eslint.validate": ["javascript", "typescript"], 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "jestrunner.jestPath": "${workspaceFolder}/node_modules/jest/bin/jest.js" 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'jsdom', 6 | setupFilesAfterEnv: ['./tests/jest.setup.ts'], 7 | transform: { 8 | '.(js|jsx)': '@sucrase/jest-plugin', 9 | }, 10 | testPathIgnorePatterns: [ 11 | '/tests/e2e/', 12 | '/tests/v1/', 13 | '/node_modules/', 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "start", 9 | "group": { 10 | "kind": "build", 11 | "isDefault": true 12 | }, 13 | "problemMatcher": [], 14 | "label": "npm: start", 15 | "detail": "run-p start:rollup" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "module": "es2015", 6 | "moduleResolution": "node", 7 | "noImplicitReturns": true, 8 | "noUnusedLocals": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "target": "es2017", 12 | "lib": ["es2017", "dom", "esnext.array"], 13 | "skipLibCheck": true, 14 | }, 15 | "exclude": ["lib", "playground"] 16 | } 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getBucket } from './bucket' 2 | 3 | /** 4 | * Buckets for each storage area. 5 | */ 6 | export const storage = { 7 | local: getBucket>('local', 'local'), 8 | sync: getBucket>('sync', 'sync'), 9 | managed: getBucket>('managed', 'managed'), 10 | } 11 | 12 | // Workaround for @rollup/plugin-typescript 13 | export * from './types' 14 | export { getBucket } 15 | 16 | /** 17 | * Deprecated. Use `getBucket`. 18 | */ 19 | export const useBucket = ( 20 | areaName: 'local' | 'sync' | 'managed', 21 | bucketName: string, 22 | ) => getBucket(bucketName, areaName) 23 | -------------------------------------------------------------------------------- /tests/bucket/get-error.test.ts: -------------------------------------------------------------------------------- 1 | import cases from 'jest-in-case' 2 | import { getBucket } from '../../src/bucket' 3 | import { Bucket, bucketName } from './setup' 4 | 5 | beforeEach(jest.clearAllMocks) 6 | 7 | cases<{ getter: any }>( 8 | 'throws with wrong getter types', 9 | ({ getter }) => { 10 | const bucket = getBucket(bucketName) 11 | 12 | const shouldThrow = () => bucket.get(getter) 13 | 14 | expect(shouldThrow).toThrow( 15 | new TypeError( 16 | `Unexpected argument type: ${typeof getter}`, 17 | ), 18 | ) 19 | }, 20 | { 21 | boolean: { 22 | getter: true, 23 | }, 24 | number: { 25 | getter: 123, 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /tests/bucket/clear.test.ts: -------------------------------------------------------------------------------- 1 | import { getBucket } from '../../src/bucket' 2 | import { 3 | clear, 4 | get, 5 | keysName, 6 | remove, 7 | set, 8 | x, 9 | y, 10 | bucketName, 11 | Bucket, 12 | } from './setup' 13 | 14 | beforeEach(() => { 15 | jest.clearAllMocks() 16 | }) 17 | 18 | test('clear', async () => { 19 | const bucket = getBucket(bucketName) 20 | 21 | await bucket.clear() 22 | 23 | expect(set).not.toBeCalled() 24 | expect(clear).not.toBeCalled() 25 | 26 | expect(get).toBeCalledTimes(1) 27 | expect(get).toBeCalledWith(keysName, expect.any(Function)) 28 | 29 | expect(remove).toBeCalledTimes(1) 30 | expect(remove).toBeCalledWith( 31 | [keysName, x, y], 32 | expect.any(Function), 33 | ) 34 | }) 35 | -------------------------------------------------------------------------------- /tests/jest.setup.ts: -------------------------------------------------------------------------------- 1 | const makeStorageArea = () => ({ 2 | clear: jest.fn((cb: Function) => { 3 | cb() 4 | }), 5 | get: jest.fn((getter: any, cb: Function) => { 6 | cb() 7 | }), 8 | getBytesInUse: jest.fn((cb: Function) => { 9 | cb() 10 | }), 11 | remove: jest.fn((keys: any, cb: Function) => { 12 | cb() 13 | }), 14 | set: jest.fn((setter: any, cb: Function) => { 15 | cb() 16 | }), 17 | }) 18 | 19 | Object.assign(global, { 20 | chrome: { 21 | storage: { 22 | local: makeStorageArea(), 23 | sync: makeStorageArea(), 24 | managed: makeStorageArea(), 25 | }, 26 | runtime: {}, 27 | }, 28 | }) 29 | 30 | // Jest's jsdom does not include window.crypto 31 | const nodeCrypto = require('crypto') 32 | Object.assign(global, { 33 | crypto: { 34 | getRandomValues: function(buffer: Uint8Array) { 35 | return nodeCrypto.randomFillSync(buffer) 36 | }, 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Removed 6 | 7 | - `useBucket`: This export will be removed in version 1.0.0. 8 | 9 | ## [0.5.1] - 2020-01-27 10 | 11 | ### Fixed 12 | 13 | - Fixed `getBucket` types. 14 | 15 | ### Removed 16 | 17 | - Removed `getBucket` deprecation warning log. 18 | 19 | ## [0.5.0] - 2020-01-15 20 | 21 | ### Added 22 | 23 | - `getBucket`: The arguments are reversed from `useBucket`. The 24 | bucket name is the first argument, and the native storage area 25 | name is the second optional argument. If no second argument is 26 | provided, the default is "local", the local Chrome API storage. 27 | 28 | ### Changed 29 | 30 | - Updated `README.md` with Features and API sections. 31 | - Tests were converted to TypeScript. 32 | - Various bugfixes during test conversion. 33 | 34 | ### Deprecated 35 | 36 | - `useBucket`: This name is misleading by implying that it is a 37 | React hook, which is not true. `useBucket` will be removed in 38 | version 1.0.0. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 jacksteamdev 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import typescript from '@rollup/plugin-typescript' 4 | 5 | const { dependencies: deps } = require('./package.json') 6 | 7 | const external = [...Object.keys(deps), 'rxjs/operators'] 8 | 9 | const plugins = [ 10 | typescript({ 11 | noEmitOnError: false, 12 | }), 13 | ] 14 | 15 | export default [ 16 | { 17 | input: 'src/index.ts', 18 | output: outputs('lib/index'), 19 | external, 20 | plugins, 21 | }, 22 | { 23 | input: 'src/jest/jest-mock.ts', 24 | output: outputs('jest/jest-mock'), 25 | external, 26 | plugins, 27 | }, 28 | { 29 | input: 'src/bucket/index.ts', 30 | output: outputs('bucket/index'), 31 | external, 32 | plugins, 33 | }, 34 | // { 35 | // input: 'src/react-hooks.ts', 36 | // output: outputs('hooks/react-hooks'), 37 | // external, 38 | // plugins, 39 | // }, 40 | ] 41 | 42 | function outputs(filepathStem) { 43 | return [ 44 | { 45 | file: filepathStem + '-esm.js', 46 | format: 'esm', 47 | sourcemap: true, 48 | }, 49 | { 50 | file: filepathStem + '-cjs.js', 51 | format: 'cjs', 52 | sourcemap: true, 53 | }, 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | // export const invalidGetter = (g: any): string | void => { 2 | // switch (typeof g) { 3 | // case 'undefined': 4 | // case 'string': 5 | // case 'function': 6 | // return 7 | // case 'object': { 8 | // if (Array.isArray(g)) { 9 | // const x = g.find((x) => typeof x !== 'string') 10 | 11 | // if (x) { 12 | // return `Unexpected argument type: Array<${typeof x}>` 13 | // } 14 | // } 15 | 16 | // return 17 | // } 18 | // default: 19 | // return `Unexpected argument type: ${typeof g}` 20 | // } 21 | // } 22 | 23 | // export const invalidSetter = (s: any): string | void => { 24 | // if (Array.isArray(s)) { 25 | // return 'Unexpected argument type: Array' 26 | // } else if (s) { 27 | // switch (typeof s) { 28 | // case 'function': 29 | // case 'object': 30 | // return 31 | // default: 32 | // return `Unexpected argument type: ${typeof s}` 33 | // } 34 | // } 35 | // } 36 | 37 | export const invalidSetterReturn = (r: any): string | void => { 38 | if (Array.isArray(r)) { 39 | return 'Unexpected setter result value: Array' 40 | } else { 41 | switch (typeof r) { 42 | case 'object': 43 | case 'undefined': 44 | return 45 | default: 46 | return `Unexpected setter return value: ${typeof r}` 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/bucket/set-empty.test.ts: -------------------------------------------------------------------------------- 1 | import cases from 'jest-in-case' 2 | import { getBucket } from '../../src/bucket' 3 | import { 4 | anyFn, 5 | Bucket, 6 | bucketName, 7 | clear, 8 | get, 9 | keysName, 10 | remove, 11 | set, 12 | z, 13 | } from './setup' 14 | 15 | beforeEach(() => { 16 | jest.clearAllMocks() 17 | get.mockImplementation((getter, cb) => { 18 | cb({}) 19 | }) 20 | }) 21 | 22 | cases<{ 23 | setter: any 24 | setterFn?: any 25 | rawSetter: any 26 | }>( 27 | 'each setter type', 28 | async ({ setter, setterFn, rawSetter }) => { 29 | const bucket = getBucket(bucketName) 30 | const result = await bucket.set(setterFn || setter) 31 | 32 | expect(remove).not.toBeCalled() 33 | expect(clear).not.toBeCalled() 34 | 35 | expect(get).toBeCalledTimes(1) 36 | expect(get).toBeCalledWith(keysName, anyFn) 37 | 38 | expect(set).toBeCalledTimes(1) 39 | expect(set).toBeCalledWith(rawSetter, anyFn) 40 | 41 | expect(result).toEqual(setter) 42 | }, 43 | { 44 | object: { 45 | setter: { z: '789' }, 46 | rawSetter: { 47 | [z]: '789', 48 | [keysName]: ['z'], 49 | }, 50 | }, 51 | function: { 52 | setter: { z: '789' }, 53 | setterFn: jest.fn(() => ({ z: '789' })), 54 | rawSetter: { 55 | [z]: '789', 56 | [keysName]: ['z'], 57 | }, 58 | }, 59 | }, 60 | ) 61 | -------------------------------------------------------------------------------- /tests/bucket/setup.ts: -------------------------------------------------------------------------------- 1 | export const bucketName = 'bucket1' 2 | export const prefix = `extend-chrome/storage__${bucketName}` 3 | export const keysName = `${prefix}_keys` 4 | 5 | export const pfx = (k: string) => `${prefix}--${k}` 6 | export const unpfx = (pk: string) => 7 | pk.replace(`${prefix}--`, '') 8 | 9 | export const xfmKeys = (xfm: (x: string) => string) => (obj: { 10 | [key: string]: any 11 | }): { 12 | [key: string]: any 13 | } => { 14 | return Object.keys(obj).reduce( 15 | (r, k) => ({ 16 | ...r, 17 | [xfm(k)]: obj[k], 18 | }), 19 | {}, 20 | ) 21 | } 22 | 23 | export const unpfxObj = xfmKeys(unpfx) 24 | 25 | export interface Bucket { 26 | x: string 27 | y: string 28 | z: string 29 | a: string 30 | b: string 31 | } 32 | 33 | export const x = pfx('x') 34 | export const y = pfx('y') 35 | export const z = pfx('z') 36 | export const a = pfx('a') 37 | export const b = pfx('b') 38 | 39 | export const keys = { 40 | [keysName]: ['x', 'y'], 41 | } 42 | 43 | export const values = { 44 | [x]: '123', 45 | [y]: '456', 46 | } 47 | 48 | // eslint-disable-next-line 49 | // @ts-ignore 50 | export const { get, set, remove, clear } = chrome.storage 51 | .local as { 52 | clear: jest.Mock 53 | get: jest.Mock 54 | getBytesInUse: jest.Mock 55 | remove: jest.Mock 56 | set: jest.Mock 57 | } 58 | 59 | get.mockImplementation((getter, cb) => { 60 | if (getter === keysName) { 61 | cb(keys) 62 | } else { 63 | cb(values) 64 | } 65 | }) 66 | 67 | export const anyFn = expect.any(Function) 68 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "name": "jest-all", 10 | "request": "launch", 11 | "args": ["--runInBand"], 12 | "cwd": "${workspaceFolder}", 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 16 | }, 17 | { 18 | "type": "node", 19 | "name": "jest-set", 20 | "request": "launch", 21 | "args": ["set", "--runInBand"], 22 | "cwd": "${workspaceFolder}", 23 | "console": "integratedTerminal", 24 | "internalConsoleOptions": "neverOpen", 25 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 26 | }, 27 | { 28 | "type": "node", 29 | "name": "jest-get", 30 | "request": "launch", 31 | "args": ["get", "--runInBand"], 32 | "cwd": "${workspaceFolder}", 33 | "console": "integratedTerminal", 34 | "internalConsoleOptions": "neverOpen", 35 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 36 | }, 37 | { 38 | "type": "node", 39 | "name": "jest-remove", 40 | "request": "launch", 41 | "args": ["remove", "--runInBand"], 42 | "cwd": "${workspaceFolder}", 43 | "console": "integratedTerminal", 44 | "internalConsoleOptions": "neverOpen", 45 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /src/jest/jest-mock.ts: -------------------------------------------------------------------------------- 1 | import { Bucket, Changes } from '..' 2 | 3 | /* eslint-disable @typescript-eslint/no-unused-vars */ 4 | import { Subject } from 'rxjs' 5 | 6 | /** 7 | * This module is a pre-mocked version of storage for use with Jest. 8 | * 9 | * The event streams are RxJs Subjects 10 | * 11 | * ```javascript 12 | * // __mocks__/storage.js 13 | * module.exports = require('@extend-chrome/storage/jest') 14 | * ``` 15 | * 16 | * ```typescript 17 | * // __mocks__/storage.ts 18 | * export * from '@extend-chrome/storage/jest' 19 | * ``` 20 | */ 21 | 22 | export interface MockBucket extends Bucket { 23 | get: jest.MockedFunction['get']> 24 | set: jest.MockedFunction['set']> 25 | update: jest.MockedFunction['update']> 26 | remove: jest.MockedFunction['remove']> 27 | clear: jest.MockedFunction['clear']> 28 | changeStream: Subject> 29 | valueStream: Subject 30 | } 31 | 32 | export const getBucket = ( 33 | bucketName: string, 34 | areaName?: string, 35 | ): MockBucket => ({ 36 | get: jest.fn(), 37 | set: jest.fn(), 38 | update: jest.fn(), 39 | remove: jest.fn(), 40 | clear: jest.fn(), 41 | getKeys: jest.fn(), 42 | changeStream: new Subject>(), 43 | valueStream: new Subject(), 44 | }) 45 | 46 | export const useBucket = ( 47 | areaName: string, 48 | bucketName: string, 49 | ) => getBucket(bucketName, areaName) 50 | 51 | /** 52 | * Buckets for each storage area. 53 | */ 54 | export const storage = { 55 | local: getBucket>('local', 'local'), 56 | sync: getBucket>('sync', 'sync'), 57 | managed: getBucket>('managed', 'managed'), 58 | } 59 | -------------------------------------------------------------------------------- /tests/bucket/remove.test.ts: -------------------------------------------------------------------------------- 1 | import cases from 'jest-in-case' 2 | import { getBucket } from '../../src/bucket' 3 | import { 4 | anyFn, 5 | Bucket, 6 | bucketName, 7 | clear, 8 | get, 9 | keysName, 10 | remove, 11 | set, 12 | x, 13 | z, 14 | y, 15 | } from './setup' 16 | 17 | beforeEach(jest.clearAllMocks) 18 | 19 | cases<{ 20 | remover: any 21 | rawRemover: any 22 | newKeys: (keyof Bucket)[] 23 | }>( 24 | 'each remover type', 25 | async ({ remover, rawRemover, newKeys }) => { 26 | const bucket = getBucket(bucketName) 27 | 28 | await bucket.remove(remover) 29 | 30 | expect(remove).toBeCalledTimes(1) 31 | expect(remove).toBeCalledWith(rawRemover, anyFn) 32 | 33 | expect(get).toBeCalledTimes(1) 34 | expect(get).toBeCalledWith(keysName, anyFn) 35 | 36 | expect(set).toBeCalledTimes(1) 37 | expect(set).toBeCalledWith( 38 | { 39 | [keysName]: newKeys, 40 | }, 41 | anyFn, 42 | ) 43 | 44 | expect(clear).not.toBeCalled() 45 | }, 46 | { 47 | string: { 48 | remover: 'x', 49 | rawRemover: [x], 50 | newKeys: ['y'], 51 | }, 52 | array: { 53 | remover: ['y', 'z'], 54 | rawRemover: [y, z], 55 | newKeys: ['x'], 56 | }, 57 | }, 58 | ) 59 | 60 | cases<{ remover: any; type: any }>( 61 | 'each invalid remover type', 62 | async ({ remover, type }) => { 63 | const bucket = getBucket(bucketName) 64 | 65 | expect(() => bucket.remove(remover)).toThrow( 66 | new TypeError(`Unexpected argument type: ${type}`), 67 | ) 68 | }, 69 | { 70 | number: { remover: 123, type: 'number' }, 71 | boolean: { remover: true, type: 'boolean' }, 72 | mixedArray: { remover: ['a', 1], type: 'number' }, 73 | }, 74 | ) 75 | -------------------------------------------------------------------------------- /tests/bucket/get-empty.test.ts: -------------------------------------------------------------------------------- 1 | import cases from 'jest-in-case' 2 | import { getBucket } from '../../src/bucket' 3 | import { 4 | anyFn, 5 | Bucket, 6 | bucketName, 7 | clear, 8 | get, 9 | keysName, 10 | remove, 11 | set, 12 | x, 13 | z, 14 | } from './setup' 15 | 16 | beforeEach(jest.clearAllMocks) 17 | 18 | cases<{ 19 | getter: any 20 | rawGetter: any 21 | expected: any 22 | }>( 23 | 'each getter type with empty storage', 24 | async ({ getter, rawGetter, expected }) => { 25 | get.mockImplementation((getter, cb) => { 26 | if (typeof getter === 'object' && !Array.isArray(getter)) { 27 | cb(getter) 28 | } else { 29 | cb({}) 30 | } 31 | }) 32 | 33 | const bucket = getBucket(bucketName) 34 | const result = await bucket.get(getter) 35 | 36 | expect(result).toEqual(expected) 37 | 38 | expect(set).not.toBeCalled() 39 | expect(remove).not.toBeCalled() 40 | expect(clear).not.toBeCalled() 41 | 42 | expect(get).toBeCalledTimes(1) 43 | expect(get).toBeCalledWith(rawGetter, anyFn) 44 | }, 45 | { 46 | string: { 47 | getter: 'x', 48 | rawGetter: x, 49 | expected: {}, 50 | }, 51 | object: { 52 | getter: { x: 'abc', z: '789' }, 53 | rawGetter: { [x]: 'abc', [z]: '789' }, 54 | expected: { x: 'abc', z: '789' }, 55 | }, 56 | array: { 57 | getter: ['x', 'z'], 58 | rawGetter: [x, z], 59 | expected: {}, 60 | }, 61 | function: { 62 | getter: ({ x }: Bucket) => typeof x, 63 | rawGetter: keysName, 64 | expected: 'undefined', 65 | }, 66 | undefined: { 67 | getter: undefined, 68 | rawGetter: keysName, 69 | expected: {}, 70 | }, 71 | null: { 72 | getter: null, 73 | rawGetter: keysName, 74 | expected: {}, 75 | }, 76 | }, 77 | ) 78 | -------------------------------------------------------------------------------- /src/jest/jest-mock.test.ts: -------------------------------------------------------------------------------- 1 | import { getBucket, storage } from './jest-mock' 2 | 3 | import { MockBucket } from './jest-mock' 4 | import { Subject } from 'rxjs' 5 | 6 | jest.mock('../index.ts') 7 | 8 | const MockInstance = jest.fn().constructor 9 | 10 | test('storage.local is mocked', async () => { 11 | type AreaType = Record 12 | type MockArea = MockBucket 13 | const mockStorageArea = storage.local as MockArea 14 | 15 | expect(mockStorageArea).toMatchObject({ 16 | get: expect.any(MockInstance), 17 | set: expect.any(MockInstance), 18 | update: expect.any(MockInstance), 19 | remove: expect.any(MockInstance), 20 | clear: expect.any(MockInstance), 21 | getKeys: expect.any(MockInstance), 22 | changeStream: expect.any(Subject), 23 | valueStream: expect.any(Subject), 24 | }) 25 | 26 | const defaultValue = { a: 'a', b: 1 } 27 | 28 | mockStorageArea.get.mockImplementation(async () => { 29 | return defaultValue 30 | }) 31 | 32 | expect(await storage.local.get()).toEqual(defaultValue) 33 | }) 34 | 35 | test('getBucket returns MockBucket', async () => { 36 | type AreaType = Record 37 | type MockArea = MockBucket 38 | const storageArea = getBucket('test') 39 | const mockStorageArea = storageArea as MockArea 40 | 41 | expect(mockStorageArea).toMatchObject({ 42 | get: expect.any(MockInstance), 43 | set: expect.any(MockInstance), 44 | update: expect.any(MockInstance), 45 | remove: expect.any(MockInstance), 46 | clear: expect.any(MockInstance), 47 | getKeys: expect.any(MockInstance), 48 | changeStream: expect.any(Subject), 49 | valueStream: expect.any(Subject), 50 | }) 51 | 52 | const defaultValue = { a: 'a', b: 1 } 53 | 54 | mockStorageArea.get.mockImplementation(async () => { 55 | return defaultValue 56 | }) 57 | 58 | expect(await storageArea.get()).toEqual(defaultValue) 59 | }) 60 | -------------------------------------------------------------------------------- /tests/bucket/get.test.ts: -------------------------------------------------------------------------------- 1 | import cases from 'jest-in-case' 2 | import { getBucket } from '../../src/bucket' 3 | import { 4 | anyFn, 5 | Bucket, 6 | bucketName, 7 | clear, 8 | get, 9 | keys, 10 | keysName, 11 | remove, 12 | set, 13 | values, 14 | x, 15 | y, 16 | z, 17 | } from './setup' 18 | 19 | beforeEach(jest.clearAllMocks) 20 | 21 | cases<{ 22 | getter: any 23 | rawGetter: any 24 | got: any 25 | calls: number 26 | expected: any 27 | }>( 28 | 'each getter type', 29 | async ({ getter, rawGetter, got, calls, expected }) => { 30 | const bucket = getBucket(bucketName) 31 | 32 | get.mockImplementation((getter, cb) => { 33 | if (getter === keysName) { 34 | cb(keys) 35 | } else { 36 | cb(got) 37 | } 38 | }) 39 | 40 | const result = await bucket.get(getter) 41 | 42 | expect(set).not.toBeCalled() 43 | expect(remove).not.toBeCalled() 44 | expect(clear).not.toBeCalled() 45 | 46 | expect(get).toBeCalledTimes(calls) 47 | expect(get).toBeCalledWith(rawGetter, anyFn) 48 | 49 | expect(result).toEqual(expected) 50 | }, 51 | { 52 | string: { 53 | getter: 'x', 54 | rawGetter: x, 55 | got: { [x]: values[x] }, 56 | calls: 1, 57 | expected: { x: values[x] }, 58 | }, 59 | object: { 60 | getter: { x: 'abc', z: '789' }, 61 | rawGetter: { [x]: 'abc', [z]: '789' }, 62 | got: { [x]: values[x], [z]: '789' }, 63 | calls: 1, 64 | expected: { x: values[x], z: '789' }, 65 | }, 66 | array: { 67 | getter: ['x', 'z'], 68 | rawGetter: [x, z], 69 | got: { [x]: values[x] }, 70 | calls: 1, 71 | expected: { x: values[x] }, 72 | }, 73 | function: { 74 | getter: ({ x }: Bucket) => typeof x, 75 | rawGetter: [x, y], 76 | got: values, 77 | calls: 2, 78 | expected: 'string', 79 | }, 80 | undefined: { 81 | getter: undefined, 82 | rawGetter: [x, y], 83 | got: values, 84 | calls: 2, 85 | expected: { x: values[x], y: values[y] }, 86 | }, 87 | null: { 88 | getter: null, 89 | rawGetter: [x, y], 90 | got: values, 91 | calls: 2, 92 | expected: { x: values[x], y: values[y] }, 93 | }, 94 | }, 95 | ) 96 | -------------------------------------------------------------------------------- /tests/bucket/set-error.test.ts: -------------------------------------------------------------------------------- 1 | import cases from 'jest-in-case' 2 | 3 | import { getBucket } from '../../src/bucket' 4 | import { 5 | clear, 6 | get, 7 | keysName, 8 | remove, 9 | set, 10 | unpfxObj, 11 | values, 12 | x, 13 | y, 14 | bucketName, 15 | Bucket, 16 | } from './setup' 17 | 18 | beforeEach(jest.clearAllMocks) 19 | 20 | cases<{ 21 | returnValue: any 22 | }>( 23 | 'rejects for function return types', 24 | async ({ returnValue }) => { 25 | expect.assertions(1) 26 | 27 | const bucket = getBucket(bucketName) 28 | 29 | return bucket 30 | .set(() => returnValue) 31 | .catch((error: any) => { 32 | expect(error.message).toBe( 33 | `Unexpected setter return value: ${typeof returnValue}`, 34 | ) 35 | }) 36 | }, 37 | { 38 | boolean: { 39 | returnValue: true, 40 | }, 41 | function: { 42 | returnValue: () => {}, 43 | }, 44 | string: { 45 | returnValue: 'abc', 46 | }, 47 | number: { 48 | returnValue: 123, 49 | }, 50 | }, 51 | ) 52 | 53 | test('one reject does not disrupt other set ops', async () => { 54 | const bucket = getBucket(bucketName) 55 | 56 | const setFn = ({ x }: Bucket) => ({ x: x + '4' }) 57 | const raw = { [x]: '1234', [y]: values[y] } 58 | const expected = unpfxObj(raw) 59 | 60 | const spy = jest.fn(setFn) 61 | const number = jest.fn(() => 2) 62 | 63 | const expectError = (error: any) => { 64 | expect(error.message).toBe( 65 | 'Unexpected setter return value: number', 66 | ) 67 | } 68 | 69 | const expectResult = (result: any) => { 70 | expect(result).toEqual(expected) 71 | } 72 | 73 | await Promise.all([ 74 | bucket.set(spy).then(expectResult), 75 | // eslint-disable-next-line 76 | // @ts-ignore 77 | bucket.set(number).catch(expectError), 78 | ]) 79 | 80 | expect(get).toBeCalledTimes(2) 81 | expect(get).toBeCalledWith(keysName, expect.any(Function)) 82 | expect(get).toBeCalledWith([x, y], expect.any(Function)) 83 | 84 | const setter = { 85 | ...raw, 86 | [keysName]: Object.keys(expected), 87 | } 88 | expect(set).toBeCalledTimes(1) 89 | expect(set).toBeCalledWith(setter, expect.any(Function)) 90 | 91 | expect(spy).toBeCalled() 92 | expect(spy).toBeCalledTimes(1) 93 | expect(spy).toBeCalledWith({ 94 | x: values[x], 95 | y: values[y], 96 | }) 97 | 98 | expect(clear).not.toBeCalled() 99 | expect(remove).not.toBeCalled() 100 | }) 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extend-chrome/storage", 3 | "version": "1.5.0", 4 | "description": "This is a wrapper for the Chrome Extension Storage API that adds promises and functional set transactions similar to the React `this.setState` API. Functional set transactions make it easy to use the Chrome Storage API to share and manage state between different contexts in a Chrome Extension.", 5 | "repository": "extend-chrome/storage", 6 | "license": "MIT", 7 | "author": "", 8 | "main": "lib/index-cjs.js", 9 | "module": "lib/index-esm.js", 10 | "types": "types/index.d.ts", 11 | "files": [ 12 | "bucket", 13 | "jest", 14 | "lib", 15 | "types" 16 | ], 17 | "scripts": { 18 | "build:pre": "rm -rf lib types hooks jest bucket", 19 | "build": "run-p build:pre build:pro build:types build:copy", 20 | "build:copy": "run-p build:copy:*", 21 | "build:copy:bucket": "copyfiles -f src/bucket/package.json bucket", 22 | "build:copy:jest": "copyfiles -f src/jest/package.json jest", 23 | "build:dev": "rollup -c --environment NODE_ENV:development", 24 | "build:pro": "rollup -c --environment NODE_ENV:production", 25 | "build:types": "tsc -p tsconfig.d.json", 26 | "prepublishOnly": "npm run build", 27 | "postpublish": "rm -rf node_modules package-lock.json && pnpm i", 28 | "start": "run-p start:*", 29 | "start:rollup": "npm run build:dev -- -w", 30 | "start:tsc": "tsc -b tsconfig.d.json -w", 31 | "test:tsc": "tsc --noEmit", 32 | "test:jest": "jest", 33 | "test": "run-s build test:*" 34 | }, 35 | "dependencies": { 36 | "chrome-promise": "^3.0.5", 37 | "rxjs": "^6.5.5 || ^7.1.0" 38 | }, 39 | "devDependencies": { 40 | "@rollup/plugin-commonjs": "^12.0.0", 41 | "@rollup/plugin-node-resolve": "^8.0.0", 42 | "@rollup/plugin-typescript": "^4.1.2", 43 | "@sucrase/jest-plugin": "^2.0.0", 44 | "@types/chrome": "^0.0.142", 45 | "@types/jest": "^25.2.3", 46 | "@types/jest-in-case": "^1.0.2", 47 | "@types/node": "^14.0.4", 48 | "@types/puppeteer": "^3.0.0", 49 | "@types/react": "^16.9.35", 50 | "@types/react-dom": "^16.9.8", 51 | "@typescript-eslint/eslint-plugin": "^2.34.0", 52 | "@typescript-eslint/parser": "^2.34.0", 53 | "copyfiles": "^2.2.0", 54 | "delay": "^4.3.0", 55 | "eslint": "^7.0.0", 56 | "eslint-plugin-jest": "^23.13.1", 57 | "jest": "26.6.3", 58 | "jest-in-case": "^1.0.2", 59 | "npm-run-all": "^4.1.5", 60 | "prettier": "^2.0.5", 61 | "rollup": "^2.10.5", 62 | "rollup-plugin-copy2": "^0.2.0", 63 | "rxjs": "^7.1.0", 64 | "ts-jest": "^26.5.6", 65 | "tslib": "^2.2.0", 66 | "typescript": "^4.2.4" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs' 2 | 3 | export type AreaName = 'local' | 'sync' | 'managed' 4 | 5 | export type CoreGetter = 6 | | Extract 7 | | Extract[] 8 | | Partial 9 | 10 | export type Getter = 11 | | Extract 12 | | Extract[] 13 | | ((values: T) => any) 14 | | Partial 15 | 16 | export type Changes = { 17 | [K in keyof T]?: { 18 | oldValue?: T[K] 19 | newValue?: T[K] 20 | } 21 | } 22 | 23 | export interface Bucket { 24 | /** 25 | * Get a value or values in the storage area using a key name, a key name array, or a getter function. 26 | * 27 | * A getter function receives a StorageValues object and can return anything. 28 | */ 29 | get(): Promise 30 | get(getter: null): Promise 31 | get

>( 32 | getter: P, 33 | ): Promise<{ [key in P]: T[P] }> 34 | get

[]>( 35 | getter: P, 36 | ): Promise> 37 | get(getter: (values: T) => K): Promise 38 | get>(getter: K): Promise 39 | /** 40 | * Set a value or values in the storage area using an object with keys and default values, or a setter function. 41 | * 42 | * A setter function receives a StorageValues object and must return a StorageValues object. A setter function cannot be an async function. 43 | * 44 | * Synchronous calls to set will be composed into a single setter function for performance and reliability. 45 | */ 46 | set(setter: Partial): Promise 47 | set(setter: (prev: T) => Partial): Promise 48 | /** 49 | * Set a value or values in the storage area using an async setter function. 50 | * 51 | * An async setter function should return a Promise that contains a StorageValues object. 52 | * 53 | * `StorageArea#update` should be used if an async setter function is required. Syncronous calls to `set` will be more performant than calls to `update`. 54 | * 55 | * ```javascript 56 | * storage.local.update(async ({ text }) => { 57 | * const result = await asyncApiRequest(text) 58 | * 59 | * return { text: result } 60 | * }) 61 | * ``` 62 | */ 63 | update: (asyncSetter: (values: T) => Promise) => Promise 64 | /** Remove a key from the storage area */ 65 | remove: (query: string | string[]) => Promise 66 | /** Clear the storage area */ 67 | clear: () => Promise 68 | /** Get the keys (or property names) of the storage area */ 69 | getKeys: () => Promise 70 | /** Emits an object with changed storage keys and StorageChange values */ 71 | readonly changeStream: Observable> 72 | /** Emits the current storage values immediately and when changeStream emits */ 73 | readonly valueStream: Observable 74 | } 75 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* ----------------------------------------------------------- */ 2 | /* ESLINT BASE RULES */ 3 | /* ----------------------------------------------------------- */ 4 | 5 | const rules = { 6 | '@typescript-eslint/camelcase': 'off', 7 | '@typescript-eslint/explicit-function-return-type': 'off', 8 | '@typescript-eslint/member-delimiter-style': [ 9 | 'error', 10 | { 11 | multiline: { 12 | delimiter: 'none', 13 | requireLast: false, 14 | }, 15 | singleline: { 16 | delimiter: 'semi', 17 | requireLast: false, 18 | }, 19 | }, 20 | ], 21 | '@typescript-eslint/no-explicit-any': 'off', 22 | '@typescript-eslint/no-non-null-assertion': 'off', 23 | '@typescript-eslint/no-unused-vars': [ 24 | 'warn', 25 | { 26 | args: 'after-used', 27 | ignoreRestSiblings: true, 28 | vars: 'all', 29 | }, 30 | ], 31 | '@typescript-eslint/no-use-before-define': [ 32 | 'error', 33 | { 34 | classes: true, 35 | functions: false, 36 | }, 37 | ], 38 | '@typescript-eslint/no-var-requires': 'off', 39 | '@typescript-eslint/unbound-method': 'off', 40 | } 41 | 42 | /* ----------------------------------------------------------- */ 43 | /* OVERRIDES */ 44 | /* ----------------------------------------------------------- */ 45 | 46 | const jest = { 47 | files: [ 48 | '**/*.test.ts', 49 | '**/*.test.tsx', 50 | 'tests/**/*.ts', 51 | '**/__mocks__/**/*.ts', 52 | ], 53 | env: { 'jest/globals': true }, 54 | extends: [ 55 | 'eslint:recommended', 56 | 'plugin:@typescript-eslint/eslint-recommended', 57 | 'plugin:@typescript-eslint/recommended', 58 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 59 | // 'plugin:jest/recommended', // requires no use of power-assert 60 | // 'plugin:jest/all', // adds style rules 61 | ], 62 | rules: { 63 | ...rules, 64 | 'no-restricted-globals': 'off', 65 | '@typescript-eslint/require-await': 'off', 66 | '@typescript-eslint/no-empty-function': 'off', 67 | }, 68 | plugins: ['jest'], 69 | } 70 | 71 | const ts = { 72 | files: ['**/*.ts'], 73 | env: { 74 | es6: true, 75 | node: true, 76 | }, 77 | globals: { 78 | Atomics: 'readonly', 79 | SharedArrayBuffer: 'readonly', 80 | }, 81 | extends: [ 82 | 'eslint:recommended', 83 | 'plugin:@typescript-eslint/eslint-recommended', 84 | 'plugin:@typescript-eslint/recommended', 85 | // 'plugin:@typescript-eslint/recommended-requiring-type-checking', 86 | ], 87 | parser: '@typescript-eslint/parser', 88 | parserOptions: { 89 | ecmaVersion: 2018, 90 | sourceType: 'module', 91 | project: './tsconfig.json', 92 | }, 93 | plugins: ['@typescript-eslint'], 94 | rules: { 95 | ...rules, 96 | 'no-restricted-globals': [ 97 | 1, 98 | 'it', 99 | 'test', 100 | 'expect', 101 | 'describe', 102 | ], 103 | }, 104 | // File matching patterns should go from general to specific 105 | overrides: [jest], 106 | } 107 | 108 | /* ----------------------------------------------------------- */ 109 | /* FINAL ESLINT CONFIG */ 110 | /* ----------------------------------------------------------- */ 111 | 112 | module.exports = { 113 | env: { 114 | es6: true, 115 | node: true, 116 | }, 117 | extends: ['eslint:recommended'], 118 | parserOptions: { 119 | ecmaVersion: 2018, 120 | sourceType: 'module', 121 | }, 122 | rules: {}, 123 | overrides: [ts], 124 | } 125 | -------------------------------------------------------------------------------- /tests/bucket/set.test.ts: -------------------------------------------------------------------------------- 1 | import { getBucket } from '../../src/bucket' 2 | import { 3 | a, 4 | b, 5 | bucketName, 6 | clear, 7 | get, 8 | keysName, 9 | remove, 10 | set, 11 | unpfxObj, 12 | values, 13 | x, 14 | y, 15 | z, 16 | Bucket, 17 | } from './setup' 18 | 19 | beforeEach(jest.clearAllMocks) 20 | 21 | test('set with object', async () => { 22 | const bucket = getBucket(bucketName) 23 | 24 | const raw = { ...values, [z]: '789' } 25 | 26 | const result = await bucket.set({ z: '789' }) 27 | 28 | const expected = unpfxObj(raw) 29 | expect(result).toEqual(expected) 30 | 31 | expect(get).toBeCalledTimes(2) 32 | expect(get).toBeCalledWith(keysName, expect.any(Function)) 33 | expect(get).toBeCalledWith([x, y], expect.any(Function)) 34 | 35 | const setter = { 36 | ...raw, 37 | [keysName]: Object.keys(expected), 38 | } 39 | 40 | expect(set).toBeCalledTimes(1) 41 | expect(set).toBeCalledWith(setter, expect.any(Function)) 42 | 43 | expect(remove).not.toBeCalled() 44 | expect(clear).not.toBeCalled() 45 | }) 46 | 47 | test('set with function', async () => { 48 | const bucket = getBucket(bucketName) 49 | 50 | const setFn = ({ x }: Bucket) => ({ x: x + '4' }) 51 | const spy = jest.fn(setFn) 52 | 53 | const raw = { [x]: '1234', [y]: '456' } 54 | const result = await bucket.set(spy) 55 | 56 | const expected = unpfxObj(raw) 57 | expect(result).toEqual(expected) 58 | 59 | expect(spy).toBeCalled() 60 | expect(spy).toBeCalledTimes(1) 61 | expect(spy).toBeCalledWith({ 62 | x: values[x], 63 | y: values[y], 64 | }) 65 | 66 | expect(get).toBeCalledTimes(2) 67 | expect(get).toBeCalledWith(keysName, expect.any(Function)) 68 | expect(get).toBeCalledWith([x, y], expect.any(Function)) 69 | 70 | const setter = { 71 | ...raw, 72 | [keysName]: Object.keys(expected), 73 | } 74 | expect(set).toBeCalledTimes(1) 75 | expect(set).toBeCalledWith(setter, expect.any(Function)) 76 | 77 | expect(remove).not.toBeCalled() 78 | expect(clear).not.toBeCalled() 79 | }) 80 | 81 | test('repeated object set operations', async () => { 82 | const bucket = getBucket(bucketName) 83 | 84 | const raw = { 85 | [x]: values[x], 86 | [y]: values[y], 87 | [z]: '789', 88 | [a]: '000', 89 | } 90 | 91 | const results = await Promise.all([ 92 | bucket.set({ z: '789' }), 93 | bucket.set({ a: '000' }), 94 | ]) 95 | 96 | const expected = unpfxObj(raw) 97 | results.forEach((result) => { 98 | expect(result).toEqual(expected) 99 | }) 100 | 101 | expect(get).toBeCalledTimes(2) 102 | expect(get).toBeCalledWith(keysName, expect.any(Function)) 103 | expect(get).toBeCalledWith([x, y], expect.any(Function)) 104 | 105 | const setter = { 106 | ...raw, 107 | [keysName]: Object.keys(expected), 108 | } 109 | expect(set).toBeCalledTimes(1) 110 | expect(set).toBeCalledWith(setter, expect.any(Function)) 111 | 112 | expect(remove).not.toBeCalled() 113 | expect(clear).not.toBeCalled() 114 | }) 115 | 116 | test('repeated function set operations', async () => { 117 | const bucket = getBucket(bucketName) 118 | 119 | const raw = { [x]: '789', [y]: '456', [a]: '7890', [b]: '456' } 120 | 121 | const results = await Promise.all([ 122 | bucket.set(() => ({ x: '789' })), 123 | bucket.set(({ x }) => ({ a: x + '0' })), 124 | bucket.set(({ y }) => ({ b: y })), 125 | ]) 126 | 127 | const expected = unpfxObj(raw) 128 | results.forEach((result) => { 129 | expect(result).toEqual(expected) 130 | }) 131 | 132 | expect(get).toBeCalledTimes(2) 133 | expect(get).toBeCalledWith(keysName, expect.any(Function)) 134 | expect(get).toBeCalledWith([x, y], expect.any(Function)) 135 | 136 | const setter = { 137 | ...raw, 138 | [keysName]: Object.keys(expected), 139 | } 140 | expect(set).toBeCalledTimes(1) 141 | expect(set).toBeCalledWith(setter, expect.any(Function)) 142 | 143 | expect(remove).not.toBeCalled() 144 | expect(clear).not.toBeCalled() 145 | }) 146 | 147 | test('mixed set operations', async () => { 148 | const bucket = getBucket(bucketName) 149 | 150 | const raw = { [x]: '123', [y]: '456', [z]: '7890', [a]: '000' } 151 | 152 | const results = await Promise.all([ 153 | bucket.set({ z: '789' }), 154 | bucket.set(({ z }) => ({ z: z + 0 })), 155 | bucket.set({ a: '000' }), 156 | ]) 157 | 158 | const expected = unpfxObj(raw) 159 | results.forEach((result) => { 160 | expect(result).toEqual(expected) 161 | }) 162 | 163 | expect(get).toBeCalledTimes(2) 164 | expect(get).toBeCalledWith(keysName, expect.any(Function)) 165 | expect(get).toBeCalledWith([x, y], expect.any(Function)) 166 | 167 | const setter = { 168 | ...raw, 169 | [keysName]: Object.keys(expected), 170 | } 171 | expect(set).toBeCalledTimes(1) 172 | expect(set).toBeCalledWith(setter, expect.any(Function)) 173 | 174 | expect(remove).not.toBeCalled() 175 | expect(clear).not.toBeCalled() 176 | }) 177 | -------------------------------------------------------------------------------- /src/bucket/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AreaName, 3 | Bucket, 4 | Changes, 5 | CoreGetter, 6 | Getter, 7 | } from '../types' 8 | import { concat, from, fromEventPattern } from 'rxjs' 9 | import { filter, map, mergeMap } from 'rxjs/operators' 10 | 11 | import chromep from 'chrome-promise' 12 | import { chromepApi } from 'chrome-promise/chrome-promise' 13 | import { invalidSetterReturn } from '../validate' 14 | import { isNonNull } from '../guards' 15 | 16 | export { Bucket } 17 | 18 | export const getStorageArea = ( 19 | area: AreaName, 20 | ): chromepApi.storage.StorageArea => { 21 | switch (area) { 22 | case 'local': 23 | return chromep.storage.local 24 | case 'sync': 25 | return chromep.storage.sync 26 | case 'managed': 27 | return chromep.storage.managed 28 | 29 | default: 30 | throw new TypeError( 31 | `area must be "local" | "sync" | "managed"`, 32 | ) 33 | } 34 | } 35 | 36 | /** 37 | * Create a bucket (synthetic storage area). 38 | * 39 | * @param {string} bucketName Must be a id for each bucket. 40 | * @param {string} [areaName = 'local'] The name of the storage area to use. 41 | * @returns {Bucket} Returns a bucket. 42 | */ 43 | export function getBucket>( 44 | bucketName: string, 45 | areaName?: AreaName, 46 | ): Bucket { 47 | /* ------------- GET STORAGE AREA ------------- */ 48 | if (!areaName) areaName = 'local' as const 49 | const _areaName: AreaName = areaName 50 | const storage = getStorageArea(_areaName) 51 | 52 | /* --------------- SETUP BUCKET --------------- */ 53 | const prefix = `extend-chrome/storage__${bucketName}` 54 | const keys = `${prefix}_keys` 55 | const pfx = (k: keyof T) => { 56 | return `${prefix}--${k}` 57 | } 58 | const unpfx = (pk: string) => { 59 | return pk.replace(`${prefix}--`, '') 60 | } 61 | 62 | const xfmKeys = (xfm: (x: string) => string) => ( 63 | obj: Record, 64 | ): Record => { 65 | return Object.keys(obj).reduce( 66 | (r, k) => ({ 67 | ...r, 68 | [xfm(k)]: obj[k], 69 | }), 70 | {}, 71 | ) 72 | } 73 | 74 | const pfxAry = (ary: (keyof T)[]) => { 75 | return ary.map(pfx) 76 | } 77 | const pfxObj = xfmKeys(pfx) 78 | const unpfxObj = xfmKeys(unpfx) 79 | 80 | const getKeys = async () => { 81 | const result = await storage.get(keys) 82 | 83 | return result[keys] || [] 84 | } 85 | 86 | const setKeys = (_keys: string[]) => { 87 | return storage.set({ [keys]: _keys }) 88 | } 89 | 90 | /* --------- STORAGE OPERATION PROMISE -------- */ 91 | 92 | let promise: Promise | null = null 93 | 94 | /* -------------------------------------------- */ 95 | /* STORAGE.GET */ 96 | /* -------------------------------------------- */ 97 | 98 | async function coreGet(): Promise 99 | async function coreGet(x: CoreGetter): Promise> 100 | async function coreGet(x?: CoreGetter) { 101 | // Flush pending storage.set ops before 102 | if (promise) return promise 103 | 104 | if (typeof x === 'undefined' || x === null) { 105 | // get all 106 | const keys = await getKeys() 107 | if (!keys.length) { 108 | return {} as T 109 | } else { 110 | const getter = pfxAry(keys) 111 | const result = await storage.get(getter) 112 | 113 | return unpfxObj(result) as T 114 | } 115 | } else if (typeof x === 'string') { 116 | // string getter, get one 117 | const getter = pfx(x) 118 | const result = await storage.get(getter) 119 | 120 | return unpfxObj(result) as Partial 121 | } else if (Array.isArray(x)) { 122 | // string array getter, get each 123 | const getter = pfxAry(x) 124 | const result = await storage.get(getter) 125 | 126 | return unpfxObj(result) as Partial 127 | } else { 128 | // object getter, get each key 129 | const getter = pfxObj(x) 130 | const result = await storage.get(getter) 131 | 132 | return unpfxObj(result) as Partial 133 | } 134 | } 135 | 136 | function get(): Promise 137 | function get(getter: null): Promise 138 | function get(getter: Getter>): Promise> 139 | function get(getter?: Getter> | null) { 140 | if (getter === null || getter === undefined) { 141 | return coreGet() as Promise 142 | } 143 | 144 | if (typeof getter === 'string' || typeof getter === 'object') 145 | return coreGet(getter) as Promise> 146 | if (typeof getter === 'function') 147 | return coreGet().then(getter) 148 | 149 | throw new TypeError( 150 | `Unexpected argument type: ${typeof getter}`, 151 | ) 152 | } 153 | 154 | /* -------------------------------------------- */ 155 | /* STORAGE.SET */ 156 | /* -------------------------------------------- */ 157 | 158 | const _createNextValue = (x: T): T => x 159 | let createNextValue = _createNextValue 160 | 161 | type SetterFn = (prev: Partial) => Partial 162 | function set(setter: Partial): Promise 163 | function set(setter: (prev: T) => Partial): Promise 164 | function set( 165 | arg: Partial | ((prev: T) => Partial), 166 | ): Promise { 167 | return new Promise((resolve, reject) => { 168 | let setter: SetterFn 169 | if (typeof arg === 'function') { 170 | setter = (prev) => { 171 | const result = (arg as SetterFn)(prev) 172 | const errorMessage = invalidSetterReturn(result) 173 | 174 | if (errorMessage) { 175 | reject(new TypeError(errorMessage)) 176 | 177 | return prev 178 | } else { 179 | return { 180 | ...prev, 181 | ...result, 182 | } 183 | } 184 | } 185 | } else { 186 | setter = (prev) => ({ 187 | ...prev, 188 | ...arg, 189 | }) 190 | } 191 | 192 | const composeFn = createNextValue 193 | createNextValue = (prev) => ({ 194 | ...prev, 195 | ...setter(composeFn(prev)), 196 | }) 197 | 198 | if (promise === null) { 199 | // Update storage starting with current values 200 | promise = coreGet().then((prev) => { 201 | try { 202 | // Compose new values 203 | const next = createNextValue(prev) 204 | const pfxNext = pfxObj(next) 205 | 206 | pfxNext[keys] = Object.keys(next) 207 | 208 | // Execute set 209 | return storage.set(pfxNext).then(() => next) 210 | } finally { 211 | // Clean up after a set operation 212 | createNextValue = _createNextValue 213 | promise = null 214 | } 215 | }) 216 | } 217 | 218 | // All calls to set should call resolve or reject 219 | promise.then(resolve).catch(reject) 220 | }) 221 | } 222 | 223 | const remove = (arg: string | string[]) => { 224 | const query = ([] as string[]).concat(arg) 225 | 226 | query.forEach((x) => { 227 | if (typeof x !== 'string') { 228 | throw new TypeError( 229 | `Unexpected argument type: ${typeof x}`, 230 | ) 231 | } 232 | }) 233 | 234 | const _setKeys = (_keys: string[]) => 235 | setKeys(_keys.filter((k) => !query.includes(k))) 236 | 237 | return storage 238 | .remove(pfxAry(query)) 239 | .then(getKeys) 240 | .then(_setKeys) 241 | } 242 | 243 | const nativeChange$ = fromEventPattern< 244 | [{ [key in keyof T]: chrome.storage.StorageChange }, string] 245 | >( 246 | (handler) => { 247 | chrome.storage.onChanged.addListener(handler) 248 | }, 249 | (handler) => { 250 | chrome.storage.onChanged.removeListener(handler) 251 | }, 252 | ) 253 | 254 | const changeStream = nativeChange$.pipe( 255 | filter(([changes, area]) => { 256 | return ( 257 | area === areaName && 258 | Object.keys(changes).some((k) => k.startsWith(prefix)) 259 | ) 260 | }), 261 | map(([changes]): Changes | undefined => { 262 | const bucketChanges = Object.keys(changes).filter( 263 | (k) => k.startsWith(prefix) && k !== keys, 264 | ) 265 | 266 | return bucketChanges.length 267 | ? bucketChanges.reduce( 268 | (r, k) => ({ ...r, [unpfx(k)]: changes[k] }), 269 | {} as typeof changes, 270 | ) 271 | : undefined 272 | }), 273 | filter(isNonNull), 274 | ) 275 | 276 | return { 277 | set, 278 | get, 279 | remove, 280 | 281 | async clear() { 282 | const _keys = await getKeys() 283 | const query = [keys, ...pfxAry(_keys)] 284 | 285 | return storage.remove(query) 286 | }, 287 | 288 | async update(updater) { 289 | const store = await get() 290 | const result = await updater(store) 291 | return set(result) 292 | }, 293 | 294 | async getKeys() { 295 | return getKeys() 296 | }, 297 | 298 | get changeStream() { 299 | return changeStream 300 | }, 301 | 302 | get valueStream() { 303 | return concat( 304 | from(get()), 305 | changeStream.pipe(mergeMap(() => get())), 306 | ) 307 | }, 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | @extend-chrome/storage logo 4 |

5 | 6 |

@extend-chrome/storage

7 | 8 |
9 | 10 | [![npm (scoped)](https://img.shields.io/npm/v/@extend-chrome/storage.svg)](https://www.npmjs.com/package/@extend-chrome/storage) 11 | [![GitHub last commit](https://img.shields.io/github/last-commit/extend-chrome/storage.svg)](https://github.com/extend-chrome/storage) 12 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](/LICENSE) 13 | [![TypeScript Declarations Included](https://img.shields.io/badge/types-TypeScript-informational)](#typescript) 14 | 15 | [![Fiverr: We make Chrome extensions](https://img.shields.io/badge/Fiverr%20-We%20make%20Chrome%20extensions-brightgreen.svg)](http://bit.ly/37mZsfA) 16 | [![ko-fi](https://img.shields.io/badge/ko--fi-Buy%20me%20a%20coffee-ff5d5b)](http://bit.ly/2qmaQYB) 17 | 18 |
19 | 20 | --- 21 | 22 | Manage Chrome Extension storage easily with `@extend-chrome/storage`. 23 | 24 | This is a wrapper for the Chrome Extension Storage API that adds 25 | [great TypeScript support](#features-typescript) using virtual storage buckets with a modern Promise-based API. 26 | 27 | # Table of Contents 28 | 29 | - [Getting Started](#getting_started) 30 | - [Usage](#usage) 31 | 32 | - [Features](#features) 33 | - [API](#api) 34 | - [interface Bucket](#api-bucket) 35 | - [bucket.get()](#api-bucket-get) 36 | - [bucket.getKeys()](#api-bucket-getKeys) 37 | - [bucket.set()](#api-bucket-set) 38 | - [bucket.remove()](#api-bucket-remove) 39 | - [bucket.clear()](#api-bucket-clear) 40 | - [bucket.changeStream](#api-bucket-changeStream) 41 | - [bucket.valueStream](#api-bucket-valueStream) 42 | - [function getBucket()](#api-getBucket) 43 | 44 | # Getting started 45 | 46 | You will need to use a bundler like 47 | [Rollup](https://rollupjs.org/guide/en/), Parcel, or Webpack to 48 | include this library in your Chrome extension. 49 | 50 | See [`rollup-plugin-chrome-extension`](http://bit.ly/35hLMR8) for 51 | an easy way to use Rollup to build your Chrome extension! 52 | 53 | ## Installation 54 | 55 | ```sh 56 | npm i @extend-chrome/storage 57 | ``` 58 | 59 | # Usage 60 | 61 | Add the `storage` permission to your `manifest.json` file. 62 | 63 | ```jsonc 64 | // manifest.json 65 | { 66 | "permissions": ["storage"] 67 | } 68 | ``` 69 | 70 | Take your Chrome extension to another level! 🚀 71 | 72 | ```javascript 73 | import { storage } from '@extend-chrome/storage' 74 | 75 | // Set storage using an object 76 | storage.set({ friends: ['Jack'] }) 77 | 78 | // Set storage using a function 79 | storage.set(({ friends }) => { 80 | // friends is ['Jack'] 81 | return { friends: [...friends, 'Amy'] } 82 | }) 83 | 84 | // Get storage value using a key 85 | storage.get('friends').then(({ friends }) => { 86 | // friends is ['Jack', 'Amy'] 87 | }) 88 | 89 | // Get storage values using an object 90 | storage 91 | .get({ friends: [], enemies: [] }) 92 | .then(({ friends, enemies }) => { 93 | // friends is ['Jack', 'Amy'] 94 | // enemies is the [] from the getter object 95 | }) 96 | ``` 97 | 98 | # Features 99 | 100 | ## Virtual typed storage buckets 101 | 102 | This library allows you to create a storage area and define the 103 | type of data that area will manage. 104 | 105 | 106 | 107 | ```typescript 108 | import { getBucket } from '@extend-chrome/storage' 109 | 110 | interface Store { 111 | a: string 112 | b: number 113 | } 114 | 115 | const store = getBucket('store') 116 | 117 | store.set({ a: 'abc', b }) 118 | store.set(({ b = 0 }) => ({ b: b + 500 })) 119 | 120 | store.set({ c: true }) // ts error 121 | store.set(({ a }) => ({ d: 'invalid' })) // ts error 122 | ``` 123 | 124 | ## Promises and functional setters 125 | 126 | The Chrome Storage API is asynchronous. This means synchronous 127 | calls to `get` and `set` will not reflect pending changes. This 128 | makes calls to `set` that depend on values held in storage 129 | difficult. 130 | 131 | While the Chrome Storage API is async, it uses callbacks. This 132 | brings a whole world of difficulty into the developer experience 133 | that have been solved with Promises. 134 | 135 | `@extend-chrome/storage` solves both of these problems. Every method 136 | returns a Promise and both `get` and `set` can take a function 137 | that provides current storage values, similar to React's 138 | `this.setState`. 139 | 140 | ## Composed set operations 141 | 142 | The `set` method can be called with a function (setter) as well 143 | as the normal types (a string, array of strings, or an object). 144 | 145 | This setter will receive the entire contents of that storage area 146 | as an argument. It must return an object which will be passed to 147 | the native storage area `set` method. 148 | 149 | Synchronous calls to `set` will be composed into one call to the 150 | native `set`. The setters will be applied in order, but each call 151 | will resolve with the final value passed to the storage area. 152 | 153 | ```javascript 154 | bucket.set({ a: 123 }) 155 | bucket.set({ b: 456 }) 156 | bucket 157 | .set(({ a, b }) => { 158 | // a === 123 159 | // b === 456 160 | return { c: 789 } 161 | }) 162 | .then(({ a, b, c }) => { 163 | // New values in bucket 164 | // a === 123 165 | // b === 456 166 | // c === 789 167 | }) 168 | ``` 169 | 170 | An individual call to `set` will reject if the setter function 171 | throws an error or returns an invalid type, but will not affect 172 | other set operations. 173 | 174 | # API 175 | 176 | ## interface `Bucket` 177 | 178 | A synthetic storage area. It has the same methods as the native 179 | Chrome API StorageArea, but `get` and `set` can take a function as an 180 | argument. A `Bucket` can use 181 | [either local or sync storage](https://developer.chrome.com/extensions/storage#using-sync). 182 | 183 | Multiple synchronous calls to set are composed into one call to 184 | the native Chrome API 185 | [`StorageArea.set`](https://developer.chrome.com/extensions/storage#method-StorageArea-set). 186 | 187 | Default storage areas are included, so you can just import 188 | `storage` if you're don't care about types and only need one 189 | storage area. 190 | 191 | ```javascript 192 | import { storage } from '@extend-chrome/storage' 193 | 194 | storage.local.set({ a: 'abc' }) 195 | storage.sync.set({ b: 123 }) 196 | ``` 197 | 198 | Create a bucket or two using [`getBucket`](#api-getBucket): 199 | 200 | ```javascript 201 | import { getBucket } from '@extend-chrome/storage' 202 | 203 | // Buckets are isomorphic, so export and 204 | // use them throughout your extension 205 | export const bucket1 = getBucket('bucket1') 206 | export const bucket2 = getBucket('bucket2') 207 | ``` 208 | 209 | Each bucket is separate, so values don't overlap. 210 | 211 | ```javascript 212 | bucket1.set({ a: 123 }) 213 | bucket2.set({ a: 'abc' }) 214 | 215 | bucket1.get() // { a: 123 } 216 | bucket2.get() // { a: 'abc' } 217 | ``` 218 | 219 | Buckets really shine if you're using TypeScript, because you can 220 | define the types your bucket will contain. 221 | [Click here for more details.](#features-typescript) 222 | 223 | ## async function `bucket.get` 224 | 225 | Takes an optional getter. Resolves to an object with the 226 | requested storage area values. 227 | 228 |
Parameters 229 |

230 | 231 | ```typescript 232 | function get(getter?: string | object | Function) => Promise<{ [key: string]: any }> 233 | ``` 234 | 235 | **[getter]**\ 236 | Type: `null`, `string`, `string array`, or `function`\ 237 | Default: `null`\ 238 | Usage is the same as for the native Chrome API, except for the function 239 | getter. 240 | 241 | - Use `null` to get the entire bucket contents. 242 | - Use a `string` as a storage area key to get an object with only 243 | that key/value pair. 244 | - Use an `object` with property names for the storage keys you 245 | want. 246 | - The values for each property will be used if the key is 247 | undefined in storage. 248 | - Use a `function` to map the contents of storage to any value. 249 | - The function will receive the entire contents of storage as 250 | the first argument. 251 | - The call to get will resolve to the function's return value. 252 | - Calls to `get` after `set` will resolve to the new set values. 253 | 254 |

255 |
256 | 257 | ```typescript 258 | bucket.get('a') // resolves to object as key/value pair 259 | bucket.get({ a: 123 }) // same, but 123 is default value 260 | bucket.get(({ a }) => a) // resolves to value of "a" 261 | ``` 262 | 263 | ## async function `bucket.getKeys` 264 | 265 | Takes no arguments. Resolves to an array of strings that represents the keys of the values in the storage area bucket. 266 | 267 | ```typescript 268 | function getKeys() => Promise 269 | ``` 270 | 271 | ```typescript 272 | bucket.set({ a: 123 }) 273 | bucket.getKeys() // Resolves to ['a'] 274 | ``` 275 | 276 | ## async function `bucket.set` 277 | 278 | Set a value or values in storage. Takes a setter `object` or 279 | `function`. Resolves to the new bucket values. 280 | 281 |
Parameters 282 |

283 | 284 | ```typescript 285 | function set(setter: object | Function) => Promise<{ [key: string]: any }> 286 | ``` 287 | 288 | **`setter`**\ 289 | Type: `object` or `Function` 290 | 291 | - Use an `object` with key/value pairs to set those values to 292 | storage. 293 | - Use a `Function` that returns an object with key/value pairs. 294 | - The setter function receives the results of previous 295 | synchronous set operations. 296 | - The setter function cannot be an async function. 297 | - Returns a `Promise` that resolves to the new storage values. 298 | - Calls to `get` after `set` will resolve with the new values. 299 | 300 | ```typescript 301 | // Values in bucket: { a: 'abc' } 302 | 303 | // First call to set 304 | bucket.set({ b: 123 }) 305 | 306 | // Second synchronous call to set 307 | bucket 308 | .set(({ a, b, c }) => { 309 | // Values composed from storage 310 | // and previous call to set: 311 | // a === 'abc' 312 | // b === 123 313 | // c === undefined 314 | return { c: true } 315 | }) 316 | .then(({ a, b, c }) => { 317 | // New values in storage 318 | // a === 'abc' 319 | // b === 123 320 | // c === true 321 | }) 322 | ``` 323 | 324 |

325 |
326 | 327 | ```typescript 328 | bucket.set({ a: 123 }) 329 | bucket 330 | .set(({ a }) => ({ b: true })) 331 | .then(({ a, b }) => { 332 | // Values were set 333 | // a === 123 334 | // b === true 335 | }) 336 | ``` 337 | 338 | ## async function `bucket.remove` 339 | 340 | Remove a value or values from storage. Resolves when the 341 | operation is complete. 342 | 343 | ```typescript 344 | bucket.remove('a') 345 | bucket.remove(['a', 'b']) 346 | ``` 347 | 348 | ## async function `bucket.clear` 349 | 350 | Empties only this bucket. Resolves when the operation is 351 | complete. Other buckets are untouched. 352 | 353 | ```typescript 354 | bucket.clear() 355 | ``` 356 | 357 | ## async function `bucket.changeStream` 358 | 359 | An RxJs Observable that emits a 360 | [StorageChange](https://developer.chrome.com/extensions/storage#type-StorageChange) 361 | object when the Chrome Storage API `onChanged` event fires. 362 | 363 | ```typescript 364 | bucket.changeStream 365 | .pipe(filter(({ a }) => !!a)) 366 | .subscribe(({ a }) => { 367 | console.log('old value', a.oldValue) 368 | console.log('new value', a.newValue) 369 | }) 370 | ``` 371 | 372 | ## async function `bucket.valueStream` 373 | 374 | An Observable that emits all the values in storage immediately, 375 | and when `onChanged` fires. 376 | 377 | ```typescript 378 | bucket.valueStream.subscribe((values) => { 379 | console.log('Everything in this bucket', values) 380 | }) 381 | ``` 382 | 383 | ## function `getBucket` 384 | 385 | Create a [bucket](#api-bucket) (a synthetic storage area). Takes 386 | a string `bucketName` and an optional string `areaName`. Returns 387 | a [`Bucket`](#api-bucket) synthetic storage area. Export this 388 | bucket and use it throughout your Chrome extension. It will work 389 | everywhere, including privileged pages and content scripts. 390 | 391 | `getBucket` is a TypeScript Generic. Pass it an interface to 392 | define the types to expect in your storage area. 393 | 394 |
Parameters 395 |

396 | 397 | ```typescript 398 | function getBucket(bucketName: string, areaName?: 'local' | 'sync') => Bucket 399 | ``` 400 | 401 | **`bucketName`**\ 402 | Type: `string`\ 403 | A unique id for this bucket. 404 | 405 | **`[areaName]`**\ 406 | Type: `"local"` or `"sync"`\ 407 | Default: `"local"`\ 408 | Choose which [native Chrome API storage area](https://developer.chrome.com/extensions/storage#using-sync) 409 | to use. 410 | 411 |

412 |
413 | 414 | ```typescript 415 | import { getBucket } from '@extend-chrome/storage' 416 | 417 | // JavaScript 418 | export const localBucket = getBucket('bucket1') 419 | export const syncBucket = getBucket('bucket2', 'sync') 420 | 421 | // TypeScript 422 | export const localBucket = getBucket<{ a: string }>('bucket1') 423 | export const syncBucket = getBucket<{ b: number }>( 424 | 'bucket2', 425 | 'sync', 426 | ) 427 | ``` 428 | --------------------------------------------------------------------------------