@caeljs/config
Version:
Simple system of standardization of configurations for node js and bun.
685 lines (676 loc) • 20.5 kB
JavaScript
// src/utils/object.ts
function getProperty(obj, path3) {
return path3.split(".").reduce((acc, key) => acc?.[key], obj);
}
function setProperty(obj, path3, value) {
const keys = path3.split(".");
const lastKey = keys.pop();
let target = obj;
for (const key of keys) {
if (target[key] === void 0 || target[key] === null) {
target[key] = {};
} else if (typeof target[key] !== "object") {
throw new Error(`Cannot set property on non-object at path: ${key}`);
}
target = target[key];
}
target[lastKey] = value;
}
function isObject(item) {
return item !== null && typeof item === "object" && !Array.isArray(item);
}
function deepMerge(target, source) {
const output = { ...target };
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
const sourceValue = source[key];
const targetValue = target[key];
if (isObject(sourceValue) && isObject(targetValue)) {
output[key] = deepMerge(
targetValue,
sourceValue
);
} else {
output[key] = sourceValue;
}
});
}
return output;
}
function flattenObject(obj, prefix = "") {
return Object.keys(obj).reduce(
(acc, k) => {
const pre = prefix.length ? `${prefix}.` : "";
if (isObject(obj[k])) {
Object.assign(acc, flattenObject(obj[k], pre + k));
} else {
acc[pre + k] = obj[k];
}
return acc;
},
{}
);
}
function unflattenObject(obj) {
const result = {};
for (const key in obj) {
setProperty(result, key, obj[key]);
}
return result;
}
function deleteProperty(obj, path3) {
const keys = path3.split(".");
const lastKey = keys.pop();
let target = obj;
for (const key of keys) {
if (target?.[key] === void 0) {
return false;
}
target = target[key];
}
if (typeof target === "object" && target !== null) {
return delete target[lastKey];
}
return false;
}
// src/utils/schema.ts
import {
Type
} from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
function addSmartDefaults(schemaNode) {
if (schemaNode.type !== "object" || !schemaNode.properties) {
return;
}
let allChildrenOptional = true;
for (const key in schemaNode.properties) {
const prop = schemaNode.properties[key];
if (prop.type === "object") {
addSmartDefaults(prop);
}
const hasDefault = prop.default !== void 0;
const isOptional = Value.Check(Type.Object({ temp: prop }), {});
if (!hasDefault && !isOptional) {
allChildrenOptional = false;
}
}
if (allChildrenOptional && schemaNode.default === void 0) {
schemaNode.default = {};
}
}
function buildTypeBoxSchema(definition) {
const properties = {};
for (const key in definition) {
const value = definition[key];
const isObject2 = typeof value === "object" && value !== null && !value[Symbol.for("TypeBox.Kind")];
if (isObject2) {
properties[key] = buildTypeBoxSchema(value);
} else {
properties[key] = value;
}
}
return Type.Object(properties, { additionalProperties: true });
}
function buildDefaultObject(definition) {
const obj = {};
for (const key in definition) {
const value = definition[key];
if (value[Symbol.for("TypeBox.Kind")]) {
if (value.default !== void 0) {
obj[key] = value.default;
}
} else if (typeof value === "object" && value !== null) {
obj[key] = buildDefaultObject(value);
}
}
return obj;
}
function makeSchemaOptional(definition) {
const newDefinition = {};
for (const key in definition) {
const value = definition[key];
if (value?.[Symbol.for("TypeBox.Kind")]) {
const schema = value;
const isOptional = Value.Check(Type.Object({ temp: schema }), {});
if (schema.important || isOptional) {
newDefinition[key] = schema;
} else {
newDefinition[key] = Type.Optional(schema);
}
} else if (typeof value === "object" && value !== null) {
newDefinition[key] = makeSchemaOptional(value);
} else {
newDefinition[key] = value;
}
}
return newDefinition;
}
// src/ConfigJS.ts
var ConfigJS = class {
driver;
schema;
loaded = false;
constructor(driver, schema) {
this.driver = driver;
this.schema = schema;
}
/**
* Loads the configuration.
* @param options - The loading options.
*/
load(options) {
let schemaToLoad = this.schema;
if (options?.only_importants) {
schemaToLoad = makeSchemaOptional(this.schema);
}
const result = this.driver.load(schemaToLoad, options);
if (this.driver.async) {
return result.then(() => {
this.loaded = true;
});
}
this.loaded = true;
return result;
}
get(path3) {
if (!this.loaded) {
throw new Error("[ConfigJS] Config not loaded. Call load() first.");
}
return this.driver.get(path3);
}
has(...paths) {
if (!this.loaded) {
throw new Error("[ConfigJS] Config not loaded. Call load() first.");
}
return this.driver.has(...paths);
}
root(path3) {
if (!this.loaded) {
throw new Error("[ConfigJS] Config not loaded. Call load() first.");
}
return this.driver.get(path3);
}
set(path3, value, options) {
if (!this.loaded) {
throw new Error("[ConfigJS] Config not loaded. Call load() first.");
}
return this.driver.set(path3, value, options);
}
insert(path3, partial) {
if (!this.loaded) {
throw new Error("[ConfigJS] Config not loaded. Call load() first.");
}
return this.driver.insert(path3, partial);
}
del(path3) {
if (!this.loaded) {
throw new Error("[ConfigJS] Config not loaded. Call load() first.");
}
return this.driver.del(path3);
}
conf(path3) {
if (!this.loaded) {
throw new Error("[ConfigJS] Config not loaded. Call load() first.");
}
return getProperty(this.schema, path3);
}
};
// src/drivers/env-driver.ts
import * as fs from "node:fs";
import * as path from "node:path";
// src/driver.ts
import { Value as Value2 } from "@sinclair/typebox/value";
var ConfigJSDriver = class {
identify;
async = void 0;
config;
data = {};
comments;
compiledSchema;
store = {};
_onSet;
_onDel;
// Utilities passed to drivers
buildDefaultObject = buildDefaultObject;
deepMerge = deepMerge;
constructor(options) {
this.identify = options.identify;
this.async = options.async;
this.config = options.config || {};
this._onLoad = options.onLoad;
this._onSet = options.onSet;
this._onDel = options.onDel;
}
load(schema, options = {}) {
this.compiledSchema = buildTypeBoxSchema(schema);
addSmartDefaults(this.compiledSchema);
this.config = { ...this.config, ...options };
const processResult = (result) => {
this.data = result;
this.validate(this.data);
};
if (this._onLoad) {
const loadResult = this._onLoad.call(this, schema, this.config);
if (this.async) {
return loadResult.then(processResult);
}
processResult(loadResult);
}
return void 0;
}
get(path3) {
const value = getProperty(this.data, path3);
if (this.async) {
return Promise.resolve(value);
}
return value;
}
has(...paths) {
const hasAllProps = paths.every(
(path3) => getProperty(this.data, path3) !== void 0
);
if (this.async) {
return Promise.resolve(hasAllProps);
}
return hasAllProps;
}
set(path3, value, options) {
setProperty(this.data, path3, value);
if (this._onSet) {
return this._onSet.call(this, path3, value, options);
}
return this.async ? Promise.resolve() : void 0;
}
insert(path3, partial) {
const currentObject = getProperty(this.data, path3);
if (typeof currentObject !== "object" || currentObject === null) {
throw new Error(`Cannot insert into non-object at path: ${path3}`);
}
Object.assign(currentObject, partial);
return this.set(path3, currentObject);
}
del(path3) {
deleteProperty(this.data, path3);
if (this._onDel) {
return this._onDel.call(this, path3);
}
return this.async ? Promise.resolve() : void 0;
}
validate(config = this.data) {
if (!this.compiledSchema) return;
Value2.Default(this.compiledSchema, config);
Value2.Convert(this.compiledSchema, config);
if (!Value2.Check(this.compiledSchema, config)) {
const errors = [...Value2.Errors(this.compiledSchema, config)];
throw new Error(
`[ConfigJS] Validation failed:
${errors.map((e) => `- ${e.path}: ${e.message}`).join("\n")}`
);
}
this.data = config;
}
};
// src/utils/env.ts
function parse(content) {
const result = {};
const lines = content.split(/\r?\n/);
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith("#")) {
continue;
}
const match = trimmedLine.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/);
if (!match) continue;
const [, key, rawValue] = match;
let value = rawValue.trim();
if (!value.startsWith('"') && !value.startsWith("'")) {
const commentIndex = value.indexOf("#");
if (commentIndex > -1) {
value = value.substring(0, commentIndex).trim();
}
}
if (value.startsWith("'") && value.endsWith("'")) {
value = value.substring(1, value.length - 1);
} else if (value.startsWith('"') && value.endsWith('"')) {
value = value.substring(1, value.length - 1);
}
result[key] = value;
}
return result;
}
function updateEnvContent(content, key, value, description) {
const lines = content.split(/\r?\n/);
let keyFound = false;
const newLines = [...lines];
let formattedValue;
if (Array.isArray(value)) {
formattedValue = JSON.stringify(value);
} else {
const stringValue = String(value);
formattedValue = /[\s"'#]/.test(stringValue) ? `"${stringValue.replace(/"/g, '"').replace(/\n/g, "\\n")}"` : stringValue;
}
let lineIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (new RegExp(`^s*${key}s*=s*`).test(lines[i])) {
keyFound = true;
lineIndex = i;
break;
}
}
if (keyFound) {
newLines[lineIndex] = `${key}=${formattedValue}`;
if (description) {
const comment = `# ${description}`;
if (lineIndex === 0 || !newLines[lineIndex - 1].trim().startsWith(comment)) {
if (lineIndex > 0 && !newLines[lineIndex - 1].trim().startsWith("#")) {
newLines.splice(lineIndex, 0, comment);
}
}
}
} else {
if (newLines[newLines.length - 1] !== "") {
newLines.push("");
}
if (description) {
newLines.push(`# ${description}`);
}
newLines.push(`${key}=${formattedValue}`);
}
return newLines.join("\n");
}
function removeEnvKey(content, key) {
const lines = content.split(/\r?\n/);
const keyRegex = new RegExp(`^\\s*${key}\\s*=\\s*`);
const newLines = [];
for (const line of lines) {
if (keyRegex.test(line)) {
if (newLines.length > 0 && newLines[newLines.length - 1].trim().startsWith("#")) {
newLines.pop();
}
} else {
newLines.push(line);
}
}
return newLines.join("\n");
}
// src/drivers/env-driver.ts
function getFilePath(config) {
return path.resolve(process.cwd(), config.path || ".env");
}
function coerceType(value, schema) {
if (value === void 0) return void 0;
const type = schema.type;
if (type === "number") return Number(value);
if (type === "boolean") return String(value).toLowerCase() === "true";
if (type === "array" && typeof value === "string") {
const trimmedValue = value.trim();
if (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) {
try {
return JSON.parse(trimmedValue);
} catch {
}
}
}
return value;
}
function traverseSchema(schema, envValues, prefix = []) {
const builtConfig = {};
for (const key in schema) {
const currentPath = [...prefix, key];
const definition = schema[key];
const isTypeBoxSchema = (def) => !!def[Symbol.for("TypeBox.Kind")];
if (isTypeBoxSchema(definition)) {
const prop = definition.prop;
const envKey = prop || currentPath.join("_").toUpperCase();
let value = envValues[envKey];
if (value === void 0) {
value = definition.default;
}
if (value !== void 0) {
builtConfig[key] = coerceType(value, definition);
}
} else if (typeof definition === "object" && definition !== null) {
const nestedConfig = traverseSchema(
definition,
envValues,
currentPath
);
builtConfig[key] = nestedConfig;
}
}
return builtConfig;
}
var envDriver = new ConfigJSDriver({
identify: "env-driver",
async: false,
config: { path: ".env" },
onLoad(schema, _opts) {
const filePath = getFilePath(this.config);
const fileContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
const envFileValues = parse(fileContent);
const processEnv = Object.fromEntries(
Object.entries(process.env).filter(([, v]) => v !== void 0)
);
const allEnvValues = { ...processEnv, ...envFileValues };
const envData = traverseSchema(schema, allEnvValues);
const defaultData = this.buildDefaultObject(schema);
this.store = this.deepMerge(defaultData, envData);
return this.store;
},
onSet(key, value, options) {
const envKey = key.replace(/\./g, "_").toUpperCase();
const filePath = getFilePath(this.config);
const currentContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
const newContent = updateEnvContent(
currentContent,
envKey,
value,
options?.description
);
fs.writeFileSync(filePath, newContent);
},
onDel(key) {
const envKey = key.replace(/\./g, "_").toUpperCase();
const filePath = getFilePath(this.config);
if (!fs.existsSync(filePath)) {
return;
}
const currentContent = fs.readFileSync(filePath, "utf-8");
const newContent = removeEnvKey(currentContent, envKey);
fs.writeFileSync(filePath, newContent);
}
});
// src/drivers/json-driver.ts
import * as fs2 from "node:fs";
import * as path2 from "node:path";
function stripComments(data) {
const comments = {};
function recurse(currentData, prefix = "") {
const keys = Object.keys(currentData);
for (const key of keys) {
if (key.endsWith(":comment")) {
const dataKey = key.replace(/:comment$/, "");
const commentPath = prefix ? `${prefix}.${dataKey}` : dataKey;
comments[commentPath] = currentData[key];
delete currentData[key];
}
}
for (const key of keys) {
if (typeof currentData[key] === "object" && currentData[key] !== null && !key.endsWith(":comment")) {
const nestedPrefix = prefix ? `${prefix}.${key}` : key;
recurse(currentData[key], nestedPrefix);
}
}
}
recurse(data);
return comments;
}
function getFilePath2(config) {
return path2.resolve(process.cwd(), config.path || "config.json");
}
var jsonDriver = new ConfigJSDriver({
identify: "json-driver",
async: false,
config: { path: "config.json", keyroot: false },
onLoad(schema, _opts) {
this.comments = this.comments || {};
const defaultData = this.buildDefaultObject(schema);
const filePath = getFilePath2(this.config);
let loadedData = {};
if (fs2.existsSync(filePath)) {
try {
const fileContent = fs2.readFileSync(filePath, "utf-8");
if (fileContent) {
loadedData = JSON.parse(fileContent);
}
} catch (_e) {
}
}
if (this.config.keyroot) {
const flatData = loadedData;
const comments = {};
const data = {};
for (const key in flatData) {
if (key.endsWith(":comment")) {
comments[key.replace(/:comment$/, "")] = flatData[key];
} else {
data[key] = flatData[key];
}
}
this.comments = comments;
loadedData = unflattenObject(data);
} else {
this.comments = stripComments(loadedData);
}
this.store = this.deepMerge(defaultData, loadedData);
return this.store;
},
onSet(key, _value, options) {
this.comments = this.comments || {};
if (options?.description) {
this.comments[key] = options.description;
}
let dataToSave;
if (this.config.keyroot) {
dataToSave = flattenObject(this.data);
for (const path3 in this.comments) {
dataToSave[`${path3}:comment`] = this.comments[path3];
}
} else {
const dataWithComments = JSON.parse(JSON.stringify(this.data));
for (const path3 in this.comments) {
const keys = path3.split(".");
const propName = keys.pop();
const parentPath = keys.join(".");
const parentObject = parentPath ? getProperty(dataWithComments, parentPath) : dataWithComments;
if (typeof parentObject === "object" && parentObject !== null) {
parentObject[`${propName}:comment`] = this.comments[path3];
}
}
dataToSave = dataWithComments;
}
const filePath = getFilePath2(this.config);
fs2.writeFileSync(filePath, JSON.stringify(dataToSave, null, 2));
},
onDel(key) {
if (this.comments?.[key]) {
delete this.comments[key];
}
let dataToSave;
if (this.config.keyroot) {
dataToSave = flattenObject(this.data);
for (const path3 in this.comments) {
dataToSave[`${path3}:comment`] = this.comments[path3];
}
} else {
const dataWithComments = JSON.parse(JSON.stringify(this.data));
for (const path3 in this.comments) {
const keys = path3.split(".");
const propName = keys.pop();
const parentPath = keys.join(".");
const parentObject = parentPath ? getProperty(dataWithComments, parentPath) : dataWithComments;
if (typeof parentObject === "object" && parentObject !== null) {
parentObject[`${propName}:comment`] = this.comments[path3];
}
}
dataToSave = dataWithComments;
}
const filePath = getFilePath2(this.config);
fs2.writeFileSync(filePath, JSON.stringify(dataToSave, null, 2));
}
});
// src/factory.ts
import {
Type as Type2
} from "@sinclair/typebox";
function getEnumValues(values) {
if (Array.isArray(values)) {
return values;
}
const isNumericEnum = Object.values(values).some(
(v) => typeof v === "number"
);
if (isNumericEnum) {
return Object.values(values).filter(
(v) => typeof v === "number"
);
}
return Object.values(values).filter((v) => typeof v === "string");
}
var _c = {
/** Creates a String schema. */
String: (options) => Type2.String(options),
/** Creates a Number schema. */
Number: (options) => Type2.Number(options),
/** Creates a Boolean schema. */
Boolean: (options) => Type2.Boolean(options),
/** Creates an Object schema. */
Object: (properties, options) => Type2.Object(properties, options),
/** Creates an Array schema. */
Array: (items, options) => Type2.Array(items, options),
/** Creates a Record schema. */
Record: (key, value, options) => Type2.Record(key, value, options),
/** Creates a Union of Literals from a string array, const array, or a TypeScript enum. */
Enum: (values, options) => {
const enumValues = getEnumValues(values);
return Type2.Union(
enumValues.map((v) => Type2.Literal(v)),
options
//@ts-expect-error ignore
);
},
/** Creates a string schema with 'ipv4' format. */
IP: (options) => Type2.String({ ...options, format: "ipv4" }),
/** Creates a string schema with 'ipv6' format. */
IPv6: (options) => Type2.String({ ...options, format: "ipv6" }),
/** Creates a string schema with 'email' format. */
Email: (options) => Type2.String({ ...options, format: "email" }),
/** Creates a string schema with 'uri' format. */
URL: (options) => Type2.String({ ...options, format: "uri" }),
/** Creates an Any schema. */
Any: () => Type2.Any(),
/** Creates an Optional schema. */
Optional: (schema) => Type2.Optional(schema)
};
var c = {
..._c,
string: _c.String,
number: _c.Number,
boolean: _c.Boolean,
object: _c.Object,
array: _c.Array,
record: _c.Record,
enum: _c.Enum,
ip: _c.IP,
ipv6: _c.IPv6,
email: _c.Email,
url: _c.URL,
any: _c.Any,
optional: _c.Optional
};
export {
ConfigJS,
c,
envDriver,
jsonDriver
};