UNPKG

@caeljs/config

Version:

Simple system of standardization of configurations for node js and bun.

685 lines (676 loc) 20.5 kB
// 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 };