├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ └── esm-lint.yml ├── .gitignore ├── .npmrc ├── file.ts ├── globals.d.ts ├── index.ts ├── license ├── package.json ├── readme.md ├── test ├── _env.js └── index.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [{package.json,*.yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | vendor/ linguist-vendored 3 | test/* linguist-detectable=false 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | env: {} 2 | 3 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/node 4 | # SOURCE: https://github.com/fregante/ghatemplates 5 | 6 | name: CI 7 | 8 | on: 9 | - pull_request 10 | - push 11 | 12 | jobs: 13 | Lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version-file: package.json 20 | - name: install 21 | run: npm ci || npm install 22 | - name: XO 23 | run: npx xo 24 | 25 | Test: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-node@v4 30 | with: 31 | node-version-file: package.json 32 | - name: install 33 | run: npm ci || npm install 34 | - name: build 35 | run: npm run build --if-present 36 | - name: AVA 37 | run: npx ava 38 | 39 | Build: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version-file: package.json 46 | - name: install 47 | run: npm ci || npm install 48 | - name: build 49 | run: npm run build 50 | -------------------------------------------------------------------------------- /.github/workflows/esm-lint.yml: -------------------------------------------------------------------------------- 1 | env: 2 | IMPORT_STATEMENT: export {default as OptionsSync} from "webext-options-sync" 3 | 4 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/esm-lint 5 | # SOURCE: https://github.com/fregante/ghatemplates 6 | # OPTIONS: {"exclude":["jobs.Rollup"]} 7 | 8 | name: ESM 9 | on: 10 | pull_request: 11 | branches: 12 | - '*' 13 | push: 14 | branches: 15 | - master 16 | - main 17 | jobs: 18 | Pack: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - run: npm install 23 | - run: npm run build --if-present 24 | - run: npm pack --dry-run 25 | - run: npm pack | tail -1 | xargs -n1 tar -xzf 26 | - uses: actions/upload-artifact@v4 27 | with: 28 | path: package 29 | Publint: 30 | runs-on: ubuntu-latest 31 | needs: Pack 32 | steps: 33 | - uses: actions/download-artifact@v4 34 | - run: npx publint ./artifact 35 | Webpack: 36 | runs-on: ubuntu-latest 37 | needs: Pack 38 | steps: 39 | - uses: actions/download-artifact@v4 40 | - run: npm install --omit=dev ./artifact 41 | - run: echo "$IMPORT_STATEMENT" > index.js 42 | - run: webpack --entry ./index.js 43 | - run: cat dist/main.js 44 | Parcel: 45 | runs-on: ubuntu-latest 46 | needs: Pack 47 | steps: 48 | - uses: actions/download-artifact@v4 49 | - run: npm install --omit=dev ./artifact 50 | - run: echo "$IMPORT_STATEMENT" > index.js 51 | - run: > 52 | echo '{"@parcel/resolver-default": {"packageExports": true}}' > 53 | package.json 54 | - run: npx parcel@2 build index.js 55 | - run: cat dist/index.js 56 | Vite: 57 | runs-on: ubuntu-latest 58 | needs: Pack 59 | steps: 60 | - uses: actions/download-artifact@v4 61 | - run: npm install --omit=dev ./artifact 62 | - run: echo '' > index.html 63 | - run: npx vite build 64 | - run: cat dist/assets/* 65 | esbuild: 66 | runs-on: ubuntu-latest 67 | needs: Pack 68 | steps: 69 | - uses: actions/download-artifact@v4 70 | - run: echo '{}' > package.json 71 | - run: echo "$IMPORT_STATEMENT" > index.js 72 | - run: npm install --omit=dev ./artifact 73 | - run: npx esbuild --bundle index.js 74 | TypeScript: 75 | runs-on: ubuntu-latest 76 | needs: Pack 77 | steps: 78 | - uses: actions/download-artifact@v4 79 | - run: echo '{"type":"module"}' > package.json 80 | - run: npm install --omit=dev ./artifact @sindresorhus/tsconfig 81 | - run: echo "$IMPORT_STATEMENT" > index.ts 82 | - run: > 83 | echo '{"extends":"@sindresorhus/tsconfig","files":["index.ts"]}' > 84 | tsconfig.json 85 | - run: npx --package typescript -- tsc 86 | - run: cat distribution/index.js 87 | Node: 88 | runs-on: ubuntu-latest 89 | needs: Pack 90 | steps: 91 | - uses: actions/download-artifact@v4 92 | - uses: actions/setup-node@v4 93 | with: 94 | node-version-file: artifact/package.json 95 | - run: echo "$IMPORT_STATEMENT" > index.mjs 96 | - run: npm install --omit=dev ./artifact 97 | - run: node index.mjs 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | Desktop.ini 4 | ._* 5 | Thumbs.db 6 | *.tmp 7 | *.bak 8 | *.log 9 | logs 10 | *.map 11 | index.js 12 | index.d.ts 13 | file.js 14 | file.d.ts 15 | !*/index.js 16 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /file.ts: -------------------------------------------------------------------------------- 1 | import {stringToBase64} from 'uint8array-extras'; 2 | 3 | const filePickerOptions: FilePickerOptions = { 4 | types: [ 5 | { 6 | accept: { 7 | // eslint-disable-next-line @typescript-eslint/naming-convention 8 | 'application/json': '.json', 9 | }, 10 | }, 11 | ], 12 | }; 13 | 14 | const isModern = typeof showOpenFilePicker === 'function'; 15 | 16 | async function loadFileOld(): Promise { 17 | const input = document.createElement('input'); 18 | input.type = 'file'; 19 | input.accept = '.json'; 20 | const eventPromise = new Promise(resolve => { 21 | input.addEventListener('change', resolve, {once: true}); 22 | }); 23 | 24 | input.click(); 25 | const event = await eventPromise; 26 | const file = (event.target as HTMLInputElement).files![0]; 27 | if (!file) { 28 | throw new Error('No file selected'); 29 | } 30 | 31 | return file.text(); 32 | } 33 | 34 | async function saveFileOld(text: string, suggestedName: string): Promise { 35 | // Use data URL because Safari doesn't support saving blob URLs 36 | // Use base64 or else linebreaks are lost 37 | const url = `data:application/json;base64,${stringToBase64(text)}`; 38 | const link = document.createElement('a'); 39 | link.download = suggestedName; 40 | link.href = url; 41 | link.click(); 42 | } 43 | 44 | async function loadFileModern(): Promise { 45 | const [fileHandle] = await showOpenFilePicker(filePickerOptions); 46 | const file = await fileHandle.getFile(); 47 | return file.text(); 48 | } 49 | 50 | async function saveFileModern(text: string, suggestedName: string) { 51 | const fileHandle = await showSaveFilePicker({ 52 | ...filePickerOptions, 53 | suggestedName, 54 | }); 55 | 56 | const writable = await fileHandle.createWritable(); 57 | await writable.write(text); 58 | await writable.close(); 59 | } 60 | 61 | export const loadFile = isModern ? loadFileModern : loadFileOld; 62 | export const saveFile = isModern ? saveFileModern : saveFileOld; 63 | -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'dom-form-serializer/dist/dom-form-serializer.mjs' { 2 | import {type JsonObject} from 'type-fest'; 3 | 4 | export function serialize( 5 | element: HTMLFormElement, 6 | options: { 7 | include?: string[]; 8 | } 9 | ): JSONValue; 10 | 11 | export function deserialize( 12 | element: HTMLFormElement, 13 | serializedData: JsonObject, 14 | options?: { 15 | include?: string[]; 16 | } 17 | ): void; 18 | } 19 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import {debounce} from 'throttle-debounce'; 2 | import chromeP from 'webext-polyfill-kinda'; 3 | import {isBackground} from 'webext-detect'; 4 | import {serialize, deserialize} from 'dom-form-serializer/dist/dom-form-serializer.mjs'; 5 | import LZString from 'lz-string'; 6 | import {onContextInvalidated} from 'webext-events'; 7 | import {loadFile, saveFile} from './file.js'; 8 | 9 | // eslint-disable-next-line @typescript-eslint/naming-convention -- CJS in ESM imports 10 | const {compressToEncodedURIComponent, decompressFromEncodedURIComponent} = LZString; 11 | 12 | function alertAndThrow(message: string): never { 13 | // eslint-disable-next-line no-alert 14 | alert(message); 15 | throw new Error(message); 16 | } 17 | 18 | async function shouldRunMigrations(): Promise { 19 | const self = await chromeP.management?.getSelf(); 20 | 21 | // Always run migrations during development #25 22 | if (self?.installType === 'development') { 23 | return true; 24 | } 25 | 26 | return new Promise(resolve => { 27 | // Run migrations when the extension is installed or updated 28 | chrome.runtime.onInstalled.addListener(() => { 29 | resolve(true); 30 | }); 31 | 32 | // If `onInstalled` isn't fired, then migrations should not be run 33 | setTimeout(resolve, 500, false); 34 | }); 35 | } 36 | 37 | export type StorageType = 'sync' | 'local'; 38 | 39 | /** 40 | @example 41 | { 42 | // Recommended 43 | defaults: { 44 | color: 'blue' 45 | }, 46 | // Optional 47 | migrations: [ 48 | savedOptions => { 49 | if (savedOptions.oldStuff) { 50 | delete savedOptions.oldStuff; 51 | } 52 | } 53 | ], 54 | } 55 | */ 56 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- Maybe later 57 | export interface Setup { 58 | storageName?: string; 59 | logging?: boolean; 60 | defaults?: UserOptions; 61 | /** 62 | * A list of functions to call when the extension is updated. 63 | */ 64 | migrations?: Array>; 65 | storageType?: StorageType; 66 | } 67 | 68 | /** 69 | A map of options as strings or booleans. The keys will have to match the form fields' `name` attributes. 70 | */ 71 | // eslint-disable-next-line @typescript-eslint/consistent-indexed-object-style, @typescript-eslint/consistent-type-definitions -- Interfaces are extendable 72 | export interface Options { 73 | [key: string]: string | number | boolean; 74 | } 75 | 76 | /* 77 | Handler signature for when an extension updates. 78 | */ 79 | export type Migration = (savedOptions: UserOptions, defaults: UserOptions) => Promise | void; 80 | 81 | class OptionsSync { 82 | public static migrations = { 83 | /** 84 | Helper method that removes any option that isn't defined in the defaults. It's useful to avoid leaving old options taking up space. 85 | */ 86 | removeUnused(options: Options, defaults: Options) { 87 | for (const key of Object.keys(options)) { 88 | if (!(key in defaults)) { 89 | delete options[key]; 90 | } 91 | } 92 | }, 93 | }; 94 | 95 | storageName: string; 96 | storageType: StorageType; 97 | 98 | defaults: UserOptions; 99 | 100 | _form: HTMLFormElement | undefined; 101 | 102 | private readonly _migrations: Promise; 103 | 104 | /** 105 | @constructor Returns an instance linked to the chosen storage. 106 | @param setup - Configuration for `webext-options-sync` 107 | */ 108 | constructor({ 109 | // `as` reason: https://github.com/fregante/webext-options-sync/pull/21#issuecomment-500314074 110 | defaults = {} as UserOptions, 111 | storageName = 'options', 112 | migrations = [], 113 | logging = true, 114 | storageType = 'sync', 115 | }: Setup = {}) { 116 | this.storageName = storageName; 117 | this.defaults = defaults; 118 | this.storageType = storageType; 119 | 120 | if (!logging) { 121 | this._log = () => {}; 122 | } 123 | 124 | this._migrations = this._runMigrations(migrations); 125 | } 126 | 127 | private get storage(): chrome.storage.StorageArea { 128 | return chromeP.storage[this.storageType]; 129 | } 130 | 131 | /** 132 | Retrieves all the options stored. 133 | 134 | @returns Promise that will resolve with **all** the options stored, as an object. 135 | 136 | @example 137 | const optionsStorage = new OptionsSync(); 138 | const options = await optionsStorage.getAll(); 139 | console.log('The user’s options are', options); 140 | if (options.color) { 141 | document.body.style.color = color; 142 | } 143 | */ 144 | async getAll(): Promise { 145 | await this._migrations; 146 | return this._getAll(); 147 | } 148 | 149 | /** 150 | Overrides **all** the options stored with your `options`. 151 | 152 | @param newOptions - A map of default options as strings or booleans. The keys will have to match the form fields' `name` attributes. 153 | */ 154 | async setAll(newOptions: UserOptions): Promise { 155 | await this._migrations; 156 | return this._setAll(newOptions); 157 | } 158 | 159 | /** 160 | Merges new options with the existing stored options. 161 | 162 | @param newOptions - A map of default options as strings or booleans. The keys will have to match the form fields' `name` attributes. 163 | */ 164 | async set(newOptions: Partial): Promise { 165 | return this.setAll({...await this.getAll(), ...newOptions}); 166 | } 167 | 168 | /** 169 | Any defaults or saved options will be loaded into the `
` and any change will automatically be saved to storage 170 | 171 | @param selector - The `` that needs to be synchronized or a CSS selector (one element). 172 | The form fields' `name` attributes will have to match the option names. 173 | */ 174 | async syncForm(form: string | HTMLFormElement): Promise { 175 | this.stopSyncForm(); 176 | this._form = form instanceof HTMLFormElement 177 | ? form 178 | : document.querySelector(form)!; 179 | 180 | this._form.addEventListener('input', this._handleFormInput); 181 | this._form.addEventListener('submit', this._handleFormSubmit); 182 | chrome.storage.onChanged.addListener(this._handleStorageChangeOnForm); 183 | this._updateForm(this._form, await this.getAll()); 184 | 185 | this._form.querySelector('.js-export')?.addEventListener('click', this.exportToFile); 186 | this._form.querySelector('.js-import')?.addEventListener('click', this.importFromFile); 187 | 188 | onContextInvalidated.addListener(() => { 189 | location.reload(); 190 | }); 191 | } 192 | 193 | /** 194 | Removes any listeners added by `syncForm` 195 | */ 196 | stopSyncForm(): void { 197 | if (this._form) { 198 | this._form.removeEventListener('input', this._handleFormInput); 199 | this._form.removeEventListener('submit', this._handleFormSubmit); 200 | this._form.querySelector('.js-export')?.removeEventListener('click', this.exportToFile); 201 | this._form.querySelector('.js-import')?.removeEventListener('click', this.importFromFile); 202 | chrome.storage.onChanged.removeListener(this._handleStorageChangeOnForm); 203 | delete this._form; 204 | } 205 | } 206 | 207 | private get _jsonIdentityHelper() { 208 | return '__webextOptionsSync'; 209 | } 210 | 211 | /** 212 | Opens the browser’s file picker to import options from a previously-saved JSON file 213 | */ 214 | importFromFile = async (): Promise => { 215 | const text = await loadFile(); 216 | 217 | let options: UserOptions; 218 | 219 | try { 220 | options = JSON.parse(text) as UserOptions; 221 | } catch { 222 | alertAndThrow('The file is not a valid JSON file.'); 223 | } 224 | 225 | if (!(this._jsonIdentityHelper in options)) { 226 | alertAndThrow('The file selected is not a valid recognized options file.'); 227 | } 228 | 229 | delete options[this._jsonIdentityHelper]; 230 | 231 | await this.set(options); 232 | if (this._form) { 233 | this._updateForm(this._form, options); 234 | } 235 | }; 236 | 237 | /** 238 | Opens the browser’s "save file" dialog to export options to a JSON file 239 | */ 240 | exportToFile = async (): Promise => { 241 | const extension = chrome.runtime.getManifest(); 242 | const text = JSON.stringify({ 243 | [this._jsonIdentityHelper]: extension.name, 244 | ...await this.getAll(), 245 | }, null, '\t'); 246 | 247 | await saveFile(text, extension.name + ' options.json'); 248 | }; 249 | 250 | onChanged(callback: (options: UserOptions, oldOptions: UserOptions) => void, signal?: AbortSignal): void { 251 | const onChanged = (changes: Record, area: chrome.storage.AreaName) => { 252 | const data = changes[this.storageName]; 253 | if (data && area === this.storageType) { 254 | callback( 255 | this._decode(data.newValue as string), 256 | this._decode(data.oldValue as string ?? {}), 257 | ); 258 | } 259 | }; 260 | 261 | chrome.storage.onChanged.addListener(onChanged); 262 | signal?.addEventListener('abort', () => { 263 | chrome.storage.onChanged.removeListener(onChanged); 264 | }); 265 | } 266 | 267 | private _log(method: 'log' | 'info', ...arguments_: unknown[]): void { 268 | console[method](...arguments_); 269 | } 270 | 271 | private async _getAll(): Promise { 272 | const result = await this.storage.get(this.storageName); 273 | return this._decode(result[this.storageName] as UserOptions); 274 | } 275 | 276 | private async _setAll(newOptions: UserOptions): Promise { 277 | this._log('log', 'Saving options', newOptions); 278 | await this.storage.set({ 279 | [this.storageName]: this._encode(newOptions), 280 | }); 281 | } 282 | 283 | private _encode(options: UserOptions): string { 284 | const thinnedOptions: Partial = {...options}; 285 | for (const [key, value] of Object.entries(thinnedOptions)) { 286 | if (this.defaults[key] === value) { 287 | delete thinnedOptions[key]; 288 | } 289 | } 290 | 291 | this._log('log', 'Without the default values', thinnedOptions); 292 | 293 | return compressToEncodedURIComponent(JSON.stringify(thinnedOptions)); 294 | } 295 | 296 | private _decode(options: string | UserOptions): UserOptions { 297 | let decompressed = options; 298 | if (typeof options === 'string') { 299 | decompressed = JSON.parse(decompressFromEncodedURIComponent(options)) as UserOptions; 300 | } 301 | 302 | return {...this.defaults, ...decompressed as UserOptions}; 303 | } 304 | 305 | private async _runMigrations(migrations: Array>): Promise { 306 | if (migrations.length === 0 || !isBackground() || !await shouldRunMigrations()) { 307 | return; 308 | } 309 | 310 | const options = await this._getAll(); 311 | const initial = JSON.stringify(options); 312 | 313 | this._log('log', 'Found these stored options', {...options}); 314 | this._log('info', 'Will run', migrations.length, migrations.length === 1 ? 'migration' : ' migrations'); 315 | for (const migrate of migrations) { 316 | // eslint-disable-next-line no-await-in-loop -- Must be done in order 317 | await migrate(options, this.defaults); 318 | } 319 | 320 | // Only save to storage if there were any changes 321 | if (initial !== JSON.stringify(options)) { 322 | await this._setAll(options); 323 | } 324 | } 325 | 326 | // eslint-disable-next-line @typescript-eslint/member-ordering -- Needs to be near _handleFormSubmit 327 | private readonly _handleFormInput = debounce(300, async ({target}: Event): Promise => { 328 | const field = target as HTMLInputElement; 329 | if (!field.name) { 330 | return; 331 | } 332 | 333 | try { 334 | await this.set(this._parseForm(field.form!)); 335 | } catch (error) { 336 | field.dispatchEvent(new CustomEvent('options-sync:save-error', { 337 | bubbles: true, 338 | detail: error, 339 | })); 340 | throw error; 341 | } 342 | 343 | field.dispatchEvent(new CustomEvent('options-sync:save-success', { 344 | bubbles: true, 345 | })); 346 | 347 | // TODO: Deprecated; drop in next major 348 | field.form!.dispatchEvent(new CustomEvent('options-sync:form-synced', { 349 | bubbles: true, 350 | })); 351 | }); 352 | 353 | private _handleFormSubmit(event: Event): void { 354 | event.preventDefault(); 355 | } 356 | 357 | private _updateForm(form: HTMLFormElement, options: UserOptions): void { 358 | // Reduce changes to only values that have changed 359 | const currentFormState = this._parseForm(form); 360 | for (const [key, value] of Object.entries(options)) { 361 | if (currentFormState[key] === value) { 362 | delete options[key]; 363 | } 364 | } 365 | 366 | const include = Object.keys(options); 367 | if (include.length > 0) { 368 | // Limits `deserialize` to only the specified fields. Without it, it will try to set the every field, even if they're missing from the supplied `options` 369 | deserialize(form, options, {include}); 370 | } 371 | } 372 | 373 | // Parse form into object, except invalid fields 374 | private _parseForm(form: HTMLFormElement): Partial { 375 | const include: string[] = []; 376 | 377 | // Don't serialize disabled and invalid fields 378 | for (const field of form.querySelectorAll('[name]')) { 379 | if (field.validity.valid && !field.disabled) { 380 | include.push(field.name.replace(/\[.*]/, '')); 381 | } 382 | } 383 | 384 | return serialize(form, {include}); 385 | } 386 | 387 | private readonly _handleStorageChangeOnForm = (changes: Record, areaName: string): void => { 388 | if ( 389 | areaName === this.storageType 390 | && this.storageName in changes 391 | && (!document.hasFocus() || !this._form!.contains(document.activeElement)) // Avoid applying changes while the user is editing a field 392 | ) { 393 | this._updateForm(this._form!, this._decode(changes[this.storageName]!.newValue as string)); 394 | } 395 | }; 396 | } 397 | 398 | export default OptionsSync; 399 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Federico Brigante (https://fregante.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webext-options-sync", 3 | "version": "4.3.0", 4 | "description": "Helps you manage and autosave your extension's options.", 5 | "keywords": [ 6 | "browser", 7 | "chrome", 8 | "extension", 9 | "firefox", 10 | "options", 11 | "sync", 12 | "webext" 13 | ], 14 | "repository": "fregante/webext-options-sync", 15 | "funding": "https://github.com/sponsors/fregante", 16 | "license": "MIT", 17 | "author": "Federico Brigante (https://fregante.com)", 18 | "type": "module", 19 | "main": "index.js", 20 | "types": "./index.d.ts", 21 | "files": [ 22 | "index.js", 23 | "index.d.ts", 24 | "file.js", 25 | "file.d.ts" 26 | ], 27 | "scripts": { 28 | "build": "tsc", 29 | "prepack": "tsc", 30 | "test": "run-p build ava xo", 31 | "watch": "tsc --watch", 32 | "ava": "ava", 33 | "xo": "xo" 34 | }, 35 | "xo": { 36 | "envs": [ 37 | "browser", 38 | "webextensions" 39 | ], 40 | "rules": { 41 | "@typescript-eslint/no-unsafe-return": "off", 42 | "@typescript-eslint/no-dynamic-delete": "off", 43 | "@typescript-eslint/no-empty-function": "off" 44 | } 45 | }, 46 | "ava": { 47 | "require": [ 48 | "./test/_env.js" 49 | ] 50 | }, 51 | "dependencies": { 52 | "dom-form-serializer": "^2.0.0", 53 | "lz-string": "^1.5.0", 54 | "throttle-debounce": "^5.0.2", 55 | "uint8array-extras": "^1.2.0", 56 | "webext-detect": "^5.0.2", 57 | "webext-events": "^3.0.1", 58 | "webext-polyfill-kinda": "^1.0.2" 59 | }, 60 | "devDependencies": { 61 | "@sindresorhus/tsconfig": "^6.0.0", 62 | "@types/chrome": "0.0.268", 63 | "@types/lz-string": "^1.3.34", 64 | "@types/throttle-debounce": "^5.0.2", 65 | "@types/wicg-file-system-access": "^2023.10.5", 66 | "ava": "^6.1.3", 67 | "npm-run-all": "^4.1.5", 68 | "sinon": "^18.0.0", 69 | "sinon-chrome": "^3.0.1", 70 | "type-fest": "^4.20.1", 71 | "typescript": "^5.5.2", 72 | "xo": "^0.58.0" 73 | }, 74 | "engines": { 75 | "node": ">=18" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # webext-options-sync [![](https://img.shields.io/npm/v/webext-options-sync.svg)](https://www.npmjs.com/package/webext-options-sync) 2 | 3 | > Helps you manage and autosave your extension's options. 4 | 5 | Main features: 6 | 7 | - Define your default options 8 | - Add autoload and autosave to your options `` 9 | - Run migrations on update 10 | - Import/export helpers 11 | 12 | This also lets you very easily have [separate options for each domain](https://github.com/fregante/webext-options-sync-per-domain) with the help of `webext-options-sync-per-domain`. 13 | 14 | ## Install 15 | 16 | You can download the [standalone bundle](https://bundle.fregante.com/?pkg=webext-options-sync&global=OptionsSync) and include it in your `manifest.json`. 17 | 18 | Or use `npm`: 19 | 20 | ```sh 21 | npm install webext-options-sync 22 | ``` 23 | 24 | ```js 25 | import OptionsSync from 'webext-options-sync'; 26 | ``` 27 | 28 | The [browser-extension-template](https://github.com/notlmn/browser-extension-template) repo includes a complete setup with ES Modules, based on the advanced usage below. 29 | 30 | ## Usage 31 | 32 | This module requires the `storage` permission in `manifest.json`: 33 | 34 | ```json 35 | { 36 | "name": "My Cool Extension", 37 | "permissions": [ 38 | "storage" 39 | ] 40 | } 41 | ``` 42 | 43 | ### Simple usage 44 | 45 | You can set and get your options from any context (background, content script, etc): 46 | 47 | ```js 48 | /* global OptionsSync */ 49 | const optionsStorage = new OptionsSync(); 50 | 51 | await optionsStorage.set({showStars: 10}); 52 | 53 | const options = await optionsStorage.getAll(); 54 | // {showStars: 10} 55 | ``` 56 | 57 | **Note:** `OptionsSync` relies on `chrome.storage.sync`, so its [limitations](https://developer.chrome.com/docs/extensions/reference/api/storage?hl=en#property-sync) apply, both the size limit and the type of data stored (which must be compatible with JSON). 58 | 59 | ### Advanced usage 60 | 61 | It's suggested to create an `options-storage.js` file with your defaults and possible migrations, and import it where needed: 62 | 63 | ```js 64 | /* global OptionsSync */ 65 | window.optionsStorage = new OptionsSync({ 66 | defaults: { 67 | colorString: 'green', 68 | anyBooleans: true, 69 | numbersAreFine: 9001 70 | }, 71 | 72 | // List of functions that are called when the extension is updated 73 | migrations: [ 74 | (savedOptions, currentDefaults) => { 75 | // Perhaps it was renamed 76 | if (savedOptions.colour) { 77 | savedOptions.color = savedOptions.colour; 78 | delete savedOptions.colour; 79 | } 80 | }, 81 | 82 | // Integrated utility that drops any properties that don't appear in the defaults 83 | OptionsSync.migrations.removeUnused 84 | ] 85 | }); 86 | ``` 87 | 88 | Include this file as a background script: it's where the `defaults` are set for the first time and where the `migrations` are run. This example also includes it in the content script, if you need it there: 89 | 90 | ```json 91 | { 92 | "background": { 93 | "scripts": [ 94 | "webext-options-sync.js", 95 | "options-storage.js", 96 | "background.js" 97 | ] 98 | }, 99 | "content_scripts": [ 100 | { 101 | "matches": [ 102 | "https://www.google.com/*", 103 | ], 104 | "js": [ 105 | "webext-options-sync.js", 106 | "options-storage.js", 107 | "content.js" 108 | ] 109 | } 110 | ] 111 | } 112 | ``` 113 | 114 | Then you can use it this way from the `background` or `content.js`: 115 | 116 | ```js 117 | /* global optionsStorage */ 118 | async function init () { 119 | const {colorString} = await optionsStorage.getAll(); 120 | document.body.style.background = colorString; 121 | } 122 | 123 | init(); 124 | ``` 125 | 126 | And also enable autosaving in your options page: 127 | 128 | ```html 129 | 130 | 131 |
132 |
133 |
134 | 135 | 136 | 137 | 138 | 139 | ``` 140 | 141 | ```js 142 | // Your options.js file 143 | /* global optionsStorage */ 144 | 145 | optionsStorage.syncForm(document.querySelector('form')); 146 | ``` 147 | 148 | ### Form autosave and autoload 149 | 150 | When using the `syncForm` method, `OptionsSync` will serialize the form using [dom-form-serializer](https://github.com/jefersondaniel/dom-form-serializer), which uses the `name` attribute as `key` for your options. Refer to its readme for more info on the structure of the data. 151 | 152 | Any user changes to the form are automatically saved into `chrome.storage.sync` after 300ms (debounced). It listens to `input` events. 153 | 154 | #### Input validation 155 | 156 | If your form fields have any [validation attributes](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation#Validation-related_attributes) they will not be saved until they become valid. 157 | 158 |
159 | 160 | Since autosave and validation is silent, you should inform the user of invalid fields, possibly via CSS by using the `:invalid` selector: 161 | 162 | ```css 163 | /* Style the element */ 164 | input:invalid { 165 | color: red; 166 | border: 1px solid red; 167 | } 168 | 169 | /* Or display a custom error message */ 170 | input:invalid ~ .error-message { 171 | display: block; 172 | } 173 | ``` 174 | 175 |
176 | 177 | ## API 178 | 179 | #### const optionsStorage = new OptionsSync(setup?) 180 | 181 | Returns an instance linked to the chosen storage. It will also run any migrations if it's called in the background. 182 | 183 | ##### setup 184 | 185 | Type: `object` 186 | 187 | Optional. It should follow this format: 188 | 189 | ```js 190 | { 191 | defaults: { // recommended 192 | color: 'blue' 193 | }, 194 | migrations: [ // optional 195 | savedOptions => { 196 | if(savedOptions.oldStuff) { 197 | delete savedOptions.oldStuff 198 | } 199 | } 200 | ], 201 | } 202 | ``` 203 | 204 | ###### defaults 205 | 206 | Type: `object` 207 | 208 | A map of default options as strings or booleans. The keys will have to match the options form fields' `name` attributes. 209 | 210 | ###### migrations 211 | 212 | Type: `array` 213 | 214 | A list of functions to run in the `background` when the extension is updated. Example: 215 | 216 | ```js 217 | { 218 | migrations: [ 219 | (options, defaults) => { 220 | // Change the `options` 221 | if(options.oldStuff) { 222 | delete options.oldStuff 223 | } 224 | 225 | // No return needed 226 | }, 227 | 228 | // Integrated utility that drops any properties that don't appear in the defaults 229 | OptionsSync.migrations.removeUnused 230 | ], 231 | } 232 | ``` 233 | 234 | > [!NOTE] 235 | > The `options` argument will always include the `defaults` as well as any options that were saved before the update. 236 | 237 | ###### storageName 238 | 239 | Type: `string` 240 | Default: `'options'` 241 | 242 | The key used to store data in `chrome.storage.sync` 243 | 244 | ###### logging 245 | 246 | Type: `boolean` 247 | Default: `true` 248 | 249 | Whether info and warnings (on sync, updating form, etc.) should be logged to the console or not. 250 | 251 | ###### storageType 252 | 253 | Type: `'local' | 'sync'` 254 | Default: `sync` 255 | 256 | What storage area type to use (sync storage vs local storage). Sync storage is used by default. 257 | 258 | **Considerations for selecting which option to use:** 259 | 260 | - Sync is default as it's likely more convenient for users. 261 | - Firefox requires [`browser_specific_settings.gecko.id`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings) for the `sync` storage to work locally. 262 | - Sync storage is subject to much tighter [quota limitations](https://developer.chrome.com/docs/extensions/reference/storage/#property-sync), and may cause privacy concerns if the data being stored is confidential. 263 | 264 | #### optionsStorage.set(options) 265 | 266 | This will merge the existing options with the object provided. 267 | 268 | **Note:** Any values specified in `default` are not saved into the storage, to save space, but they will still appear when using `getAll`. You just have to make sure to always specify the same `defaults` object in every context (this happens automatically in the [Advanced usage](#advanced-usage) above.) 269 | 270 | ##### options 271 | 272 | Type: `object` 273 | Default: `{}` 274 | Example: `{color: red}` 275 | 276 | A map of default options as strings, booleans, numbers and anything accepted by [dom-form-serializer](https://github.com/jefersondaniel/dom-form-serializer)’s `deserialize` function. 277 | 278 | #### optionsStorage.setAll(options) 279 | 280 | This will override **all** the options stored with your `options`. 281 | 282 | #### optionsStorage.getAll() 283 | 284 | This returns a Promise that will resolve with all the options. 285 | 286 | #### optionsStorage.onChanged(callback, signal?) 287 | 288 | Listens to changes in the storage and calls the callback when the options are changed. The callback is called with the new and old options. 289 | 290 | ##### callback 291 | 292 | Type: `(newOptions: object, oldOptions: object) => void` 293 | 294 | ##### signal 295 | 296 | Type: `AbortSignal` 297 | 298 | If provided, the callback will be removed when the signal is aborted. 299 | 300 | #### optionsStorage.syncForm(form) 301 | 302 | Any defaults or saved options will be loaded into the `
` and any change will automatically be saved via `chrome.storage.sync`. It also looks for any buttons with `js-import` or `js-export` classes that when clicked will allow the user to export and import the options to a JSON file. 303 | 304 | - `options-sync:save-success`: Fired on the edited field when the form is saved. 305 | - `options-sync:save-error`: Fired on the edited field when the form is not saved due to an error. The error is passed as the `detail` property. 306 | 307 | Saving can fail when the storage quota is exceeded for example. You should handle this case and display a message to the user. 308 | 309 | ##### form 310 | 311 | Type: `HTMLFormElement`, `string` 312 | 313 | It's the `` that needs to be synchronized or a CSS selector (one element). The form fields' `name` attributes will have to match the option names. 314 | 315 | #### optionsStorage.stopSyncForm() 316 | 317 | Removes any listeners added by `syncForm`. 318 | 319 | #### optionsStorage.exportToFile() 320 | 321 | Opens the browser’s "save file" dialog to export options to a JSON file. If your form has a `.js-export` element, this listener will be attached automatically. 322 | 323 | #### optionsStorage.importFromFile() 324 | 325 | Opens the browser’s file picker to import options from a previously-saved JSON file. If your form has a `.js-import` element, this listener will be attached automatically. 326 | 327 | ## Related 328 | 329 | - [webext-options-sync-per-domain](https://github.com/fregante/webext-options-sync-per-domain) - Wrapper for `webext-options-sync` to have different options for each domain your extension supports. 330 | - [webext-storage-cache](https://github.com/fregante/webext-storage-cache) - Map-like promised cache storage with expiration. 331 | - [webext-dynamic-content-scripts](https://github.com/fregante/webext-dynamic-content-scripts) - Automatically registers your content_scripts on domains added via permission.request. 332 | - [Awesome-WebExtensions](https://github.com/fregante/Awesome-WebExtensions) - A curated list of awesome resources for WebExtensions development. 333 | - [More…](https://github.com/fregante/webext-fun) 334 | -------------------------------------------------------------------------------- /test/_env.js: -------------------------------------------------------------------------------- 1 | import chrome from 'sinon-chrome'; 2 | 3 | global.location = { 4 | origin: 'chrome://abc', 5 | pathname: '/_generated_background_page.html', 6 | }; 7 | global.chrome = chrome; 8 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import lzString from 'lz-string'; 3 | import OptionsSync from '../index.js'; 4 | 5 | OptionsSync.prototype._log = () => {}; 6 | 7 | function flattenInstance(setup) { 8 | return JSON.parse(JSON.stringify(setup)); 9 | } 10 | 11 | function compressOptions(options) { 12 | return lzString.compressToEncodedURIComponent(JSON.stringify(options)); 13 | } 14 | 15 | const defaultSetup = { 16 | _migrations: {}, 17 | defaults: {}, 18 | storageName: 'options', 19 | storageType: 'sync', 20 | }; 21 | 22 | const simpleSetup = { 23 | _migrations: {}, 24 | defaults: { 25 | color: 'red', 26 | sound: true, 27 | }, 28 | storageName: 'settings', 29 | storageType: 'sync', 30 | }; 31 | 32 | test.beforeEach(() => { 33 | chrome.flush(); 34 | chrome.runtime.getManifest.returns({ 35 | version: 2, 36 | background: {scripts: 'background.js'}, 37 | }); 38 | chrome.storage.sync.set.yields(undefined); 39 | chrome.management.getSelf.yields({ 40 | installType: 'development', 41 | }); 42 | }); 43 | 44 | test.serial('basic usage', t => { 45 | t.deepEqual(flattenInstance(new OptionsSync()), defaultSetup); 46 | t.deepEqual(flattenInstance(new OptionsSync(simpleSetup)), simpleSetup); 47 | }); 48 | 49 | test.serial('getAll returns empty object when storage is empty', async t => { 50 | chrome.storage.sync.get 51 | .withArgs('options') 52 | .yields({}); 53 | 54 | const storage = new OptionsSync(); 55 | t.deepEqual(await storage.getAll(), {}); 56 | }); 57 | 58 | test.serial('getAll returns defaults when storage is empty', async t => { 59 | chrome.storage.sync.get 60 | .withArgs('settings') 61 | .yields({}); 62 | 63 | const storage = new OptionsSync(simpleSetup); 64 | t.deepEqual(await storage.getAll(), simpleSetup.defaults); 65 | }); 66 | 67 | test.serial('getAll returns saved options', async t => { 68 | const previouslySavedOptions = { 69 | color: 'fucsia', 70 | people: 3, 71 | }; 72 | 73 | chrome.storage.sync.get 74 | .withArgs('options') 75 | .yields({options: compressOptions(previouslySavedOptions)}); 76 | 77 | const storage = new OptionsSync(); 78 | t.deepEqual(await storage.getAll(), previouslySavedOptions); 79 | }); 80 | 81 | test.serial('getAll returns saved legacy options', async t => { 82 | const previouslySavedOptions = { 83 | color: 'fucsia', 84 | people: 3, 85 | }; 86 | 87 | chrome.storage.sync.get 88 | .withArgs('options') 89 | .yields({options: previouslySavedOptions}); 90 | 91 | const storage = new OptionsSync(); 92 | t.deepEqual(await storage.getAll(), previouslySavedOptions); 93 | }); 94 | 95 | test.serial('getAll merges saved options with defaults', async t => { 96 | const previouslySavedOptions = { 97 | color: 'fucsia', 98 | people: 3, 99 | }; 100 | 101 | chrome.storage.sync.get 102 | .withArgs('settings') 103 | .yields({settings: compressOptions(previouslySavedOptions)}); 104 | 105 | const storage = new OptionsSync(simpleSetup); 106 | t.deepEqual(await storage.getAll(), { 107 | color: 'fucsia', 108 | people: 3, 109 | sound: true, 110 | }); 111 | }); 112 | 113 | test.serial('setAll', async t => { 114 | const newOptions = { 115 | name: 'Rico', 116 | people: 3, 117 | }; 118 | 119 | const storage = new OptionsSync(); 120 | await storage.setAll(newOptions); 121 | t.true(chrome.storage.sync.set.calledOnce); 122 | t.deepEqual(chrome.storage.sync.set.firstCall.args[0], { 123 | options: compressOptions(newOptions), 124 | }); 125 | }); 126 | 127 | test.serial('setAll skips defaults', async t => { 128 | const newOptions = { 129 | name: 'Rico', 130 | people: 3, 131 | }; 132 | 133 | const storage = new OptionsSync(simpleSetup); 134 | await storage.setAll({...newOptions, sound: true}); 135 | t.true(chrome.storage.sync.set.calledOnce); 136 | t.deepEqual(chrome.storage.sync.set.firstCall.args[0], { 137 | settings: compressOptions(newOptions), 138 | }); 139 | }); 140 | 141 | test.serial('set merges with existing data', async t => { 142 | chrome.storage.sync.get 143 | .withArgs('options') 144 | .yields({options: {size: 30}}); 145 | 146 | const storage = new OptionsSync(); 147 | await storage.set({sound: false}); 148 | t.is(chrome.storage.sync.set.callCount, 1); 149 | t.deepEqual(chrome.storage.sync.set.firstCall.args[0], { 150 | options: compressOptions({ 151 | size: 30, 152 | sound: false, 153 | }), 154 | }); 155 | }); 156 | 157 | test.serial('migrations alter the stored options', async t => { 158 | chrome.storage.sync.get 159 | .withArgs('options') 160 | .yields({options: {size: 30}}); 161 | 162 | const storage = new OptionsSync({ 163 | migrations: [ 164 | async savedOptions => { 165 | await new Promise(resolve => { 166 | setTimeout(resolve, 100); 167 | }); 168 | savedOptions.size += 10; 169 | }, 170 | savedOptions => { 171 | if (savedOptions.size !== undefined) { 172 | savedOptions.minSize = savedOptions.size; 173 | delete savedOptions.size; 174 | } 175 | }, 176 | ], 177 | }); 178 | 179 | await storage._migrations; 180 | 181 | t.is(chrome.storage.sync.set.callCount, 1); 182 | t.deepEqual(chrome.storage.sync.set.firstCall.args[0], { 183 | options: compressOptions({ 184 | minSize: 40, 185 | }), 186 | }); 187 | }); 188 | 189 | test.serial('migrations shouldn’t trigger updates if they don’t change anything', async t => { 190 | chrome.storage.sync.get 191 | .withArgs('options') 192 | .yields({}); 193 | 194 | const storage = new OptionsSync({ 195 | migrations: [ 196 | () => {}, 197 | ], 198 | }); 199 | 200 | await storage._migrations; 201 | 202 | t.true(chrome.storage.sync.set.notCalled); 203 | }); 204 | 205 | test.serial('migrations are completed before future get/set operations', async t => { 206 | chrome.storage.sync.get 207 | .withArgs('options') 208 | .yields({}); 209 | 210 | const storage = new OptionsSync({ 211 | migrations: [ 212 | savedOptions => { 213 | savedOptions.foo = 'bar'; 214 | chrome.storage.sync.get 215 | .withArgs('options') 216 | .yields({ 217 | options: { 218 | foo: 'bar', 219 | }, 220 | }); 221 | }, 222 | ], 223 | }); 224 | 225 | t.deepEqual(await storage.getAll(), { 226 | foo: 'bar', 227 | }); 228 | }); 229 | 230 | test.serial('removeUnused migration works', async t => { 231 | chrome.storage.sync.get 232 | .withArgs('settings') 233 | .yields({ 234 | settings: { 235 | size: 30, // Unused 236 | sound: false, // Non-default 237 | }, 238 | }); 239 | 240 | const storage = new OptionsSync(simpleSetup); 241 | await storage._runMigrations([ 242 | OptionsSync.migrations.removeUnused, 243 | ]); 244 | 245 | t.is(chrome.storage.sync.set.callCount, 1); 246 | t.deepEqual(chrome.storage.sync.set.firstCall.args[0], { 247 | settings: compressOptions({ 248 | sound: false, 249 | }), 250 | }); 251 | }); 252 | 253 | test.todo('form syncing'); 254 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "target": "es2022", 5 | "outDir": "." 6 | }, 7 | "files": [ 8 | "globals.d.ts", 9 | "index.ts" 10 | ] 11 | } 12 | --------------------------------------------------------------------------------