UNPKG

@openally/config

Version:

Reactive configuration loader

320 lines (317 loc) 10.1 kB
// src/AsynchronousConfig.class.ts import path from "path"; import nodeFs from "fs"; import { EventEmitter } from "events"; import { isDeepStrictEqual } from "util"; import Ajv from "ajv"; import Observable from "zen-observable"; import * as TOML from "smol-toml"; // src/utils.ts function formatAjvErrors(ajvErrors) { if (!Array.isArray(ajvErrors)) { return ""; } const stdout = []; for (const ajvError of ajvErrors) { const isProperty = ajvError.instancePath === "" ? "" : `property ${ajvError.instancePath} `; stdout.push(`${isProperty}${ajvError.message} `); } return stdout.join(""); } function limitObjectDepth(obj, depth = 0) { if (!obj || typeof obj !== "object") { return obj; } if (depth === 0) { return Object.keys(obj); } for (const [key, value] of Object.entries(obj)) { Reflect.set(obj, key, limitObjectDepth(value, depth - 1)); } return obj; } function deepGet(obj, path2) { const keys = path2.split("."); let value = obj; for (const key of keys) { if (!Reflect.has(value, key)) { return null; } value = Reflect.get(value, key); } return value; } function deepSet(obj, path2, value) { const keys = path2.split("."); const lastKey = keys.pop(); let current = obj; for (const key of keys) { if (!Reflect.has(current, key)) { Reflect.set(current, key, {}); } current = Reflect.get(current, key); } Reflect.set(current, lastKey, value); return obj; } // src/AsynchronousConfig.class.ts var kPayloadIdentifier = Symbol("payload"); var kSchemaIdentifier = Symbol("schema"); var kAjv = new Ajv({ useDefaults: true }); var kDefaultExtension = ".json"; var kSupportedExtensions = /* @__PURE__ */ new Set([".json", ".toml"]); var kDefaultSchema = { title: "Default config", type: "object", additionalProperties: true }; var AsynchronousConfig = class extends EventEmitter { #isDotFile = false; #isTOML = false; #configFilePath; #configSchemaFilePath; #createOnNoEntry; #autoReload; #writeOnSet; #scheduledLazyWrite; #autoReloadActivated = false; #configHasBeenRead = false; #subscriptionObservers = []; #jsonSchema; #cleanupTimeout; #watcher; #fs; constructor(configFilePath, options = /* @__PURE__ */ Object.create(null)) { super(); if (typeof configFilePath !== "string") { throw new TypeError("The configPath must be a string"); } if (typeof options !== "object") { throw new TypeError("The options must be an object"); } const { createOnNoEntry = false, autoReload = false, writeOnSet = false, jsonSchema, fs = nodeFs } = options; this.#fs = fs; const { dir, name, ext } = path.parse(configFilePath); this.#isDotFile = name.startsWith("."); if (this.#isDotFile) { this.#configFilePath = configFilePath; } else { let configFileExtension = ext; if (ext === "") { configFileExtension = this.#fs.existsSync(`${configFilePath}.toml`) ? ".toml" : kDefaultExtension; this.#configFilePath = `${configFilePath}${configFileExtension}`; } else { this.#configFilePath = configFilePath; } if (!kSupportedExtensions.has(configFileExtension)) { throw new Error("The config file extension should be .json or .toml, got: " + configFileExtension); } this.#isTOML = configFileExtension === ".toml"; } this.#configSchemaFilePath = `${path.join(dir, name)}.schema.json`; this[kPayloadIdentifier] = /* @__PURE__ */ Object.create(null); this[kSchemaIdentifier] = null; this.#createOnNoEntry = Boolean(createOnNoEntry); this.#autoReload = Boolean(autoReload); this.#writeOnSet = Boolean(writeOnSet); this.#subscriptionObservers = []; if (jsonSchema !== void 0) { if (typeof jsonSchema !== "object") { throw new TypeError("The options.jsonSchema must be an object"); } this.#jsonSchema = jsonSchema; } } get payload() { return structuredClone(this[kPayloadIdentifier]); } set payload(newPayload) { if (!this.#configHasBeenRead) { throw new Error("You must read config first before setting a new payload!"); } if (!newPayload) { throw new TypeError("Invalid payload argument (cannot be null or undefined)"); } if (isDeepStrictEqual(this[kPayloadIdentifier], newPayload)) { return; } const tempPayload = structuredClone(newPayload); if (this[kSchemaIdentifier](tempPayload) === false) { const ajvErrors = formatAjvErrors(this[kSchemaIdentifier].errors); const errorMessage = `Config.payload (setter) - AJV Validation failed with error(s) => ${ajvErrors}`; throw new Error(errorMessage); } this[kPayloadIdentifier] = tempPayload; for (const [fieldPath, subscriptionObservers] of this.#subscriptionObservers) { subscriptionObservers.next(this.get(fieldPath)); } } async read(defaultPayload) { if (typeof defaultPayload === "object" && !defaultPayload) { throw new TypeError("The defaultPayload must be an object"); } let JSONConfig; let JSONSchema; let writeOnDisk = false; try { let configFileContent = await this.#fs.promises.readFile(this.#configFilePath, "utf-8"); if (this.#isTOML === false && configFileContent.trim() === "") { configFileContent = "{}"; writeOnDisk = true; } JSONConfig = this.#isTOML ? TOML.parse(configFileContent) : JSON.parse(configFileContent); } catch (err) { const isSyntaxError = err.name === "SyntaxError" || err.name === "TomlError"; if (isSyntaxError || !this.#createOnNoEntry || Reflect.has(err, "code") && err.code !== "ENOENT") { throw err; } JSONConfig = defaultPayload ? defaultPayload : this[kPayloadIdentifier]; writeOnDisk = true; } try { const schemaFileContent = await this.#fs.promises.readFile(this.#configSchemaFilePath, "utf-8"); JSONSchema = JSON.parse(schemaFileContent); } catch (err) { if (Reflect.has(err, "code") && err.code !== "ENOENT") { throw err; } JSONSchema = this.#jsonSchema ?? kDefaultSchema; } this[kSchemaIdentifier] = kAjv.compile(JSONSchema); if (!this.#configHasBeenRead) { if (this.#cleanupTimeout) { clearInterval(this.#cleanupTimeout); } this.#cleanupTimeout = setInterval(() => { this.#subscriptionObservers = this.#subscriptionObservers.filter( ([, subscriptionObservers]) => !subscriptionObservers.closed ); }, 1e3); this.#cleanupTimeout.unref(); } this.#configHasBeenRead = true; try { this.payload = JSONConfig; } catch (error) { this.#configHasBeenRead = false; throw error; } if (writeOnDisk) { const autoReload = () => this.setupAutoReload(); this.once("error", () => { this.removeListener("configWritten", autoReload); }); this.once("configWritten", autoReload); this.#lazyWriteOnDisk(); } else { this.setupAutoReload(); } return this; } setupAutoReload() { if (!this.#configHasBeenRead) { throw new Error("You must read config first before setting up autoReload!"); } if (!this.#autoReload || this.#autoReloadActivated) { return false; } this.#watcher = this.#fs.watch( this.#configFilePath, { persistent: false }, async () => { try { if (!this.#configHasBeenRead) { return; } await this.read(); this.emit("reload"); } catch (err) { this.emit("error", err); } } ); this.#autoReloadActivated = true; this.emit("watcherInitialized"); return true; } observableOf(fieldPath, depth = Infinity) { const fieldValue = this.get(fieldPath, depth); return new Observable((observer) => { observer.next(fieldValue); this.#subscriptionObservers.push([fieldPath, observer]); }); } get(fieldPath, depth = Infinity) { if (!this.#configHasBeenRead) { throw new Error("You must read config first before getting a field!"); } if (typeof fieldPath !== "string") { throw new TypeError("The fieldPath must be a string"); } let value = deepGet(this.payload, fieldPath); if (value === null) { return null; } if (Number.isFinite(depth)) { value = limitObjectDepth(value, depth); } return value; } set(fieldPath, fieldValue) { if (!this.#configHasBeenRead) { throw new Error("You must read config first before setting a field!"); } if (typeof fieldPath !== "string") { throw new TypeError("The fieldPath must be a string"); } this.payload = deepSet(this.payload, fieldPath, fieldValue); if (this.#writeOnSet) { this.#lazyWriteOnDisk(); } return this; } async writeOnDisk() { if (!this.#configHasBeenRead) { throw new Error("You must read config first before writing it on the disk!"); } const data = this.#isTOML ? TOML.stringify(this[kPayloadIdentifier]) : JSON.stringify(this[kPayloadIdentifier], null, 2); await this.#fs.promises.writeFile(this.#configFilePath, data); this.emit("configWritten"); } #lazyWriteOnDisk() { if (this.#scheduledLazyWrite) { clearImmediate(this.#scheduledLazyWrite); } this.#scheduledLazyWrite = setImmediate( () => this.writeOnDisk().catch((error) => this.emit("error", error)) ); } async close() { if (!this.#configHasBeenRead) { return; } clearImmediate(this.#scheduledLazyWrite); if (this.#autoReloadActivated) { this.#watcher.close(); this.#autoReloadActivated = false; } for (const [, subscriptionObservers] of this.#subscriptionObservers) { subscriptionObservers.complete(); } this.#subscriptionObservers = []; clearInterval(this.#cleanupTimeout); await this.writeOnDisk(); this.#configHasBeenRead = false; this.emit("close"); } }; export { AsynchronousConfig };