├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── release-tag.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.ts ├── src ├── conf.ts ├── global.ts ├── main.ts ├── preload.ts ├── renderer.ts ├── types.ts └── utils.ts ├── test ├── basic.test.ts ├── electron.test.ts └── electron │ ├── index.html │ ├── main.mjs │ ├── preload.mjs │ ├── renderer.mjs │ └── test.mjs └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | commonjs: true, 6 | es6: true, 7 | node: true 8 | }, 9 | parser: '@typescript-eslint/parser', 10 | parserOptions: { 11 | sourceType: 'module', 12 | ecmaVersion: 2022 13 | }, 14 | plugins: ['@typescript-eslint'], 15 | extends: [ 16 | 'eslint:recommended', 17 | 'plugin:@typescript-eslint/recommended', 18 | 'plugin:@typescript-eslint/eslint-recommended', 19 | 'plugin:prettier/recommended' 20 | ], 21 | rules: { 22 | 'prettier/prettier': 'warn', 23 | '@typescript-eslint/ban-ts-comment': [ 24 | 'error', 25 | { 'ts-ignore': 'allow-with-description' } 26 | ], 27 | '@typescript-eslint/explicit-function-return-type': 'error', 28 | '@typescript-eslint/explicit-module-boundary-types': 'off', 29 | '@typescript-eslint/no-empty-function': [ 30 | 'error', 31 | { allow: ['arrowFunctions'] } 32 | ], 33 | '@typescript-eslint/no-explicit-any': 'off', 34 | '@typescript-eslint/no-non-null-assertion': 'off', 35 | '@typescript-eslint/no-var-requires': 'off' 36 | }, 37 | overrides: [ 38 | { 39 | files: ['*.js', '*.mjs'], 40 | rules: { 41 | '@typescript-eslint/explicit-function-return-type': 'off' 42 | } 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/release-tag.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 5 | 6 | name: Create Release 7 | 8 | jobs: 9 | build: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@master 15 | - name: Create Release for Tag 16 | id: release_tag 17 | uses: actions/create-release@v1 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | tag_name: ${{ github.ref }} 22 | release_name: ${{ github.ref }} 23 | body: | 24 | Please refer to [CHANGELOG.md](https://github.com/alex8088/electron-conf/blob/${{ github.ref_name }}/CHANGELOG.md) for details. 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | tmp 4 | .env* 5 | *.log* 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 80 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[json]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v1.3.0 (_2025-03-16_) 2 | 3 | - fix: support new registering preload script menthod of Electron 35 4 | - perf: improve resolve import meta url 5 | - chore(deps): update electron to v35 6 | 7 | ### v1.2.1 (_2024-08-21_) 8 | 9 | - fix: getting with default value in renderer doesn't work 10 | 11 | ### v1.2.0 (_2024-08-11_) 12 | 13 | - fix: support multiple instances with the same name and different paths 14 | 15 | ### v1.1.0 (_2024-05-22_) 16 | 17 | - fix: support commonjs export 18 | - fix: support subpath export 19 | 20 | ### v1.0.0 (_2024-05-20_) 21 | 22 | - chore: Simple data persistence for your Electron app 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-present, Alex.Wei 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # electron-conf 2 | 3 | > Simple data persistence for your Electron app - save and load user settings, app state, cache, etc 4 | 5 | > Another electron-store, minimal fork of conf, with more features. 6 | 7 | electron-conf is a fork of [conf](https://github.com/sindresorhus/conf) (behind [electron-store](https://github.com/sindresorhus/electron-store)). What we try to achieve in this library, is to eliminate some dependencies and features that our target users don't need, and is designed only for Electron. 8 | 9 | - ✅ Minimal and simple 10 | - ✅ Read data form disk once, ~100x faster 11 | - ✅ Simpler migration strategy 12 | - ✅ Safer to use it in Electron renderer (no nodeIntegration) 13 | - ✅ Written in TypeScript, and support CommonJS and ESM. For Electron 15.x and higher. 14 | - ❌ No watch 15 | - ❌ No encryption 16 | 17 | _If you need features like watch or encryption, electron-store is a better choice for you._ 18 | 19 | ## Install 20 | 21 | ```sh 22 | $ npm install electron-conf 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### Using in Electron Main Process 28 | 29 | ```ts 30 | import { Conf } from 'electron-conf/main' 31 | 32 | const conf = new Conf() 33 | 34 | conf.set('foo', '🌈') 35 | console.log(conf.get('foo')) // => 🌈 36 | 37 | // Use dot-notation to access nested properties 38 | conf.set('a.b', true) 39 | console.log(conf.get('a')) // => {b: true} 40 | 41 | conf.delete('foo') 42 | console.log(conf.get('foo')) // => undefined 43 | ``` 44 | 45 | ### Using in Electron Renderer Process 46 | 47 | 1. Register a listener in main process, so that you can use it in the renderer process. 48 | 49 | ```ts 50 | import { Conf } from 'electron-conf/main' 51 | 52 | const conf = new Conf() 53 | 54 | conf.registerRendererListener() 55 | ``` 56 | 57 | 2. Expose the `Conf` API. 58 | 59 | You can expose it in the specified preload script: 60 | 61 | ```ts 62 | import { exposeConf } from 'electron-conf/preload' 63 | 64 | exposeConf() 65 | ``` 66 | 67 | Or, you can expose it globally in the main process for all renderer processes: 68 | 69 | ```ts 70 | import { useConf } from 'electron-conf/main' 71 | 72 | useConf() 73 | ``` 74 | 75 | 3. Use it in the renderer process 76 | 77 | ```ts 78 | import { Conf } from 'electron-conf/renderer' 79 | 80 | const conf = new Conf() 81 | 82 | await conf.set('foo', 1) 83 | ``` 84 | 85 | > [!NOTE] 86 | > Use the same way as the main process. The difference is that all APIs are promise-based. 87 | 88 | ## API 89 | 90 | ### Conf([options]) 91 | 92 | return a new instance. 93 | 94 | > [!WARNING] 95 | > It does not support multiple instances reading and writing the same configuration file. 96 | 97 | ### Constructor Options 98 | 99 | > [!NOTE] 100 | > `Conf` for the renderer process, only supports the `name` option. 101 | 102 | #### `dir` 103 | 104 | - Type: `string` 105 | - Default: [`app.getPath('userData')`](https://www.electronjs.org/docs/latest/api/app#appgetpathname) 106 | 107 | The directory for storing your app's configuration file. 108 | 109 | #### `name` 110 | 111 | - Type: `string` 112 | - Default: `config` 113 | 114 | Configuration file name without extension. 115 | 116 | #### `ext` 117 | 118 | - Type: `string` 119 | - Default: `.json` 120 | 121 | Configuration file extension. 122 | 123 | #### `defaults` 124 | 125 | - Type: `object` 126 | 127 | Default config used if there are no existing config. 128 | 129 | #### `serializer` 130 | 131 | - Type: [`Serializer`](./src/types.ts) 132 | 133 | Provides functionality to serialize object types to UTF-8 strings and to deserialize UTF-8 strings into object types. 134 | 135 | By default, `JSON.stringify` is used for serialization and `JSON.parse` is used for deserialization. 136 | 137 | You would usually not need this, but it could be useful if you want to use a format other than JSON. 138 | 139 |
140 | Type Signature 141 |

142 | 143 | ```ts 144 | interface Serializer { 145 | /** 146 | * Deserialize the config object from a UTF-8 string when reading the config file. 147 | * @param raw UTF-8 encoded string. 148 | */ 149 | read: (raw: string) => T 150 | /** 151 | * Serialize the config object to a UTF-8 string when writing the config file. 152 | * @param value The config object. 153 | */ 154 | write: (value: T) => string 155 | } 156 | ``` 157 | 158 |
159 | 160 | #### `schema` 161 | 162 | - Type: [JSONSchema](https://json-schema.org/understanding-json-schema/reference/object#properties) 163 | 164 | [JSON Schema](https://json-schema.org) to validate your config data. 165 | 166 | Under the hood, we use the [ajv](https://ajv.js.org/) JSON Schema validator to validate config data. 167 | 168 | You should define your schema as an object where each key is the name of your data's property and each value is a JSON schema used to validate that property. 169 | 170 | ```ts 171 | import { Conf } from 'electron-conf/main' 172 | 173 | const schema = { 174 | type: 'object', 175 | properties: { 176 | foo: { 177 | type: 'string', 178 | maxLength: 10, 179 | nullable: true 180 | } 181 | } 182 | } 183 | 184 | const conf = new Conf({ schema }) 185 | ``` 186 | 187 | #### `migrations` 188 | 189 | - type: [`Migration[]`](./src/types.ts) 190 | 191 | You can customize versions and perform operations to migrate configurations. When instantiated, it will be compared with the version number of the configuration file and a higher version migration operation will be performed. 192 | 193 | **Note:** The migration version must be greater than `0`. A new version is defined on each migration and is incremented on the previous version. 194 | 195 | ```ts 196 | import { Conf } from 'electron-conf/main' 197 | 198 | const migrations = [ 199 | { 200 | version: 1, 201 | hook: (conf, version): void => { 202 | conf.set('foo', 'a') 203 | console.log(`migrate from ${version} to 1`) // migrate from 0 to 1 204 | } 205 | }, 206 | { 207 | version: 2, 208 | hook: (conf, version): void => { 209 | conf.set('foo', 'b') 210 | console.log(`migrate from ${version} to 2`) // migrate from 1 to 2 211 | } 212 | } 213 | ] 214 | 215 | const conf = new Conf({ migrations }) 216 | ``` 217 | 218 |
219 | Type Signature 220 |

221 | 222 | ```ts 223 | type Migration> = { 224 | /** 225 | * Migration version. The initial version must be greater than `0`. A new 226 | * version is defined on each migration and is incremented on the previous version. 227 | */ 228 | version: number 229 | /** 230 | * Migration hook. You can perform operations to update your configuration. 231 | * @param instance config instance. 232 | * @param currentVersion current version. 233 | */ 234 | hook: (instance: BaseConf, currentVersion: number) => void 235 | } 236 | ``` 237 | 238 |
239 |

240 | 241 | ### Instance Methods 242 | 243 | You can use [dot-notation](https://github.com/sindresorhus/dot-prop) in a key to access nested properties. 244 | 245 | The instance is [`iterable`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Iteration_protocols) so you can use it directly in a [`for…of`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Statements/for...of) loop. 246 | 247 | > [!NOTE] 248 | > All methods in renderer are promise-based. 249 | 250 | #### `.get(key, defaultValue?)` 251 | 252 | Get an item or defaultValue if the item does not exist. 253 | 254 | #### `.set(key, value)` 255 | 256 | Set an item. 257 | 258 | #### `.set(object)` 259 | 260 | Set an item or multiple items at once. 261 | 262 | ```js 263 | conf.set({ foo: 'boo', bar: { baz: 1 } }) 264 | ``` 265 | 266 | #### `.reset(...keys)` 267 | 268 | Reset items to their default values, as defined by the defaults or schema option. 269 | 270 | #### `.has(key)` 271 | 272 | Check if an item exists. 273 | 274 | #### `.delete(key)` 275 | 276 | Delete an item. 277 | 278 | #### `.clear()` 279 | 280 | Delete all items. 281 | 282 | #### `.onDidChange(key, callback)` 283 | 284 | - `callback`: `(newValue, oldValue) => {}` 285 | 286 | Watches the given `key`, calling `callback` on any changes. 287 | 288 | When a key is first set `oldValue` will be `undefined`, and when a key is deleted `newValue` will be `undefined`. 289 | 290 | Returns a function which you can use to unsubscribe: 291 | 292 | ```js 293 | const unsubscribe = conf.onDidChange(key, callback) 294 | 295 | unsubscribe() 296 | ``` 297 | 298 | > [!TIP] 299 | > Not available in renderer 300 | 301 | #### `.onDidAnyChange(callback)` 302 | 303 | - `callback`: `(newValue, oldValue) => {}` 304 | 305 | Watches the whole config object, calling `callback` on any changes. 306 | 307 | `oldValue` and `newValue` will be the config object before and after the change, respectively. You must compare `oldValue` to `newValue `to find out what changed. 308 | 309 | Returns a function which you can use to unsubscribe: 310 | 311 | ```js 312 | const unsubscribe = store.onDidAnyChange(callback) 313 | 314 | unsubscribe() 315 | ``` 316 | 317 | > [!TIP] 318 | > Not available in renderer 319 | 320 | #### `.fileName` 321 | 322 | Get the configuration file path. 323 | 324 | > [!TIP] 325 | > Not available in renderer 326 | 327 | ## Credits 328 | 329 | [Conf](https://github.com/sindresorhus/conf), simple config handling for your app or module. 330 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-conf", 3 | "version": "1.3.0", 4 | "description": "Simple data persistence for your Electron app - save and load user settings, app state, cache, etc", 5 | "main": "./dist/main.cjs", 6 | "module": "./dist/main.mjs", 7 | "types": "./dist/main.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/main.d.ts", 11 | "import": "./dist/main.mjs", 12 | "require": "./dist/main.cjs" 13 | }, 14 | "./main": { 15 | "types": "./dist/main.d.ts", 16 | "import": "./dist/main.mjs", 17 | "require": "./dist/main.cjs" 18 | }, 19 | "./preload": { 20 | "types": "./dist/preload.d.ts", 21 | "import": "./dist/preload.mjs", 22 | "require": "./dist/preload.cjs" 23 | }, 24 | "./renderer": { 25 | "types": "./dist/renderer.d.ts", 26 | "import": "./dist/renderer.mjs", 27 | "require": "./dist/renderer.cjs" 28 | } 29 | }, 30 | "typesVersions": { 31 | "*": { 32 | "main": [ 33 | "./dist/main.d.ts" 34 | ], 35 | "preload": [ 36 | "./dist/preload.d.ts" 37 | ], 38 | "renderer": [ 39 | "./dist/renderer.d.ts" 40 | ] 41 | } 42 | }, 43 | "author": "Alex Wei ", 44 | "license": "MIT", 45 | "homepage": "https://github.com/alex8088/electron-conf#readme", 46 | "repository": { 47 | "type": "git", 48 | "url": "git+https://github.com/alex8088/electron-conf.git" 49 | }, 50 | "bugs": { 51 | "url": "https://github.com/alex8088/electron-conf/issues" 52 | }, 53 | "files": [ 54 | "dist" 55 | ], 56 | "keywords": [ 57 | "config", 58 | "settings", 59 | "store", 60 | "storage", 61 | "electron" 62 | ], 63 | "scripts": { 64 | "format": "prettier --write .", 65 | "lint": "eslint --ext .js,.cjs,.mjs,.ts,.cts,.mts src/**", 66 | "typecheck": "tsc --noEmit", 67 | "build": "npm run lint && rollup -c rollup.config.ts --configPlugin typescript", 68 | "test": "vitest run" 69 | }, 70 | "peerDependencies": { 71 | "electron": ">=15.0.0" 72 | }, 73 | "dependencies": { 74 | "ajv": "^8.13.0" 75 | }, 76 | "devDependencies": { 77 | "@rollup/plugin-commonjs": "^25.0.7", 78 | "@rollup/plugin-node-resolve": "^15.2.3", 79 | "@rollup/plugin-typescript": "^11.1.6", 80 | "@types/node": "^18.19.33", 81 | "@typescript-eslint/eslint-plugin": "^7.9.0", 82 | "@typescript-eslint/parser": "^7.9.0", 83 | "atomically": "^2.0.3", 84 | "dot-prop": "^9.0.0", 85 | "eslint": "^8.57.0", 86 | "eslint-config-prettier": "^9.1.0", 87 | "eslint-plugin-prettier": "^5.1.3", 88 | "prettier": "^3.2.5", 89 | "rollup": "^4.17.2", 90 | "rollup-plugin-dts": "^6.1.0", 91 | "rollup-plugin-rm": "^1.0.2", 92 | "typescript": "^5.4.5", 93 | "vitest": "^1.6.0" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | import { createRequire } from 'node:module' 3 | import { defineConfig } from 'rollup' 4 | import resolve from '@rollup/plugin-node-resolve' 5 | import commonjs from '@rollup/plugin-commonjs' 6 | import ts from '@rollup/plugin-typescript' 7 | import dts from 'rollup-plugin-dts' 8 | import rm from 'rollup-plugin-rm' 9 | 10 | const require = createRequire(import.meta.url) 11 | const pkg = require('./package.json') 12 | 13 | const external = [ 14 | ...Object.keys(pkg.dependencies || {}), 15 | ...Object.keys(pkg.peerDependencies || {}) 16 | ] 17 | 18 | export default defineConfig([ 19 | { 20 | input: ['src/main.ts', 'src/preload.ts', 'src/renderer.ts'], 21 | output: [ 22 | { 23 | entryFileNames: '[name].cjs', 24 | chunkFileNames: 'chunks/lib-[hash].cjs', 25 | format: 'cjs', 26 | dir: 'dist' 27 | }, 28 | { 29 | entryFileNames: '[name].mjs', 30 | chunkFileNames: 'chunks/lib-[hash].mjs', 31 | format: 'es', 32 | dir: 'dist' 33 | } 34 | ], 35 | external, 36 | plugins: [ 37 | rm('dist', 'buildStart'), 38 | resolve({ exportConditions: ['node', 'default', 'module', 'import'] }), 39 | commonjs(), 40 | ts({ 41 | compilerOptions: { 42 | rootDir: 'src', 43 | declaration: true, 44 | outDir: 'dist/types' 45 | } 46 | }), 47 | { 48 | name: 'import-meta-url', 49 | resolveImportMeta(property, { format }) { 50 | if (property === 'url' && format === 'cjs') { 51 | return `require("url").pathToFileURL(__filename).href` 52 | } 53 | return null 54 | } 55 | } 56 | ] 57 | }, 58 | { 59 | input: ['src/global.ts'], 60 | output: [ 61 | { 62 | entryFileNames: 'electron-conf-preload.cjs', 63 | format: 'cjs', 64 | dir: 'dist' 65 | }, 66 | { 67 | entryFileNames: 'electron-conf-preload.mjs', 68 | format: 'es', 69 | dir: 'dist' 70 | } 71 | ], 72 | external, 73 | plugins: [resolve(), commonjs(), ts()] 74 | }, 75 | { 76 | input: ['dist/types/main.d.ts'], 77 | output: [{ file: './dist/main.d.ts', format: 'es' }], 78 | plugins: [dts()] 79 | }, 80 | { 81 | input: ['dist/types/preload.d.ts'], 82 | output: [{ file: './dist/preload.d.ts', format: 'es' }], 83 | plugins: [dts()] 84 | }, 85 | { 86 | input: ['dist/types/renderer.d.ts'], 87 | output: [{ file: './dist/renderer.d.ts', format: 'es' }], 88 | plugins: [dts(), rm('dist/types', 'buildEnd')] 89 | } 90 | ]) 91 | -------------------------------------------------------------------------------- /src/conf.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The core code was conceived by sindresorhus and is taken from the following repository: 3 | * https://github.com/sindresorhus/conf/blob/main/source/index.ts 4 | * license: https://github.com/sindresorhus/conf/blob/main/license 5 | */ 6 | 7 | import path from 'node:path' 8 | import fs from 'node:fs' 9 | import Ajv from 'ajv' 10 | import { writeFileSync as atomicWriteFileSync } from 'atomically' 11 | import { getProperty, hasProperty, setProperty, deleteProperty } from 'dot-prop' 12 | 13 | import { 14 | deepEqual, 15 | createPlainObject, 16 | cloneObject, 17 | mergeObject, 18 | deepCloneObject 19 | } from './utils' 20 | 21 | import type { 22 | Options, 23 | Serializer, 24 | ValidateFn, 25 | Migration, 26 | OnDidChangeCallback, 27 | OnDidAnyChangeCallback, 28 | Unsubscribe 29 | } from './types' 30 | 31 | const JsonSerializer: Serializer = { 32 | read: (value): any => JSON.parse(value), 33 | write: (value): string => JSON.stringify(value, undefined, '\t') 34 | } 35 | 36 | const INTERNAL_KEY = '__internal__' 37 | const MIGRATION_KEY = `${INTERNAL_KEY}.migrationVersion` 38 | 39 | const BAN_TYPES = new Set(['undefined', 'symbol', 'function']) 40 | 41 | export class BaseConf = Record> 42 | implements Iterable<[keyof T, T[keyof T]]> 43 | { 44 | /** 45 | * Configuration file name without extension. 46 | */ 47 | readonly name: string 48 | /** 49 | * Configuration file path. 50 | */ 51 | readonly fileName: string 52 | readonly events: EventTarget 53 | 54 | private _store?: T 55 | 56 | private dir: string 57 | 58 | private serializer: Serializer 59 | private validator?: ValidateFn 60 | defaultValues: Partial = {} 61 | 62 | constructor(options: Options = {}) { 63 | const { 64 | dir = process.cwd(), 65 | name = 'config', 66 | ext = '.json', 67 | serializer, 68 | schema, 69 | defaults, 70 | migrations 71 | } = options 72 | 73 | this.dir = dir 74 | this.name = name 75 | 76 | this.fileName = path.join(dir, `${name}${ext}`) 77 | 78 | this.events = new EventTarget() 79 | 80 | this.serializer = serializer || JsonSerializer 81 | 82 | if (schema) { 83 | this.validator = new Ajv({ allErrors: true }).compile(schema) 84 | } 85 | 86 | // call the getter to read the store file. 87 | const fileStore = this.store 88 | 89 | if (defaults) { 90 | this.defaultValues = { ...defaults } 91 | 92 | const store = mergeObject(deepCloneObject(defaults), fileStore) 93 | this.validate(store) 94 | 95 | if (!deepEqual(fileStore, store)) { 96 | this.store = store 97 | } 98 | } 99 | 100 | this.migrate(migrations) 101 | } 102 | 103 | *[Symbol.iterator](): IterableIterator<[keyof T, T[keyof T]]> { 104 | for (const [key, value] of Object.entries(this.store)) { 105 | yield [key, value] 106 | } 107 | } 108 | 109 | get store(): T { 110 | if (this._store) { 111 | return cloneObject(this._store) 112 | } 113 | 114 | this._store = this.read() 115 | this.validate(this._store) 116 | return this._store 117 | } 118 | 119 | set store(value: T) { 120 | this.validate(value) 121 | this.write(value) 122 | 123 | this._store = value 124 | 125 | this.events.dispatchEvent(new Event('change')) 126 | } 127 | 128 | private migrate(migrations?: Migration[]): void { 129 | if (migrations && migrations.length) { 130 | let version: number = getProperty(this.store, MIGRATION_KEY, 0) 131 | const _migrations = migrations 132 | .sort((a, b) => a.version - b.version) 133 | .filter((m) => m.version > version) 134 | 135 | for (const migration of _migrations) { 136 | migration.hook(this, version) 137 | 138 | const { store } = this 139 | setProperty(store, MIGRATION_KEY, migration.version) 140 | 141 | version = migration.version 142 | 143 | this.store = store 144 | } 145 | } 146 | } 147 | 148 | /** 149 | * Get an item. 150 | * @param key The key of the item to get. 151 | * @param defaultValue The default value if the item does not exist. 152 | * 153 | * @example 154 | * ``` 155 | * import { Conf } from 'electron-conf/main' 156 | * 157 | * const conf = new Conf() 158 | * 159 | * conf.get('foo') 160 | * conf.get('a.b') 161 | * ``` 162 | */ 163 | get(key: K): T[K] 164 | get(key: K, defaultValue: Required[K]): Required[K] 165 | get( 166 | key: Exclude, 167 | defaultValue?: V 168 | ): V 169 | get(key: string, defaultValue?: unknown): unknown 170 | get(key: string, defaultValue?: unknown): unknown { 171 | return getProperty(this.store, key, defaultValue) 172 | } 173 | 174 | /** 175 | * Set an item or multiple items at once. 176 | * 177 | * @param key The key of the item or a hashmap of items to set at once. 178 | * @param value Must be JSON serializable. Trying to set the type `undefined`, `function`, or `symbol` will result in a `TypeError`. 179 | * 180 | * @example 181 | * ``` 182 | * import { Conf } from 'electron-conf/main' 183 | * 184 | * const conf = new Conf() 185 | * 186 | * conf.set('foo', 1) 187 | * conf.set('a.b', 2) 188 | * conf.set({ foo: 1, a: { b: 2 }}) 189 | * ``` 190 | */ 191 | set(key: K, value?: T[K]): void 192 | set(key: string, value: unknown): void 193 | set(object: Partial): void 194 | set( 195 | key: Partial | K | string, 196 | value?: T[K] | unknown 197 | ): void 198 | set( 199 | key: Partial | K | string, 200 | value?: T[K] | unknown 201 | ): void { 202 | if (typeof key !== 'string' && typeof key !== 'object') { 203 | throw new TypeError( 204 | `Expected 'key' to be of type 'string' or 'object', got '${typeof key}'.` 205 | ) 206 | } 207 | 208 | if (typeof key !== 'object' && value === undefined) { 209 | throw new TypeError('Use `delete()` to clear values.') 210 | } 211 | 212 | if (this.containsReservedKey(key)) { 213 | throw new TypeError( 214 | `Please don't use the ${INTERNAL_KEY} key, as it's used to manage this module internal operations.` 215 | ) 216 | } 217 | 218 | const { store } = this 219 | 220 | const set = (key: string, value?: T[K] | T | unknown): void => { 221 | const type = typeof value 222 | 223 | if (BAN_TYPES.has(type)) { 224 | throw new TypeError( 225 | `Setting a value of type '${type}' for key '${key}' is not allowed as it's not supported.` 226 | ) 227 | } 228 | 229 | setProperty(store, key, value) 230 | } 231 | 232 | if (typeof key === 'object') { 233 | const object = key 234 | for (const [key, value] of Object.entries(object)) { 235 | set(key, value) 236 | } 237 | } else { 238 | set(key, value) 239 | } 240 | 241 | this.store = store 242 | } 243 | 244 | /** 245 | * Check if an item exists. 246 | * @param key The key of the item to check. 247 | */ 248 | has(key: Key | string): boolean { 249 | return hasProperty(this.store, key as string) 250 | } 251 | 252 | /** 253 | * Reset items to their default values, as defined by the `defaults` or `schema` option. 254 | * @param keys The keys of the items to reset. 255 | */ 256 | reset(...keys: Key[]): void { 257 | for (const key of keys) { 258 | const value = deepCloneObject(this.defaultValues[key]) 259 | if (value !== undefined && value !== null) { 260 | this.set(key, value) 261 | } 262 | } 263 | } 264 | 265 | /** 266 | * Delete an item. 267 | * @param key The key of the item to delete. 268 | */ 269 | delete(key: Key): void 270 | delete(key: string): void 271 | delete(key: string): void { 272 | const { store } = this 273 | 274 | deleteProperty(store, key) 275 | 276 | this.store = store 277 | } 278 | 279 | /** 280 | * Delete all items. This resets known items to their default values, if 281 | * defined by the `defaults` or `schema` option. 282 | */ 283 | clear(): void { 284 | this.store = createPlainObject() 285 | 286 | this.reset(...Object.keys(this.defaultValues)) 287 | } 288 | 289 | private ensureDirectory(): void { 290 | fs.mkdirSync(this.dir, { recursive: true }) 291 | } 292 | 293 | private read(): T { 294 | if (!fs.existsSync(this.fileName)) { 295 | this.ensureDirectory() 296 | return createPlainObject() 297 | } 298 | 299 | const data = fs.readFileSync(this.fileName, 'utf8') 300 | const deserializedData = this.serializer.read(data) 301 | 302 | return cloneObject(deserializedData) 303 | } 304 | 305 | private write(value: T): void { 306 | this.ensureDirectory() 307 | 308 | const data: string = this.serializer.write(value) 309 | 310 | const wOptions = { mode: 0o666 } 311 | if (process.env.SNAP) { 312 | fs.writeFileSync(this.fileName, data, wOptions) 313 | } else { 314 | try { 315 | atomicWriteFileSync(this.fileName, data, wOptions) 316 | } catch (error: unknown) { 317 | if ((error as any)?.code === 'EXDEV') { 318 | fs.writeFileSync(this.fileName, data, wOptions) 319 | return 320 | } 321 | 322 | throw error 323 | } 324 | } 325 | } 326 | 327 | private validate(data: T | unknown): void { 328 | if (!this.validator) { 329 | return 330 | } 331 | 332 | const valid = this.validator(data) 333 | if (valid || !this.validator.errors) { 334 | return 335 | } 336 | 337 | const errorsText = this.validator.errors 338 | .map(({ instancePath, message }) => `${instancePath} ${message}`) 339 | .join('; ') 340 | 341 | throw new Error('Config schema violation: ' + errorsText) 342 | } 343 | 344 | private containsReservedKey(key: string | Partial): boolean { 345 | if (typeof key === 'object') { 346 | const firstKey = Object.keys(key)[0] 347 | 348 | if (firstKey === INTERNAL_KEY) { 349 | return true 350 | } 351 | } 352 | 353 | if (typeof key !== 'string') { 354 | return false 355 | } 356 | 357 | if (key.startsWith(`${INTERNAL_KEY}.`)) { 358 | return true 359 | } 360 | 361 | return false 362 | } 363 | 364 | /** 365 | * Watches the given `key`, calling `callback` on any changes. 366 | * @param key The key to watch. 367 | * @param callback A callback function that is called on any changes. When a `key` is first set `oldValue` will be `undefined`, and when a key is deleted `newValue` will be `undefined`. 368 | * @returns A function, that when called, will unsubscribe. 369 | */ 370 | onDidChange( 371 | key: Key, 372 | callback: OnDidChangeCallback 373 | ): Unsubscribe 374 | onDidChange( 375 | key: string, 376 | callback: OnDidChangeCallback 377 | ): Unsubscribe 378 | onDidChange( 379 | key: string, 380 | callback: OnDidChangeCallback 381 | ): Unsubscribe { 382 | if (typeof key !== 'string') { 383 | throw new TypeError( 384 | `Expected 'key' to be of type 'string', got '${typeof key}'.` 385 | ) 386 | } 387 | 388 | if (typeof callback !== 'function') { 389 | throw new TypeError( 390 | `Expected 'callback' to be of type 'function', got '${typeof callback}'.` 391 | ) 392 | } 393 | 394 | return this.handleChange(() => this.get(key), callback) 395 | } 396 | 397 | /** 398 | * Watches the whole config object, calling `callback` on any changes. 399 | * @param callback A callback function that is called on any changes. When a `key` is first set `oldValue` will be `undefined`, and when a key is deleted `newValue` will be `undefined`. 400 | * @returns A function, that when called, will unsubscribe. 401 | */ 402 | onDidAnyChange(callback: OnDidAnyChangeCallback): Unsubscribe { 403 | if (typeof callback !== 'function') { 404 | throw new TypeError( 405 | `Expected 'callback' to be of type 'function', got '${typeof callback}'.` 406 | ) 407 | } 408 | 409 | return this.handleChange(() => this.store, callback) 410 | } 411 | 412 | private handleChange( 413 | getter: () => T | undefined, 414 | callback: OnDidAnyChangeCallback 415 | ): Unsubscribe 416 | private handleChange( 417 | getter: () => T[K] | undefined, 418 | callback: OnDidChangeCallback 419 | ): Unsubscribe 420 | private handleChange( 421 | getter: () => T | T[K] | undefined, 422 | callback: OnDidAnyChangeCallback | OnDidChangeCallback 423 | ): Unsubscribe { 424 | let currentValue = getter() 425 | 426 | const onChange = (): void => { 427 | const oldValue = currentValue 428 | const newValue = getter() 429 | 430 | if (deepEqual(newValue, oldValue)) { 431 | return 432 | } 433 | 434 | currentValue = newValue 435 | callback.call(this, newValue, oldValue) 436 | } 437 | 438 | this.events.addEventListener('change', onChange) 439 | 440 | return () => { 441 | this.events.removeEventListener('change', onChange) 442 | } 443 | } 444 | } 445 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | import { exposeConf } from './preload' 2 | 3 | exposeConf() 4 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { app, ipcMain, session as _session } from 'electron' 3 | import { type Session } from 'electron' 4 | 5 | import { BaseConf } from './conf' 6 | 7 | import type { ConfOptions } from './types' 8 | 9 | type Action = 'get' | 'set' | 'has' | 'reset' | 'delete' | 'clear' 10 | 11 | export class Conf< 12 | T extends Record = Record 13 | > extends BaseConf { 14 | constructor(options: ConfOptions = {}) { 15 | options.dir = options.dir || app.getPath('userData') 16 | 17 | super(options) 18 | } 19 | 20 | /** 21 | * Register the config ipc handler for use by renderer. 22 | */ 23 | 24 | /** 25 | * Register the config ipc handler for use by renderer. 26 | * @param name The name used to define the renderer process Conf. Default to `config`. 27 | */ 28 | registerRendererListener(name?: string): void { 29 | const channel = `__electron_conf_${name || this.name}_handler__` 30 | if (!ipcMain.eventNames().some((e) => e === channel)) { 31 | ipcMain.handle( 32 | channel, 33 | (_, action: Action, key: any, value?: unknown) => { 34 | if (action === 'get') { 35 | return this.get(key, value) 36 | } 37 | 38 | if (action === 'set') { 39 | this.set(key, value) 40 | return 41 | } 42 | 43 | if (action === 'has') { 44 | return this.has(key) 45 | } 46 | 47 | if (action === 'reset') { 48 | this.reset(key) 49 | return 50 | } 51 | 52 | if (action === 'delete') { 53 | this.delete(key) 54 | return 55 | } 56 | 57 | if (action === 'clear') { 58 | this.clear() 59 | return 60 | } 61 | 62 | return 63 | } 64 | ) 65 | } 66 | } 67 | } 68 | 69 | export type { ConfOptions, Serializer, JSONSchema, Migration } from './types' 70 | 71 | type Options = { 72 | /** 73 | * Attach ES module preload script. 74 | * 75 | * @default false 76 | */ 77 | esModule: boolean 78 | } 79 | 80 | /** 81 | * Use Electron config for the specified session. 82 | */ 83 | export function useConf( 84 | session: Session = _session.defaultSession, 85 | options: Options = { esModule: false } 86 | ): void { 87 | const electronVer = process.versions.electron 88 | const electronMajorVer = electronVer ? parseInt(electronVer.split('.')[0]) : 0 89 | const preloadPath = fileURLToPath( 90 | new URL( 91 | options.esModule 92 | ? 'electron-conf-preload.mjs' 93 | : 'electron-conf-preload.cjs', 94 | import.meta.url 95 | ) 96 | ) 97 | 98 | if (electronMajorVer >= 35) { 99 | session.registerPreloadScript({ type: 'frame', filePath: preloadPath }) 100 | } else { 101 | session.setPreloads([...session.getPreloads(), preloadPath]) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron' 2 | 3 | import type { ConfAPI } from './types' 4 | 5 | const api: ConfAPI = { 6 | ipcRenderer: { 7 | invoke(channel, ...args) { 8 | return ipcRenderer.invoke(channel, ...args) 9 | } 10 | } 11 | } 12 | 13 | /** 14 | * Expose config in the specified preload script. 15 | */ 16 | export function exposeConf(): void { 17 | if (process.contextIsolated) { 18 | try { 19 | contextBridge.exposeInMainWorld(`__ELECTRON_CONF__`, api) 20 | } catch (error) { 21 | console.error(error) 22 | } 23 | } else { 24 | // @ts-ignore (need dts) 25 | window.__ELECTRON_CONF__ = api 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | import type { ConfAPI } from './types' 2 | 3 | type ConfOptions = { 4 | /** 5 | * The configuration file name should be the name of the listener 6 | * registered by the main process. 7 | * 8 | * @default 'config' 9 | */ 10 | name?: string 11 | } 12 | 13 | export class Conf = Record> { 14 | private api: ConfAPI 15 | private channel: string 16 | 17 | constructor(options: ConfOptions = {}) { 18 | const { name = 'config' } = options 19 | 20 | this.channel = `__electron_conf_${name}_handler__` 21 | 22 | this.api = 23 | (globalThis || window).__ELECTRON_CONF__ || 24 | (globalThis || window).electron 25 | } 26 | 27 | /** 28 | * Get an item. 29 | * @param key The key of the item to get. 30 | * @param defaultValue The default value if the item does not exist. 31 | * 32 | * @example 33 | * ``` 34 | * import { Conf } from 'electron-conf/renderer' 35 | * 36 | * const conf = new Conf() 37 | * 38 | * await conf.get('foo') 39 | * await conf.get('a.b') 40 | * ``` 41 | */ 42 | get(key: K): Promise 43 | get( 44 | key: K, 45 | defaultValue: Required[K] 46 | ): Promise[K]> 47 | get( 48 | key: Exclude, 49 | defaultValue?: V 50 | ): Promise 51 | get(key: string, defaultValue?: unknown): Promise 52 | get(key: string, defaultValue?: unknown): Promise { 53 | return this.api.ipcRenderer.invoke(this.channel, 'get', key, defaultValue) 54 | } 55 | 56 | /** 57 | * Set an item or multiple items at once. 58 | * 59 | * @param key The key of the item or a hashmap of items to set at once. 60 | * @param value Must be JSON serializable. Trying to set the type `undefined`, `function`, or `symbol` will result in a `TypeError`. 61 | * 62 | * @example 63 | * ``` 64 | * import { Conf } from 'electron-conf/renderer' 65 | * 66 | * const conf = new Conf() 67 | * 68 | * await conf.set('foo', 1) 69 | * await conf.set('a.b', 2) 70 | * await conf.set({ foo: 1, a: { b: 2 }}) 71 | * ``` 72 | */ 73 | set(key: K, value?: T[K]): Promise 74 | set(key: string, value: unknown): Promise 75 | set(object: Partial): Promise 76 | set( 77 | key: Partial | K | string, 78 | value?: T[K] | unknown 79 | ): Promise 80 | set( 81 | key: Partial | K | string, 82 | value?: T[K] | unknown 83 | ): Promise { 84 | return this.api.ipcRenderer.invoke(this.channel, 'set', key, value) 85 | } 86 | 87 | /** 88 | * Check if an item exists. 89 | * @param key The key of the item to check. 90 | */ 91 | has(key: Key | string): Promise { 92 | return this.api.ipcRenderer.invoke(this.channel, 'has', key) 93 | } 94 | 95 | /** 96 | * Reset items to their default values, as defined by the `defaults` or `schema` option. 97 | * @param keys The keys of the items to reset. 98 | */ 99 | reset(...keys: Key[]): Promise { 100 | return this.api.ipcRenderer.invoke(this.channel, 'reset', keys) 101 | } 102 | 103 | /** 104 | * Delete an item. 105 | * @param key The key of the item to delete. 106 | */ 107 | delete(key: Key): Promise 108 | delete(key: string): Promise 109 | delete(key: string): Promise { 110 | return this.api.ipcRenderer.invoke(this.channel, 'delete', key) 111 | } 112 | 113 | /** 114 | * Delete all items. This resets known items to their default values, if 115 | * defined by the `defaults` or `schema` option. 116 | */ 117 | clear(): Promise { 118 | return this.api.ipcRenderer.invoke(this.channel, 'clear') 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { JSONSchemaType, ValidateFunction } from 'ajv' 2 | 3 | import type { BaseConf } from './conf' 4 | 5 | export type JSONSchema = JSONSchemaType 6 | 7 | export type ValidateFn = ValidateFunction 8 | 9 | export interface Serializer { 10 | /** 11 | * Deserialize the config object from a UTF-8 string when reading the config file. 12 | * @param raw UTF-8 encoded string. 13 | */ 14 | read: (raw: string) => T 15 | /** 16 | * Serialize the config object to a UTF-8 string when writing the config file. 17 | * @param value The config object. 18 | */ 19 | write: (value: T) => string 20 | } 21 | 22 | export type Migration> = { 23 | /** 24 | * Migration version. The initial version must be greater than `0`. A new 25 | * version is defined on each migration and is incremented on the previous version. 26 | */ 27 | version: number 28 | /** 29 | * Migration hook. You can perform operations to update your configuration. 30 | * @param instance config instance. 31 | * @param currentVersion current version. 32 | */ 33 | hook: (instance: BaseConf, currentVersion: number) => void 34 | } 35 | 36 | export type Options> = { 37 | /** 38 | * The directory for storing your app's configuration file. 39 | * 40 | * @default app.getPath('userData') 41 | */ 42 | dir?: string 43 | /** 44 | * Configuration file name without extension. 45 | * 46 | * @default 'config' 47 | */ 48 | name?: string 49 | /** 50 | * Configuration file extension. 51 | * 52 | * @default '.json' 53 | */ 54 | ext?: string 55 | /** 56 | * Default config used if there are no existing config. 57 | */ 58 | defaults?: Readonly 59 | /** 60 | * Provides functionality to serialize object types to UTF-8 strings and to 61 | * deserialize UTF-8 strings into object types. 62 | * 63 | * By default, `JSON.stringify` is used for serialization and `JSON.parse` is 64 | * used for deserialization. 65 | * 66 | * You would usually not need this, but it could be useful if you want to use 67 | * a format other than JSON. 68 | */ 69 | serializer?: Serializer 70 | /** 71 | * [JSON Schema](https://json-schema.org) to validate your config data. 72 | * 73 | * Under the hood, we use the [ajv](https://ajv.js.org/) JSON Schema 74 | * validator to validate config data. 75 | * 76 | * @example 77 | * ``` 78 | * import { Conf } from 'electron-conf/main' 79 | * 80 | * const schema = { 81 | * type: 'object', 82 | * properties: { 83 | * foo: { 84 | * type: 'string', 85 | * maxLength: 10, 86 | * nullable: true 87 | * } 88 | * } 89 | * } 90 | * 91 | * const conf = new Conf({ schema }) 92 | * ``` 93 | */ 94 | schema?: JSONSchema 95 | /** 96 | * You can customize versions and perform operations to migrate configurations. 97 | * When instantiated, it will be compared with the version number of the 98 | * configuration file and a higher version migration operation will be performed. 99 | * 100 | * **Note:** The migration version must be greater than `0`. A new version is 101 | * defined on each migration and is incremented on the previous version. 102 | * 103 | * @example 104 | * ``` 105 | * import { Conf } from 'electron-conf/main' 106 | * 107 | * const migrations = [ 108 | * { 109 | * version: 1, 110 | * hook: (conf, version): void => { 111 | * conf.set('foo', 'a') 112 | * console.log(`migrate from ${version} to 1`) // migrate from 0 to 1 113 | * } 114 | * }, 115 | * { 116 | * version: 2, 117 | * hook: (conf, version): void => { 118 | * conf.set('foo', 'b') 119 | * console.log(`migrate from ${version} to 2`) // migrate from 1 to 2 120 | * } 121 | * } 122 | * ] 123 | * 124 | * const conf = new Conf({ migrations }) 125 | * ``` 126 | */ 127 | migrations?: Migration[] 128 | } 129 | 130 | export type OnDidChangeCallback = (newValue?: T, oldValue?: T) => void 131 | 132 | export type OnDidAnyChangeCallback = ( 133 | newValue?: Readonly, 134 | oldValue?: Readonly 135 | ) => void 136 | 137 | export type Unsubscribe = () => void 138 | 139 | export type ConfOptions> = Options 140 | 141 | interface IpcRenderer { 142 | invoke(channel: string, ...args: any[]): Promise 143 | } 144 | 145 | export interface ConfAPI { 146 | ipcRenderer: IpcRenderer 147 | } 148 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import util from 'node:util' 2 | 3 | export function deepEqual(val1: unknown, val2: unknown): boolean { 4 | return util.isDeepStrictEqual(val1, val2) 5 | } 6 | 7 | export function createPlainObject>(): T { 8 | return Object.create(null) 9 | } 10 | 11 | export function cloneObject>(source: T): T { 12 | return Object.assign(createPlainObject(), source) 13 | } 14 | 15 | export function mergeObject>( 16 | ...sources: unknown[] 17 | ): T { 18 | return Object.assign(createPlainObject(), ...sources) 19 | } 20 | 21 | export function deepCloneObject>(source: T): T { 22 | if (source === null || typeof source !== 'object') { 23 | return source 24 | } 25 | 26 | const cloned = Array.isArray(source) ? ([] as T) : ({} as T) 27 | 28 | for (const key in source) { 29 | cloned[key] = deepCloneObject(source[key]) 30 | } 31 | 32 | return cloned 33 | } 34 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import crypto from 'node:crypto' 4 | import { it, expect, describe, afterAll } from 'vitest' 5 | 6 | import { BaseConf } from '../src/conf' 7 | import { JSONSchema, Migration } from '../src/types' 8 | 9 | const dir = path.join(process.cwd(), 'tmp') 10 | 11 | const expected = 'foo' 12 | const unexpected = 'bar' 13 | 14 | type TestObj = { foo?: string } 15 | type NestedTestObj = TestObj & { 16 | bar?: { 17 | baz?: number 18 | } 19 | } 20 | 21 | const genTmpName = (): string => { 22 | const randomBytes = crypto.randomBytes(4) 23 | return randomBytes.toString('hex') 24 | } 25 | 26 | describe('constructor options', () => { 27 | it('name', () => { 28 | const name = genTmpName() 29 | const conf = new BaseConf({ dir, name }) 30 | expect(conf.get('foo')).toBeUndefined() 31 | conf.set('foo', expected) 32 | expect(conf.get('foo')).toBe(expected) 33 | expect(fs.existsSync(conf.fileName)).to.be.true 34 | }) 35 | 36 | describe.sequential('defaults', () => { 37 | const name = genTmpName() 38 | it('no value', () => { 39 | const defaults = { 40 | foo: expected 41 | } 42 | const conf = new BaseConf({ dir, name, defaults }) 43 | expect(conf.get('foo')).toBe(expected) 44 | }) 45 | it('already has a value', () => { 46 | const defaults = { 47 | foo: unexpected 48 | } 49 | const conf = new BaseConf<{ foo?: string }>({ dir, name, defaults }) 50 | expect(conf.get('foo')).not.toBe(unexpected) 51 | }) 52 | }) 53 | 54 | describe.sequential('schema', () => { 55 | const schema: JSONSchema = { 56 | type: 'object', 57 | properties: { 58 | foo: { 59 | type: 'string', 60 | maxLength: 10, 61 | nullable: true 62 | }, 63 | bar: { 64 | type: 'object', 65 | properties: { 66 | baz: { 67 | type: 'number', 68 | maximum: 99, 69 | nullable: true 70 | } 71 | }, 72 | nullable: true 73 | } 74 | } 75 | } 76 | const defaults = { 77 | bar: { baz: 100 } 78 | } 79 | const errExpected = /^Config schema violation/ 80 | 81 | it('validate defaults', () => { 82 | const name = genTmpName() 83 | expect( 84 | () => new BaseConf({ dir, name, defaults, schema }) 85 | ).toThrowError(errExpected) 86 | }) 87 | it('valid set', () => { 88 | const name = genTmpName() 89 | const conf = new BaseConf({ dir, name, schema }) 90 | conf.set('foo', expected.repeat(3)) 91 | expect(conf.get('foo')).toBe(expected.repeat(3)) 92 | expect(() => conf.set('foo', expected.repeat(4))).toThrowError( 93 | errExpected 94 | ) 95 | }) 96 | }) 97 | 98 | describe.sequential('migrations', () => { 99 | const name = genTmpName() 100 | const migrations: Migration[] = [ 101 | { 102 | version: 1, 103 | hook: (conf): void => conf.set('foo', expected) 104 | } 105 | ] 106 | const secondMigrations: Migration[] = [ 107 | ...migrations, 108 | { 109 | version: 2, 110 | hook: (conf): void => conf.set('bar.baz', 0) 111 | } 112 | ] 113 | 114 | const defaults = { 115 | foo: unexpected 116 | } 117 | 118 | it('do migrate and update migration version', () => { 119 | const conf = new BaseConf({ 120 | dir, 121 | name, 122 | defaults, 123 | migrations 124 | }) 125 | expect(conf.get('__internal__.migrationVersion')).toBe(1) 126 | expect(conf.get('foo')).toBe(expected) 127 | conf.set('foo', unexpected) 128 | }) 129 | 130 | it('only higher version migration operation will be performed', () => { 131 | const conf = new BaseConf({ 132 | dir, 133 | name, 134 | defaults, 135 | migrations: secondMigrations 136 | }) 137 | expect(conf.get('__internal__.migrationVersion')).toBe(2) 138 | expect(conf.get('foo')).toBe(unexpected) 139 | expect(conf.get('bar.baz')).toBe(0) 140 | }) 141 | }) 142 | }) 143 | 144 | describe.sequential('instance methods', () => { 145 | const name = genTmpName() 146 | const defaults = { 147 | bar: { baz: 100 } 148 | } 149 | 150 | const conf = new BaseConf({ dir, name, defaults }) 151 | 152 | it('.get()', () => { 153 | expect(conf.get('foo')).toBeUndefined() 154 | expect(conf.get('foo', expected)).toBe(expected) 155 | conf.set('foo', expected) 156 | expect(conf.get('foo')).toBe(expected) 157 | expect(conf.get('bar.baz')).toBe(100) 158 | expect(conf.get('bar')).toEqual({ baz: 100 }) 159 | }) 160 | 161 | it('.set()', () => { 162 | conf.set('foo', 'hello') 163 | conf.set('bar.baz', 0) 164 | expect(conf.get('foo')).toBe('hello') 165 | expect(conf.get('bar.baz')).toBe(0) 166 | conf.set({ 167 | foo: 'world', 168 | bar: { 169 | baz: 1 170 | } 171 | }) 172 | expect(conf.get('foo')).toBe('world') 173 | expect(conf.get('bar.baz')).toBe(1) 174 | expect(conf.get('bar')).toEqual({ baz: 1 }) 175 | expect(() => conf.set('foo', () => {})).toThrowError( 176 | /^Setting a value of type/ 177 | ) 178 | }) 179 | 180 | it('.has()', () => { 181 | expect(conf.has('foo')).to.be.true 182 | expect(conf.has('boo')).to.be.false 183 | }) 184 | 185 | it('.reset()', () => { 186 | expect(conf.get('bar')).not.toEqual({ baz: 100 }) 187 | conf.reset('bar') 188 | expect(conf.get('bar')).toEqual({ baz: 100 }) 189 | }) 190 | 191 | it('.delete()', () => { 192 | conf.delete('foo') 193 | expect(conf.get('foo')).toBeUndefined() 194 | conf.delete('bar.baz') 195 | expect(conf.get('bar.baz')).toBeUndefined() 196 | }) 197 | 198 | it('.clear()', () => { 199 | conf.set('foo', 'bar') 200 | conf.clear() 201 | expect(conf.get('foo')).toBeUndefined() 202 | expect(conf.get('bar.baz')).toBe(100) 203 | }) 204 | 205 | it('.onDidChange()', () => { 206 | const fooCb = (newValue, oldValue): void => { 207 | expect(oldValue).toBeUndefined() 208 | expect(newValue).toBe(expected) 209 | } 210 | 211 | const bazCb = (newValue, oldValue): void => { 212 | expect(oldValue).toBe(100) 213 | expect(newValue).toBeUndefined() 214 | } 215 | 216 | const unsubscribe1 = conf.onDidChange('foo', fooCb) 217 | const unsubscribe2 = conf.onDidChange('bar.baz', bazCb) 218 | 219 | conf.set('foo', expected) 220 | unsubscribe1() 221 | 222 | conf.delete('bar.baz') 223 | unsubscribe2() 224 | }) 225 | 226 | it('.onDidAnyChange()', () => { 227 | const cb = (newValue, oldValue): void => { 228 | expect(oldValue).not.toStrictEqual(newValue) 229 | } 230 | 231 | const unsubscribe = conf.onDidAnyChange(cb) 232 | 233 | conf.set('foo', unexpected) 234 | unsubscribe() 235 | }) 236 | }) 237 | 238 | afterAll(() => { 239 | const files = fs.readdirSync(dir) 240 | for (let i = 0; i < files.length; i++) { 241 | fs.unlinkSync(path.join(dir, files[i])) 242 | } 243 | fs.rmdirSync(dir) 244 | }) 245 | -------------------------------------------------------------------------------- /test/electron.test.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn } from 'node:child_process' 2 | import fs from 'node:fs' 3 | import path from 'node:path' 4 | import electronPath from 'electron/index' 5 | import { it, expect, beforeAll, afterAll } from 'vitest' 6 | 7 | type ElectronTestOptions = { 8 | path: string 9 | args: string[] 10 | env?: Record 11 | } 12 | 13 | type ElectronTestMessage = { 14 | invocationId: number 15 | resolve: any 16 | reject: any 17 | } 18 | 19 | interface ElectronTestRPCMap { 20 | [key: number]: { 21 | resolve: (value: any | PromiseLike) => void 22 | reject: (reason: any) => void 23 | } 24 | } 25 | 26 | class ElectronTest { 27 | private ps?: ChildProcess 28 | private rpcMap: ElectronTestRPCMap = {} 29 | private invocationId = 0 30 | 31 | constructor(readonly options: ElectronTestOptions) {} 32 | 33 | async launch(): Promise { 34 | this.ps = spawn(this.options.path, this.options.args, { 35 | stdio: ['inherit', 'inherit', 'inherit', 'ipc'], 36 | env: { APP_TEST: 'true', ...this.options.env } 37 | }) 38 | 39 | // listen for RPC messages from the app 40 | this.ps.on('message', (msg: ElectronTestMessage) => { 41 | const res = this.rpcMap[msg.invocationId] 42 | if (!res) return 43 | delete this.rpcMap[msg.invocationId] 44 | if (msg.reject) res.reject(msg.reject) 45 | else res.resolve(msg.resolve) 46 | }) 47 | 48 | return this.rpc('ready') 49 | } 50 | 51 | async rpc(cmd: string, ...args): Promise { 52 | if (!this.ps) { 53 | throw Error( 54 | 'The test instance is not initialized, please call the `.launch()` method first' 55 | ) 56 | } 57 | const invocationId = this.invocationId++ 58 | this.ps.send({ invocationId, cmd, args }) 59 | return new Promise( 60 | (resolve, reject) => (this.rpcMap[invocationId] = { resolve, reject }) 61 | ) 62 | } 63 | 64 | stop(): void { 65 | this.ps?.kill() 66 | } 67 | } 68 | 69 | const context = new ElectronTest({ 70 | path: electronPath, 71 | args: ['./test/electron/main.mjs'] 72 | }) 73 | 74 | beforeAll(async () => { 75 | await context.launch().catch(() => { 76 | context.stop() 77 | process.exit(1) 78 | }) 79 | }, 3000) 80 | 81 | it('default path', async () => { 82 | const electronUserDataDir = await context.rpc( 83 | 'electron_user_data_dir' 84 | ) 85 | const configFilePath = await context.rpc('config_file_path') 86 | expect(fs.existsSync(configFilePath)).to.be.true 87 | expect(path.dirname(configFilePath)).toBe(electronUserDataDir) 88 | }) 89 | 90 | it('supports renderer', async () => { 91 | const result = await context.rpc<{ baz: number; zoo: string }>('renderer') 92 | expect(result.baz).toBe(1) 93 | expect(result.zoo).toBe('zoo') 94 | }) 95 | 96 | afterAll(async () => { 97 | const configPath = await context.rpc('config_file_path') 98 | fs.unlinkSync(configPath) 99 | context.stop() 100 | }) 101 | -------------------------------------------------------------------------------- /test/electron/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Electron 6 | 7 | 11 | 12 | 13 | 14 |
hello world
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/electron/main.mjs: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain } from 'electron' 2 | import { join } from 'path' 3 | import TestRPC from './test.mjs' 4 | import { Conf } from '../../dist/main.mjs' 5 | 6 | app.name = 'electron-conf' 7 | 8 | function createWindow() { 9 | return new Promise((resolve) => { 10 | const win = new BrowserWindow({ 11 | show: false, 12 | webPreferences: { 13 | preload: join(import.meta.dirname, './preload.mjs'), 14 | sandbox: false 15 | } 16 | }) 17 | 18 | win.loadFile(join(import.meta.dirname, './index.html')) 19 | 20 | ipcMain.once('did', (_, arg) => { 21 | resolve(arg) 22 | win.close() 23 | }) 24 | }) 25 | } 26 | 27 | app.whenReady().then(() => { 28 | const conf = new Conf() 29 | conf.registerRendererListener() 30 | conf.set('foo', 1) 31 | 32 | const rpc = new TestRPC() 33 | 34 | rpc.ready() 35 | 36 | rpc.register('electron_user_data_dir', () => { 37 | return app.getPath('userData') 38 | }) 39 | 40 | rpc.register('config_file_path', () => { 41 | return conf.fileName 42 | }) 43 | 44 | rpc.register('renderer', async () => { 45 | const zoo = await createWindow() 46 | return { 47 | baz: conf.get('bar.baz'), 48 | zoo 49 | } 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /test/electron/preload.mjs: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron/renderer' 2 | 3 | import { exposeConf } from '../../dist/preload.mjs' 4 | 5 | exposeConf() 6 | 7 | try { 8 | contextBridge.exposeInMainWorld('api', { 9 | did: (arg) => ipcRenderer.send('did', arg) 10 | }) 11 | } catch (error) { 12 | console.error(error) 13 | } 14 | -------------------------------------------------------------------------------- /test/electron/renderer.mjs: -------------------------------------------------------------------------------- 1 | import { Conf } from '../../dist/renderer.mjs' 2 | 3 | window.addEventListener('DOMContentLoaded', async () => { 4 | const conf = new Conf() 5 | conf.set({ 6 | bar: { 7 | baz: 1 8 | } 9 | }) 10 | window.api?.did(await conf.get('zoo', 'zoo')) 11 | }) 12 | -------------------------------------------------------------------------------- /test/electron/test.mjs: -------------------------------------------------------------------------------- 1 | export default class TestRPC { 2 | methonds = {} 3 | 4 | constructor() { 5 | const onMessage = async ({ invocationId, cmd, args }) => { 6 | let method = this.methonds[cmd] 7 | 8 | if (!method) { 9 | if (cmd === 'ready') { 10 | return 11 | } 12 | method = () => new Error('Invalid method: ' + cmd) 13 | } 14 | 15 | try { 16 | const resolve = await method(...args) 17 | process.send({ invocationId, resolve }) 18 | } catch (err) { 19 | const reject = { 20 | message: err.message, 21 | stack: err.stack, 22 | name: err.name 23 | } 24 | process.send({ invocationId, reject }) 25 | } 26 | } 27 | 28 | if (process.env.APP_TEST) { 29 | process.on('message', onMessage) 30 | } 31 | } 32 | 33 | register(cmd, cb) { 34 | this.methonds[cmd] = cb 35 | } 36 | 37 | ready() { 38 | process.send({ invocationId: 0, resolve: true }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": ["esnext", "DOM"], 6 | "sourceMap": false, 7 | "strict": true, 8 | "allowJs": true, 9 | "esModuleInterop": true, 10 | "moduleResolution": "Bundler", 11 | "resolveJsonModule": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "skipLibCheck": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitAny": false, 17 | "noImplicitReturns": true 18 | }, 19 | "include": ["src", "rollup.config.ts"] 20 | } 21 | --------------------------------------------------------------------------------