├── .husky ├── .gitignore └── pre-commit ├── .travis.yml ├── tsconfig.package.json ├── .prettierrc ├── assets ├── react-runtime-config-logo.ai └── react-runtime-config-logo.png ├── src ├── utils.test.ts ├── utils.ts ├── createUseConfig.ts ├── createUseAdminConfig.ts ├── parsers.ts ├── index.ts ├── types.ts └── index.test.ts ├── tsconfig.json ├── .eslintrc.js ├── LICENSE ├── package.json ├── .gitignore └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "14.16.1" 4 | cache: yarn 5 | -------------------------------------------------------------------------------- /tsconfig.package.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["src/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 120, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /assets/react-runtime-config-logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contiamo/react-runtime-config/HEAD/assets/react-runtime-config-logo.ai -------------------------------------------------------------------------------- /assets/react-runtime-config-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contiamo/react-runtime-config/HEAD/assets/react-runtime-config-logo.png -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { capitalize } from "./utils"; 2 | 3 | describe("capitalize", () => { 4 | const tests: Array<[string, string]> = [ 5 | ["", ""], 6 | ["a", "A"], 7 | ["A", "A"], 8 | ["al", "Al"], 9 | ["sTrAnGe", "STrAnGe"], 10 | ]; 11 | 12 | tests.forEach(([input, expected]) => { 13 | it(`should return "${expected}" for "${input}"`, () => expect(capitalize(input)).toBe(expected)); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "skipLibCheck": true, 6 | "target": "es5", 7 | "module": "CommonJS", 8 | "lib": ["dom", "es2017"], 9 | "jsx": "react", 10 | "declaration": true, 11 | "declarationMap": true, 12 | "downlevelIteration": true, 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "moduleResolution": "node", 19 | "allowSyntheticDefaultImports": true, 20 | "esModuleInterop": true, 21 | "inlineSourceMap": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("eslint").Linter.Config} 3 | */ 4 | module.exports = { 5 | parser: "@typescript-eslint/parser", 6 | 7 | parserOptions: { 8 | ecmaVersion: 2020, 9 | sourceType: "module", 10 | }, 11 | extends: ["plugin:@typescript-eslint/recommended", "prettier", "plugin:prettier/recommended"], 12 | rules: { 13 | "@typescript-eslint/no-var-requires": 0, 14 | "@typescript-eslint/explicit-module-boundary-types": 0, 15 | "@typescript-eslint/no-explicit-any": 0, 16 | "@typescript-eslint/no-unused-vars": [ 17 | "error", 18 | { 19 | varsIgnorePattern: "^_", 20 | argsIgnorePattern: "^_", 21 | ignoreRestSiblings: true, 22 | }, 23 | ], 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | /** 4 | * Utils to provide a key that depends to the localStorage state. 5 | * 6 | * @param storage 7 | * @param localOverride 8 | */ 9 | export const useWatchLocalStorageEvents = (storage: Storage, localOverride: boolean) => { 10 | const [key, setKey] = React.useState(0); 11 | 12 | React.useEffect(() => { 13 | const onStorageUpdate = (_: StorageEvent) => { 14 | if (storage && localOverride) { 15 | setKey(i => (i + 1) % 10); 16 | } 17 | }; 18 | 19 | window.addEventListener("storage", onStorageUpdate); 20 | 21 | return () => window.removeEventListener("storage", onStorageUpdate); 22 | }, [storage, localOverride, setKey]); 23 | 24 | return key; 25 | }; 26 | 27 | export function capitalize(str: T) { 28 | if (!str || str.length < 1) return "" as Capitalize; 29 | return (str[0].toUpperCase() + str.slice(1)) as Capitalize; 30 | } 31 | -------------------------------------------------------------------------------- /src/createUseConfig.ts: -------------------------------------------------------------------------------- 1 | import { Config, InjectedProps, NamespacedUseConfigReturnType } from "./types"; 2 | import { useCallback } from "react"; 3 | import { useWatchLocalStorageEvents, capitalize } from "./utils"; 4 | 5 | export function createUseConfig, Namespace extends string>( 6 | props: InjectedProps, 7 | ) { 8 | return () => { 9 | const localStorageDependency = useWatchLocalStorageEvents(props.storage, props.localOverride); 10 | 11 | const getConfig = useCallback(props.getConfig, [localStorageDependency]); 12 | const getAllConfig = useCallback(props.getAllConfig, [localStorageDependency]); 13 | 14 | return { 15 | [`get${capitalize(props.configNamespace)}Config`]: getConfig, 16 | [`getAll${capitalize(props.configNamespace)}Config`]: getAllConfig, 17 | [`set${capitalize(props.configNamespace)}Config`]: props.setConfig, 18 | } as NamespacedUseConfigReturnType; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Contiamo GmbH 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 | -------------------------------------------------------------------------------- /src/createUseAdminConfig.ts: -------------------------------------------------------------------------------- 1 | import { InjectedProps, Config, ResolvedConfigValue, AdminFields } from "./types"; 2 | import { useCallback, useMemo } from "react"; 3 | import { useWatchLocalStorageEvents } from "./utils"; 4 | 5 | export function createUseAdminConfig, TNamespace extends string>( 6 | props: InjectedProps, 7 | ) { 8 | return () => { 9 | const localStorageDependency = useWatchLocalStorageEvents(props.storage, props.localOverride); 10 | 11 | const configKeys: (keyof TSchema)[] = useMemo(() => Object.keys(props.schema), [props.schema]); 12 | 13 | const fields = useMemo(() => { 14 | return configKeys.map(key => ({ 15 | key, 16 | path: `${props.namespace}.${key}`, 17 | ...props.schema[key], 18 | windowValue: props.getWindowValue(key), 19 | storageValue: props.getStorageValue(key), 20 | isFromStorage: props.getStorageValue(key) !== null, 21 | value: props.getConfig(key), 22 | set: (value: ResolvedConfigValue) => props.setConfig(key, value), 23 | })) as AdminFields; 24 | }, [localStorageDependency, configKeys]); 25 | 26 | const reset = useCallback(() => { 27 | configKeys.forEach(path => { 28 | props.storage.removeItem(`${props.namespace}.${path}`); 29 | }); 30 | window.dispatchEvent(new Event("storage")); 31 | }, [configKeys, props.namespace]); 32 | 33 | return { 34 | /** 35 | * List of all config values 36 | */ 37 | fields, 38 | 39 | /** 40 | * Reset the store 41 | */ 42 | reset, 43 | 44 | /** 45 | * Namespace 46 | */ 47 | namespace: props.namespace, 48 | }; 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-runtime-config", 3 | "version": "3.0.2", 4 | "description": "Provide a typesafe runtime configuration inside a react app", 5 | "repository": { 6 | "type": "git", 7 | "url": "git@github.com:contiamo/react-runtime-config.git" 8 | }, 9 | "main": "lib/index.js", 10 | "typings": "lib/index.d.ts", 11 | "files": [ 12 | "lib" 13 | ], 14 | "scripts": { 15 | "start": "jest --watch", 16 | "test": "jest", 17 | "build": "tsc -p tsconfig.package.json", 18 | "prepublishOnly": "yarn test --ci && yarn build", 19 | "format": "eslint src/*.{ts,tsx} --fix && prettier src/*.{ts,tsx,json} --write" 20 | }, 21 | "keywords": [ 22 | "typescript", 23 | "react", 24 | "config", 25 | "configuration", 26 | "runtime" 27 | ], 28 | "author": "Fabien Bernard ", 29 | "license": "MIT", 30 | "devDependencies": { 31 | "@testing-library/react-hooks": "^5.1.1", 32 | "@types/jest": "^26.0.22", 33 | "@types/lodash": "^4.14.168", 34 | "@types/react": "^16.14.4", 35 | "@typescript-eslint/eslint-plugin": "^4.22.0", 36 | "@typescript-eslint/parser": "^4.22.0", 37 | "eslint": "^7.24.0", 38 | "eslint-config-prettier": "^8.1.0", 39 | "eslint-plugin-prettier": "^3.3.1", 40 | "husky": "^6.0.0", 41 | "jest": "^26.6.3", 42 | "prettier": "^2.2.1", 43 | "pretty-quick": "^3.1.0", 44 | "react": "^17.0.2", 45 | "react-dom": "^17.0.2", 46 | "react-test-renderer": "^17.0.2", 47 | "ts-jest": "^26.5.3", 48 | "ts-mockery": "^1.2.0", 49 | "typescript": "^4.2.4" 50 | }, 51 | "dependencies": { 52 | "lodash": "^4.17.21" 53 | }, 54 | "peerDependencies": { 55 | "react": ">=16" 56 | }, 57 | "jest": { 58 | "preset": "ts-jest", 59 | "testEnvironment": "jsdom", 60 | "testMatch": [ 61 | "**/*.test.ts" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/parsers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Config, 3 | isBooleanConfig, 4 | isCustomConfig, 5 | isNumberConfig, 6 | isStringConfig, 7 | isStringEnumConfig, 8 | ResolvedConfigValue, 9 | } from "./types"; 10 | 11 | function parseString(value: unknown): string { 12 | if (typeof value !== "string") { 13 | throw new Error("not a string"); 14 | } 15 | return value; 16 | } 17 | 18 | function parseNumber(value: unknown): number { 19 | if (typeof value === "number" && Number.isFinite(value)) { 20 | return value; 21 | } 22 | if (typeof value === "string") { 23 | if (!Number.isFinite(parseFloat(value))) { 24 | throw new Error("not a number"); 25 | } 26 | return parseFloat(value); 27 | } 28 | throw new Error("not a number"); 29 | } 30 | 31 | function parseBoolean(value: unknown): boolean { 32 | if (typeof value === "boolean") { 33 | return value; 34 | } 35 | if (typeof value === "string" && ["true", "false"].includes(value.toLowerCase())) { 36 | return value === "true"; 37 | } 38 | throw new Error("not a boolean"); 39 | } 40 | 41 | export function parse(value: unknown, config: TConfig): ResolvedConfigValue { 42 | if (isStringEnumConfig(config)) { 43 | const parsedString = parseString(value) as ResolvedConfigValue; 44 | if (!config.enum.includes(parsedString as any)) { 45 | throw new Error(`${parsedString} not part of [${config.enum.map(i => `"${i}"`).join(", ")}]`); 46 | } 47 | return parsedString; 48 | } 49 | if (isStringConfig(config)) { 50 | return parseString(value) as ResolvedConfigValue; 51 | } 52 | if (isNumberConfig(config)) { 53 | const parsedNumber = parseNumber(value) as ResolvedConfigValue; 54 | if (typeof config.min === "number" && parsedNumber < config.min) { 55 | throw new Error(`${parseNumber} should be greater than ${config.min}`); 56 | } 57 | if (typeof config.max === "number" && parsedNumber > config.max) { 58 | throw new Error(`${parseNumber} should be lower than ${config.max}`); 59 | } 60 | return parsedNumber; 61 | } 62 | if (isBooleanConfig(config)) { 63 | return parseBoolean(value) as ResolvedConfigValue; 64 | } 65 | if (isCustomConfig(config)) { 66 | return config.parser(value) as ResolvedConfigValue; 67 | } 68 | throw new Error("unknown config type"); 69 | } 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/macos,node,vim,visualstudiocode,windows 2 | 3 | ### macOS ### 4 | # General 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | 9 | # Icon must end with two \r 10 | Icon 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear in the root of a volume 16 | .DocumentRevisions-V100 17 | .fseventsd 18 | .Spotlight-V100 19 | .TemporaryItems 20 | .Trashes 21 | .VolumeIcon.icns 22 | .com.apple.timemachine.donotpresent 23 | 24 | # Directories potentially created on remote AFP share 25 | .AppleDB 26 | .AppleDesktop 27 | Network Trash Folder 28 | Temporary Items 29 | .apdisk 30 | 31 | ### Node ### 32 | # Logs 33 | logs 34 | *.log 35 | npm-debug.log* 36 | yarn-debug.log* 37 | yarn-error.log* 38 | 39 | # Runtime data 40 | pids 41 | *.pid 42 | *.seed 43 | *.pid.lock 44 | 45 | # Directory for instrumented libs generated by jscoverage/JSCover 46 | lib-cov 47 | 48 | # Coverage directory used by tools like istanbul 49 | coverage 50 | 51 | # nyc test coverage 52 | .nyc_output 53 | 54 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 55 | .grunt 56 | 57 | # Bower dependency directory (https://bower.io/) 58 | bower_components 59 | 60 | # node-waf configuration 61 | .lock-wscript 62 | 63 | # Compiled binary addons (https://nodejs.org/api/addons.html) 64 | build/Release 65 | 66 | # Dependency directories 67 | node_modules/ 68 | jspm_packages/ 69 | 70 | # TypeScript v1 declaration files 71 | typings/ 72 | 73 | # Optional npm cache directory 74 | .npm 75 | 76 | # Optional eslint cache 77 | .eslintcache 78 | 79 | # Optional REPL history 80 | .node_repl_history 81 | 82 | # Output of 'npm pack' 83 | *.tgz 84 | 85 | # Yarn Integrity file 86 | .yarn-integrity 87 | 88 | # dotenv environment variables file 89 | .env 90 | 91 | # parcel-bundler cache (https://parceljs.org/) 92 | .cache 93 | 94 | # next.js build output 95 | .next 96 | 97 | # nuxt.js build output 98 | .nuxt 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # Serverless directories 104 | .serverless 105 | 106 | ### Vim ### 107 | # Swap 108 | [._]*.s[a-v][a-z] 109 | [._]*.sw[a-p] 110 | [._]s[a-rt-v][a-z] 111 | [._]ss[a-gi-z] 112 | [._]sw[a-p] 113 | 114 | # Session 115 | Session.vim 116 | 117 | # Temporary 118 | .netrwhist 119 | *~ 120 | # Auto-generated tag files 121 | tags 122 | # Persistent undo 123 | [._]*.un~ 124 | 125 | ### VisualStudioCode ### 126 | .vscode/* 127 | !.vscode/settings.json 128 | !.vscode/tasks.json 129 | !.vscode/launch.json 130 | !.vscode/extensions.json 131 | 132 | ### Windows ### 133 | # Windows thumbnail cache files 134 | Thumbs.db 135 | ehthumbs.db 136 | ehthumbs_vista.db 137 | 138 | # Dump file 139 | *.stackdump 140 | 141 | # Folder config file 142 | [Dd]esktop.ini 143 | 144 | # Recycle Bin used on file shares 145 | $RECYCLE.BIN/ 146 | 147 | # Windows Installer files 148 | *.cab 149 | *.msi 150 | *.msix 151 | *.msm 152 | *.msp 153 | 154 | # Windows shortcuts 155 | *.lnk 156 | 157 | 158 | # End of https://www.gitignore.io/api/macos,node,vim,visualstudiocode,windows 159 | 160 | # Build files 161 | dist 162 | lib 163 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import get from "lodash/get"; 2 | import { parse } from "./parsers"; 3 | import { 4 | ConfigOptions, 5 | InjectedProps, 6 | Config, 7 | ResolvedSchema, 8 | isStringEnumConfig, 9 | isNumberConfig, 10 | isCustomConfig, 11 | isBooleanConfig, 12 | isStringConfig, 13 | StringConfig, 14 | StringEnumConfig, 15 | NumberConfig, 16 | BooleanConfig, 17 | CustomConfig, 18 | AdminField, 19 | AdminFields, 20 | GenericAdminFields, 21 | NamespacedUseConfigReturnType, 22 | } from "./types"; 23 | import { createUseAdminConfig } from "./createUseAdminConfig"; 24 | import { createUseConfig } from "./createUseConfig"; 25 | 26 | export { 27 | // -- Options -- 28 | ConfigOptions, 29 | // -- Configs -- 30 | Config, 31 | StringConfig, 32 | StringEnumConfig, 33 | NumberConfig, 34 | BooleanConfig, 35 | CustomConfig, 36 | // -- Typeguards -- 37 | isStringEnumConfig, 38 | isNumberConfig, 39 | isCustomConfig, 40 | isBooleanConfig, 41 | isStringConfig, 42 | // -- useConfigAdmin.fields -- 43 | AdminField, 44 | AdminFields, 45 | GenericAdminFields, 46 | }; 47 | 48 | export function createConfig, TNamespace extends string = "">( 49 | options: ConfigOptions, 50 | ) { 51 | const injected: Pick, keyof ConfigOptions> = { 52 | storage: window.localStorage, 53 | localOverride: true, 54 | configNamespace: "" as TNamespace, 55 | ...options, 56 | }; 57 | 58 | /** 59 | * Get a config value from the storage (localStorage by default) 60 | */ 61 | const getStorageValue = (path: keyof TSchema) => { 62 | if (injected.storage && injected.localOverride) { 63 | try { 64 | let rawValue = injected.storage.getItem(`${injected.namespace}.${path}`); 65 | try { 66 | rawValue = JSON.parse(rawValue || ""); // Handle objects stored as string 67 | } catch {} 68 | return parse(rawValue, options.schema[path]); 69 | } catch { 70 | return null; 71 | } 72 | } else { 73 | return null; 74 | } 75 | }; 76 | 77 | /** 78 | * Get a config value from window 79 | * 80 | * @throws 81 | */ 82 | const getWindowValue = (path: keyof TSchema) => { 83 | try { 84 | const rawValue = get(window, `${injected.namespace}.${path}`, null); 85 | return rawValue === null ? null : parse(rawValue, options.schema[path]); 86 | } catch (e) { 87 | throw new Error(`Config key "${path}" not valid: ${e.message}`); 88 | } 89 | }; 90 | 91 | /** 92 | * Get a config value from storage, window or defaultValues 93 | * 94 | * @throws 95 | */ 96 | function getConfig>(path: K): ResolvedSchema[K] { 97 | const defaultValue = 98 | typeof options.schema[path].default === "function" 99 | ? (options.schema[path].default as () => ResolvedSchema[K])() 100 | : (options.schema[path].default as ResolvedSchema[K]); 101 | const storageValue = getStorageValue(path); 102 | const windowValue = getWindowValue(path); 103 | 104 | if (defaultValue === undefined && windowValue === null) { 105 | throw new Error(`Config key "${path}" need to be defined in "window.${injected.namespace}.${path}!`); 106 | } 107 | 108 | return storageValue !== null ? storageValue : windowValue !== null ? windowValue : defaultValue; 109 | } 110 | 111 | /** 112 | * Set a config value in the storage. 113 | * This will also remove the value if the value is the same as the window one. 114 | * 115 | * @throws 116 | */ 117 | function setConfig>(path: K, value: ResolvedSchema[K]) { 118 | const config = options.schema[path]; 119 | try { 120 | parse(value, config); // Runtime validation of the value 121 | } catch (e) { 122 | if (isCustomConfig(config)) { 123 | throw e; 124 | } 125 | if (isStringEnumConfig(config)) { 126 | throw new Error(`Expected "${path}=${value}" to be one of: ${config.enum.join(", ")}`); 127 | } else if (isNumberConfig(config) && Number.isFinite(value)) { 128 | if (typeof config.min === "number" && value < config.min) { 129 | throw new Error(`Expected "${path}=${value}" to be greater than ${config.min}`); 130 | } 131 | if (typeof config.max === "number" && value > config.max) { 132 | throw new Error(`Expected "${path}=${value}" to be lower than ${config.max}`); 133 | } 134 | } 135 | 136 | throw new Error(`Expected "${path}=${value}" to be a "${config.type}"`); 137 | } 138 | if (getWindowValue(path) === value || config.default === value) { 139 | injected.storage.removeItem(`${injected.namespace}.${path}`); 140 | } else { 141 | const encodedValue = typeof value === "string" ? value : JSON.stringify(value); 142 | injected.storage.setItem(`${injected.namespace}.${path}`, encodedValue); 143 | } 144 | window.dispatchEvent(new Event("storage")); 145 | } 146 | 147 | /** 148 | * Get all consolidate config values. 149 | */ 150 | function getAllConfig(): ResolvedSchema { 151 | return Object.keys(options.schema).reduce( 152 | (mem, key) => ({ ...mem, [key]: getConfig(key) }), 153 | {} as ResolvedSchema, 154 | ); 155 | } 156 | 157 | // Validate all config from `window.{namespace}` 158 | getAllConfig(); 159 | 160 | return { 161 | useConfig: createUseConfig({ 162 | getConfig, 163 | getAllConfig, 164 | getStorageValue, 165 | getWindowValue, 166 | setConfig, 167 | ...injected, 168 | }) as () => NamespacedUseConfigReturnType, // Hint for the build 169 | useAdminConfig: createUseAdminConfig({ 170 | getConfig, 171 | getAllConfig, 172 | getStorageValue, 173 | getWindowValue, 174 | setConfig, 175 | ...injected, 176 | }), 177 | getConfig, 178 | setConfig, 179 | getAllConfig, 180 | }; 181 | } 182 | 183 | export default createConfig; 184 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ConfigOptions, TNamespace extends string> { 2 | /** 3 | * Namespace of the configuration 4 | * 5 | * This namespace is used to consume the configuration from `window` and `localStorage` 6 | */ 7 | namespace: string; 8 | 9 | /** 10 | * Schema of the configuration (used for runtime validation) 11 | */ 12 | schema: TSchema; 13 | 14 | /** 15 | * Storage adapter 16 | * 17 | * @default window.localStorage 18 | */ 19 | storage?: Storage; 20 | 21 | /** 22 | * Permit to override any config values in storage 23 | * 24 | * @default true 25 | */ 26 | localOverride?: boolean; 27 | 28 | /** 29 | * Namespace for `useConfig()` return methods. 30 | * 31 | * Example: 32 | * ``` 33 | * // MyConfig.ts 34 | * export const { useConfig } = createConfig({ 35 | * configNamespace: "hello" 36 | * }); 37 | * 38 | * // In a react component 39 | * const { 40 | * getHelloConfig, 41 | * setHelloConfig, 42 | * getAllHelloConfig, 43 | * } = useConfig(); 44 | * ``` 45 | */ 46 | configNamespace?: TNamespace; 47 | } 48 | 49 | export type Config = StringConfig | NumberConfig | BooleanConfig | CustomConfig; 50 | 51 | export interface StringConfig { 52 | type: "string"; 53 | enum?: string[]; 54 | default?: string | (() => string); 55 | description?: string; 56 | } 57 | 58 | export interface StringEnumConfig extends StringConfig { 59 | /** 60 | * List of allowed values 61 | */ 62 | enum: string[]; 63 | } 64 | 65 | export interface NumberConfig { 66 | type: "number"; 67 | min?: number; 68 | max?: number; 69 | default?: number | (() => number); 70 | description?: string; 71 | } 72 | 73 | export interface BooleanConfig { 74 | type: "boolean"; 75 | default?: boolean | (() => boolean); 76 | description?: string; 77 | } 78 | 79 | export interface CustomConfig { 80 | type: "custom"; 81 | default?: T | (() => T); 82 | description?: string; 83 | /** 84 | * Custom parser. 85 | * 86 | * Should throw an error if the value can't be parsed 87 | */ 88 | parser: (value: any) => T; 89 | } 90 | 91 | export type ResolvedSchema> = { 92 | [key in keyof TSchema]: ResolvedConfigValue; 93 | }; 94 | 95 | export type ResolvedConfigValue = TValue extends StringEnumConfig 96 | ? TValue["enum"][-1] 97 | : TValue extends StringConfig 98 | ? string 99 | : TValue extends NumberConfig 100 | ? number 101 | : TValue extends BooleanConfig 102 | ? boolean 103 | : TValue extends CustomConfig 104 | ? ReturnType 105 | : never; 106 | 107 | export const isStringConfig = (config: Config): config is StringConfig => config.type === "string"; 108 | export const isStringEnumConfig = (config: Config): config is StringEnumConfig => 109 | config.type === "string" && Array.isArray(config.enum); 110 | export const isNumberConfig = (config: Config): config is NumberConfig => config.type === "number"; 111 | export const isBooleanConfig = (config: Config): config is BooleanConfig => config.type === "boolean"; 112 | export const isCustomConfig = (config: Config): config is CustomConfig => config.type === "custom"; 113 | 114 | export interface InjectedProps< 115 | TSchema extends Record, 116 | TNamespace extends string, 117 | TConfig = ResolvedSchema 118 | > { 119 | namespace: string; 120 | configNamespace: TNamespace; 121 | schema: TSchema; 122 | storage: Storage; 123 | localOverride: boolean; 124 | getConfig: (key: K) => ResolvedConfigValue; 125 | setConfig: (key: K, value: ResolvedConfigValue) => void; 126 | getAllConfig: () => TConfig; 127 | getWindowValue: (key: K) => ResolvedConfigValue | null; 128 | getStorageValue: (key: K) => ResolvedConfigValue | null; 129 | } 130 | 131 | // useAdminConfig types 132 | export type AdminField, TKey extends keyof TSchema> = TSchema[TKey] & { 133 | /** 134 | * Schema key of the config 135 | */ 136 | key: TKey; 137 | /** 138 | * Full path of the config (with `namespace`) 139 | */ 140 | path: string; 141 | /** 142 | * Value stored in `window.{path}` 143 | */ 144 | windowValue: ResolvedConfigValue | null; 145 | /** 146 | * Value stored in `storage.getItem({path})` 147 | */ 148 | storageValue: ResolvedConfigValue | null; 149 | /** 150 | * True if a value is stored on the localStorage 151 | */ 152 | isFromStorage: boolean; 153 | /** 154 | * Computed value from storage, window, schema[key].default 155 | */ 156 | value: ResolvedConfigValue; 157 | /** 158 | * Value setter 159 | */ 160 | set: (value: ResolvedConfigValue) => void; 161 | }; 162 | 163 | type Lookup = K extends keyof T ? T[K] : never; 164 | type TupleFromInterface = Array> = { 165 | [I in keyof K]: Lookup; 166 | }; 167 | 168 | export type AdminFields> = TupleFromInterface< 169 | { 170 | [key in keyof TSchema]: AdminField; 171 | } 172 | >; 173 | 174 | // useAdminConfig generic types 175 | type AdminProps = { 176 | key: string; 177 | path: string; 178 | windowValue: T | null; 179 | storageValue: T | null; 180 | isFromStorage: boolean; 181 | value: T; 182 | set: (value: U) => void; 183 | }; 184 | 185 | /** 186 | * `useAdminConfig.fields` in a generic version. 187 | * 188 | * This should be used if you are implementing a generic component 189 | * that consume any `fields` as prop. 190 | * 191 | * Note: "custom" type and "string" with enum are defined as `any` to be 192 | * compatible with any schemas. You will need to validate them in your 193 | * implementation to retrieve a strict type. 194 | */ 195 | export type GenericAdminFields = Array< 196 | | (StringConfig & AdminProps) 197 | | (NumberConfig & AdminProps) 198 | | (BooleanConfig & AdminProps) 199 | | (StringEnumConfig & AdminProps) 200 | | (CustomConfig & AdminProps) 201 | >; 202 | 203 | // useConfig types 204 | type UseConfigReturnType> = { 205 | getConfig: (path: K) => ResolvedConfigValue; 206 | getAllConfig: () => ResolvedSchema; 207 | setConfig: (path: K, value: ResolvedConfigValue) => void; 208 | }; 209 | 210 | /** 211 | * Helper to inject a namespace inside the keys of the `useConfig` return type. 212 | * 213 | * example: 214 | * ``` 215 | * type Namespaced<{getConfig: any; getAllConfig: any; setConfig: any}, "foo"> 216 | * // { getFooConfig: any; getAllFooConfig: any; setFooConfig: any } 217 | * ``` 218 | */ 219 | type Namespaced = { 220 | [P in keyof T as P extends `getConfig` 221 | ? `get${Capitalize}Config` 222 | : P extends "getAllConfig" 223 | ? `getAll${Capitalize}Config` 224 | : `set${Capitalize}Config`]: T[P]; 225 | }; 226 | 227 | export type NamespacedUseConfigReturnType< 228 | TSchema extends Record, 229 | TNamespace extends string 230 | > = Namespaced, TNamespace>; 231 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | react-runtime-config 3 |
4 | 5 |

6 | Make your application easily configurable. 7 |

8 | 9 |

10 | A simple way to provide runtime configuration for your React application, with localStorage overrides and hot-reload support ⚡️! 11 |

12 | 13 |

14 | 15 | npm (tag) 16 | 17 | 18 | travis (tag) 19 | 20 | license MIT (tag) 21 |

22 | 23 | ## Why 24 | 25 | Most web applications usually need to support and function within a variety of distinct environments: local, development, staging, production, on-prem, etc. This project aims to provide flexibility to React applications by making certain properties configurable at runtime, allowing the app to be customized based on a pre-determined configmap respective to the environment. This is especially powerful when combined with [Kubernetes configmaps](https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/). 26 | 27 | Here are examples of some real-world values that can be helpful when configurable at runtime: 28 | 29 | - Primary Color 30 | - Backend API URL 31 | - Feature Flags 32 | - … 33 | 34 | ## How 35 | 36 | The configuration can be set by _either_: 37 | 38 | - setting a configuration property on `window` with reasonable defaults. Consider, 39 | 40 | ```js 41 | window.MY_APP_CONFIG = { 42 | primaryColor: "green", 43 | }; 44 | ``` 45 | 46 | - _or_ by setting a value in `localStorage`. Consider, 47 | 48 | ```js 49 | localStorage.setItem("MY_APP_CONFIG.primaryColor", "green"); 50 | ``` 51 | 52 | The `localStorage` option could provide a nice delineation between environments: you _could_ set your local environment to green, and staging to red for example, in order to never be confused about what you're looking at when developing locally and testing against a deployed development environment: if it's green, it's local. 53 | 54 | This configuration is then easily read by the simple React hook that this library exports. 55 | 56 | ## Getting started 57 | 58 | 1. `npm i react-runtime-config` 59 | 1. Create a namespace for your config: 60 | 61 | ```tsx 62 | // components/Config.tsx 63 | import createConfig from "react-runtime-config"; 64 | 65 | /** 66 | * `useConfig` and `useAdminConfig` are now React hooks that you can use in your app. 67 | * 68 | * `useConfig` provides config getter & setter, `useAdminConfig` provides data in order 69 | * to visualize your config map with ease. More on this further down. 70 | */ 71 | export const { useConfig, useAdminConfig } = createConfig({ 72 | namespace: "MY_APP_CONFIG", 73 | schema: { 74 | color: { 75 | type: "string", 76 | enum: ["blue" as const, "green" as const, "pink" as const], // `as const` is required to have nice autocompletion 77 | description: "Main color of the application", 78 | }, 79 | backend: { 80 | type: "string", 81 | description: "Backend url", // config without `default` need to be provided into `window.MY_APP_CONFIG` 82 | }, 83 | port: { 84 | type: "number", // This schema can be retrieved after in `useAdminConfig().fields` 85 | description: "Backend port", 86 | min: 1, 87 | max: 65535, 88 | default: 8000, // config with `default` don't have to be set on `window.MY_APP_CONFIG` 89 | }, 90 | monitoringLink: { 91 | type: "custom", 92 | description: "Link of the monitoring", 93 | parser: value => { 94 | if (typeof value === "object" && typeof value.url === "string" && typeof value.displayName === "string") { 95 | // The type will be inferred from the return type 96 | return { url: value.url as string, displayName: value.displayName as string }; 97 | } 98 | // This error will be shown if the `window.MY_APP_CONFIG.monitoringLink` can't be parsed or if we `setConfig` an invalid value 99 | throw new Error("Monitoring link invalid!"); 100 | }, 101 | }, 102 | isLive: { 103 | type: "boolean", 104 | default: false, 105 | }, 106 | }, 107 | }); 108 | ``` 109 | 110 | You can now use the created hooks everywhere in your application. Thoses hooks are totally typesafe, connected to your configuration. This means that you can easily track down all your configuration usage across your entire application and have autocompletion on the keys. 111 | 112 | ### Usage 113 | 114 | ```tsx 115 | // components/MyComponents.tsx 116 | import react from "React"; 117 | import { useConfig } from "./Config"; 118 | 119 | const MyComponent = () => { 120 | const { getConfig } = useConfig(); 121 | 122 | return

My title

; 123 | }; 124 | ``` 125 | 126 | The title will have a different color regarding our current environment. 127 | 128 | The priority of config values is as follows: 129 | 130 | - `localStorage.getItem("MY_APP_CONFIG.color")` 131 | - `window.MY_APP_CONFIG.color` 132 | - `schema.color.default` 133 | 134 | ## Namespaced `useConfig` hook 135 | 136 | In a large application, you may have multiple instances of `useConfig` from different `createConfig`. So far every `useConfig` will return a set of `getConfig`, `setConfig` and `getAllConfig`. 137 | 138 | To avoid any confusion or having to manually rename every usage of `useConfig` in a large application, you can use the `configNamespace` options. 139 | 140 | ```ts 141 | // themeConfig.ts 142 | export const { useConfig: useThemeConfig } = createConfig({ 143 | namespace: "theme", 144 | schema: {}, 145 | configNamespace: "theme", // <- here 146 | }); 147 | 148 | // apiConfig.ts 149 | export const { useConfig: useApiConfig } = createConfig({ 150 | namespace: "api", 151 | schema: {}, 152 | configNamespace: "api", // <- here 153 | }); 154 | 155 | // App.ts 156 | import { useThemeConfig } from "./themeConfig"; 157 | import { useApiConfig } from "./apiConfig"; 158 | 159 | export const App = () => { 160 | // All methods are now namespaces 161 | // no more name conflicts :) 162 | const { getThemeConfig } = useThemeConfig(); 163 | const { getApiConfig } = useApiConfig(); 164 | 165 | return
; 166 | }; 167 | ``` 168 | 169 | ## Create an Administration Page 170 | 171 | To allow easy management of your configuration, we provide a smart react hook called `useAdminConfig` that provides all the data that you need in order to assemble an awesome administration page where the configuration of your app can be referenced and managed. 172 | 173 | **Note:** we are using [`@operational/components`](https://github.com/contiamo/operational-components) for this example, but a UI of config values _can_ be assembled with any UI library, or even with plain ole HTML-tag JSX. 174 | 175 | ```ts 176 | // pages/ConfigurationPage.tsx 177 | import { Page, Card, Input, Button, Checkbox } from "@operational/components"; 178 | import { useAdminConfig } from "./components/Config"; 179 | 180 | export default () => { 181 | const { fields, reset } = useAdminConfig(); 182 | 183 | return ( 184 | 185 | 186 | {fields.map(field => 187 | field.type === "boolean" ? ( 188 | 189 | ) : ( 190 | 191 | ), 192 | )} 193 | 194 | 195 | 196 | ); 197 | }; 198 | ``` 199 | 200 | You have also access to `field.windowValue` and `field.storageValue` if you want implement more advanced UX on this page. 201 | 202 | ## Multiconfiguration admin page 203 | 204 | As soon as you have more than one configuration in your project, you might want to merge all thoses configurations in one administration page. Of course, you will want a kind of `ConfigSection` component that take the result of any `useAdminConfig()` (so `field`, `reset` and `namespace` as props). 205 | 206 | Spoiler alert, having this kind of component type safe can be tricky, indeed you can try use `ReturnType | ReturnType` as props but typescript will fight you (`Array.map` will tell you that the signature are not compatible). 207 | 208 | Anyway, long story short, this library provide you an easy way to with this: `GenericAdminFields` type. This type is compatible with every configuration and will provide you a nice framework to create an amazing UX. 209 | 210 | ```tsx 211 | import { GenericAdminFields } from "react-runtime-config"; 212 | 213 | export interface ConfigSectionProps { 214 | fields: GenericAdminFields; 215 | namespace: string; 216 | reset: () => void; 217 | } 218 | 219 | export const ConfigSection = ({ namespace, fields }: ConfigSectionProps) => { 220 | return ( 221 |
222 | {fields.map(f => { 223 | if (f.type === "string" && !f.enum) { 224 | return ; 225 | } 226 | if (f.type === "number") { 227 | return ; 228 | } 229 | if (f.type === "boolean") { 230 | return ; 231 | } 232 | if (f.type === "string" && f.enum) { 233 | // `f.set` can take `any` but you still have runtime validation if a wrong value is provided. 234 | return