@openally/config
Version:
Reactive configuration loader
320 lines (317 loc) • 10.1 kB
JavaScript
// 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
};