import * as fse from "fs-extra"; // Export dependencies for stubbing in tests. export const Deps = { readFile: fse.readFile, writeFile: fse.writeFile, pathExists: fse.pathExists, }; /** * Readonly config value - no write access. */ export interface IReadableConfigValue { get(): T; } /** * Writeable config value. Write behaviour is asynchronous and defined by the implementation. */ export interface IWritableConfigValue extends IReadableConfigValue { set(value: T): Promise; } type FileContentsValidator = (value: any) => T | null; export class MissingConfigFileError extends Error { public name: string = "MissingConfigFileError"; constructor(message: string) { super(message); } } export class ConfigValidationError extends Error { public name: string = "ConfigValidationError"; constructor(message: string) { super(message); } } export interface ConfigAccessors { get: () => ValueType, set?: (value: ValueType) => Promise } /** * Provides type safe access to an underlying JSON file. * * Multiple FileConfigs for the same file shouldn't be used, as they risk going out of sync. */ export class FileConfig { /** * Creates a new type-safe FileConfig, by loading and checking the contents of the file with `validator`. * @param configPath - Path to load. * @param validator - Validates the contents are in the correct format, and converts to the correct type. * Should throw an error or return null if not valid. */ public static async create( configPath: string, validator: FileContentsValidator ): Promise> { // Start with empty object, as it can be upgraded to a full config. let rawFileContents: any = {}; if (await Deps.pathExists(configPath)) { rawFileContents = JSON.parse(await Deps.readFile(configPath, 'utf8')); } let fileContents = null; try { fileContents = validator(rawFileContents); } catch (error) { const configError = new ConfigValidationError(`Config at ${configPath} failed validation: ${error.message}`); configError.cause = error; throw configError; } if (!fileContents) { throw new ConfigValidationError(`Config at ${configPath} failed validation - check the format?`); } return new FileConfig(configPath, fileContents); } constructor(private _filePath: string, private _rawConfig: FileContents) { } public get(key: Key): FileContents[Key] { return this._rawConfig[key]; } public async set(key: Key, value: FileContents[Key]) { this._rawConfig[key] = value; await this.persistToDisk(); } public async persistToDisk(): Promise { await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2)); } } /** * Creates a function for creating accessors for a given key. * Propagates undefined values, so if no file config is available, accessors are undefined. * @param fileConfig - Config to load/save values to. */ export function fileConfigAccessorFactory( fileConfig?: FileConfig ): (key: Key) => ConfigAccessors | undefined { if (!fileConfig) { return (key) => undefined; } return (key) => ({ get: () => fileConfig.get(key), set: (value) => fileConfig.set(key, value) }); } /** * Creates a config value optionally backed by persistent storage. * Can be used as an in-memory value without persistent storage. * @param defaultValue - Value to use if no persistent value is available. * @param persistence - Accessors for saving/loading persistent value. */ export function createConfigValue( defaultValue: ValueType, persistence?: ConfigAccessors | ConfigAccessors, ): IWritableConfigValue { let inMemoryValue = (persistence && persistence.get()); return { get(): ValueType { return inMemoryValue ?? defaultValue; }, async set(value: ValueType) { if (persistence && persistence.set) { await persistence.set(value); } inMemoryValue = value; } }; }