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