UNPKG

@layerfig/config

Version:

Layer and runtime-validate type-safe configs for JavaScript apps.

374 lines (360 loc) 13.1 kB
//#region rolldown:runtime var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion const zod = __toESM(require("zod")); const es_toolkit_compat = __toESM(require("es-toolkit/compat")); const zod_mini = __toESM(require("zod/mini")); const node_path = __toESM(require("node:path")); const node_fs = __toESM(require("node:fs")); //#region src/parser/config-parser.ts /** * Abstract class for parsing configuration files. * It defines the interface for loading configuration data * and checks if a file extension is accepted. */ var ConfigParser = class { acceptedFileExtensions; constructor(options) { this.acceptedFileExtensions = options.acceptedFileExtensions; } acceptsExtension(fileExtension) { const ext = fileExtension.startsWith(".") ? fileExtension : `.${fileExtension}`; return this.acceptedFileExtensions.some((accepted) => accepted === ext || accepted === ext.slice(1)); } }; //#endregion //#region src/utils/escape-break-line.ts function escapeBreakLine(value) { if (typeof value !== "string") return value; return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); } //#endregion //#region src/utils/slot.ts function extractSlotsFromExpression(content, slotPrefix) { const slots = []; const regex = getTemplateRegex(slotPrefix); let match; while ((match = regex.exec(content)) !== null) { const [fullMatch, slotValue] = match; if (!slotValue) throw new Error("Slot value is missing"); slots.push(new Slot(fullMatch, slotValue)); } return slots; } function hasSlot(content, slotPrefix) { const regex = getTemplateRegex(slotPrefix); return regex.test(content); } var Slot = class { #references = []; #slotMatch; #slotContent; #fallbackValue; #separator = "::"; constructor(slotMatch, slotContent) { this.#slotMatch = slotMatch; this.#slotContent = slotContent; const slotParts = this.#slotContent.split(this.#separator); if (slotParts.length > 1 && slotParts[slotParts.length - 1]?.startsWith("-")) this.#fallbackValue = slotParts.pop()?.slice(1); for (const slotPart of slotParts) if (slotPart.startsWith("self.")) { const propertyPath = slotPart.trim().replace("self.", "").trim(); if (!propertyPath) throw new Error(`Invalid self-referencing slot pattern: "\${self.}". Object Path is missing.`); this.#references.push({ type: "self_reference", propertyPath }); } else this.#references.push({ type: "env_var", envVar: slotPart.trim() }); } get fallbackValue() { return this.#fallbackValue; } get slotMatch() { return this.#slotMatch; } get references() { return [...this.#references]; } }; function getTemplateRegex(slotPrefix) { const escapedPrefix = slotPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`${escapedPrefix}\\{([^}]*)\\}`, "g"); } //#endregion //#region src/sources/source.ts const UNDEFINED_MARKER = "___UNDEFINED_MARKER___"; var Source = class { maybeReplaceSlots(options) { const initialObject = options.transform(options.contentString); /** * If there's no slot, we don't need to do anything */ if (!hasSlot(options.contentString, options.slotPrefix)) return initialObject; const slots = this.#extractSlots(initialObject, options.slotPrefix); /** * At this moment it does not matter what parser the user had defined, * we're in the JS/JSON land. */ let updatedContentString = JSON.stringify(initialObject); for (const slot of slots) { let envVarValue; for (const reference of slot.references) { if (reference.type === "env_var") envVarValue = options.runtimeEnv[reference.envVar]; if (reference.type === "self_reference") { const partialObj = JSON.parse(updatedContentString); envVarValue = (0, es_toolkit_compat.get)(partialObj, reference.propertyPath); } if (envVarValue !== null && envVarValue !== void 0) break; } if (!envVarValue && slot.fallbackValue) envVarValue = slot.fallbackValue; const valueToInsert = envVarValue !== null && envVarValue !== void 0 ? String(envVarValue) : UNDEFINED_MARKER; updatedContentString = updatedContentString.replaceAll(slot.slotMatch, escapeBreakLine(valueToInsert)); } const partialConfig = this.#cleanUndefinedMarkers(JSON.parse(updatedContentString)); return partialConfig; } #extractSlots(value, slotPrefix) { const result = []; if (Array.isArray(value)) for (const item of value) result.push(...this.#extractSlots(item, slotPrefix)); else if (typeof value === "string") result.push(...extractSlotsFromExpression(value, slotPrefix)); else if (value && typeof value === "object") for (const [_, v] of Object.entries(value)) if (typeof v === "string") result.push(...extractSlotsFromExpression(v, slotPrefix)); else result.push(...this.#extractSlots(v, slotPrefix)); return result; } #cleanUndefinedMarkers(value) { if (value === UNDEFINED_MARKER) return void 0; if (typeof value === "string" && value.includes(UNDEFINED_MARKER)) return void 0; if (Array.isArray(value)) { const newList = []; for (const item of value) { const cleanedItem = this.#cleanUndefinedMarkers(item); if (cleanedItem !== void 0) newList.push(cleanedItem); } return newList; } if (value && typeof value === "object") { const result = {}; for (const [oKey, oValue] of Object.entries(value)) result[oKey] = this.#cleanUndefinedMarkers(oValue); return result; } return value; } }; //#endregion //#region src/sources/env-var.ts var EnvironmentVariableSource = class extends Source { #options; #prefixWithSeparator; constructor(options = {}) { super(); this.#options = EnvironmentVariableSourceOptions.parse(options); this.#prefixWithSeparator = `${this.#options.prefix}${this.#options.prefixSeparator}`; } loadSource({ runtimeEnv, slotPrefix }) { const envKeys = Object.keys(runtimeEnv).filter((key) => key.startsWith(this.#prefixWithSeparator)); const tempObject = {}; for (const envKey of envKeys) { const envVarValue = runtimeEnv[envKey]; if (envVarValue === void 0 || envVarValue === null) continue; const keyWithoutPrefix = envKey.replace(this.#prefixWithSeparator, ""); const keyParts = keyWithoutPrefix.split(this.#options.separator).join("."); const value = this.maybeReplaceSlots({ slotPrefix, contentString: String(envVarValue), runtimeEnv, transform: (content) => content }); (0, es_toolkit_compat.set)(tempObject, keyParts, value); } return tempObject; } }; const EnvironmentVariableSourceOptions = zod_mini.z.object({ prefix: zod_mini.z._default(zod_mini.z.optional(zod_mini.z.string()), "APP"), prefixSeparator: zod_mini.z._default(zod_mini.z.optional(zod_mini.z.string()), "_"), separator: zod_mini.z._default(zod_mini.z.optional(zod_mini.z.string()), "__") }); //#endregion //#region src/sources/object.ts var ObjectSource = class extends Source { #object; constructor(object) { super(); this.#object = object; } loadSource({ slotPrefix, runtimeEnv }) { return this.maybeReplaceSlots({ contentString: JSON.stringify(this.#object), slotPrefix, runtimeEnv, transform: (contentString) => JSON.parse(contentString) }); } }; //#endregion //#region src/parser/parser-json.ts var BasicJsonParser = class extends ConfigParser { constructor() { super({ acceptedFileExtensions: ["json"] }); } load(fileContent) { try { const content = JSON.parse(fileContent); return { ok: true, data: content }; } catch (error) { return { ok: false, error: error instanceof Error ? error : /* @__PURE__ */ new Error("Something went wrong while loading the file") }; } } }; const basicJsonParser = new BasicJsonParser(); //#endregion //#region src/types.ts const RuntimeEnvValue = zod_mini.z.union([ zod_mini.z.boolean(), zod_mini.z.null(), zod_mini.z.number(), zod_mini.z.string(), zod_mini.z.undefined() ]); const RuntimeEnv = zod_mini.z.pipe( /** * This transformation is needed because we're dealing with process.env in most of the cases. * It seems that process.env (for NodeJS environment) isn't a plain object for zod so * it'll never validate correctly. * * So this is a workaround to always "spread" the received object and transform it into a plain object. * @see https://github.com/colinhacks/zod/issues/5069#issuecomment-3166763647 */ zod_mini.z.transform((env) => { return Object.assign({}, env); }), zod_mini.z.record(zod_mini.z.string(), RuntimeEnvValue) ); const DEFAULT_SLOT_PREFIX = "$"; //#endregion //#region src/server/types.ts const ServerConfigBuilderOptionsSchema = zod_mini.z.object({ absoluteConfigFolderPath: zod_mini.z._default(zod_mini.z.string().check(zod_mini.z.refine((value) => node_path.default.isAbsolute(value), "Path must be absolute")), node_path.default.resolve(process.cwd(), "./config")), runtimeEnv: zod_mini.z._default(RuntimeEnv, process.env), parser: zod_mini.z._default(zod_mini.z.custom(), basicJsonParser), validate: zod_mini.z.custom(), slotPrefix: zod_mini.z._default(zod_mini.z.string(), DEFAULT_SLOT_PREFIX) }); //#endregion //#region src/server/config-builder.ts var ConfigBuilder = class { #options; #sources = []; constructor(options) { this.#options = ServerConfigBuilderOptionsSchema.parse(options); } build() { if (this.#sources.length === 0) throw new Error("No source was added. Please provide one by using .addSource(<source>)"); let partialConfig = {}; for (const source of this.#sources) { const data = source.loadSource(this.#options); partialConfig = (0, es_toolkit_compat.merge)({}, partialConfig, data); } return this.#options.validate(partialConfig, zod.z); } addSource(source) { if (source instanceof Source === false) throw new Error("Invalid source. Please provide a valid one (EnvironmentVariableSource, FileSource, or ObjectSource)"); this.#sources.push(source); return this; } }; //#endregion //#region src/utils/read-if-exist.ts function readIfExist(filePath) { if (node_fs.default.existsSync(filePath)) { const fileContent = node_fs.default.readFileSync(filePath, "utf8"); const fileContentResult = zod_mini.z.string().safeParse(fileContent); if (fileContentResult.success) return { ok: true, data: fileContentResult.data }; return { ok: false, error: "File content is not a string." }; } return { ok: false, error: `File "${filePath}" does not exist` }; } //#endregion //#region src/server/file-source.ts var FileSource = class extends Source { #fileName; constructor(fileName) { super(); this.#fileName = fileName; } loadSource(options) { /** * This is validated before this method is called. I've done this * just to get a type-safe type for the options and discard * the ClientConfigBuilderOptionsSchema (since it's an union). */ const validatedOptions = ServerConfigBuilderOptionsSchema.parse(options); const absoluteFilePath = node_path.default.resolve(validatedOptions.absoluteConfigFolderPath, this.#fileName); const fileExtension = this.#getFileExtension(absoluteFilePath); if (validatedOptions.parser.acceptsExtension(fileExtension) === false) throw new Error(`".${fileExtension}" file is not supported by this parser. Accepted files are: "${validatedOptions.parser.acceptedFileExtensions.join(", ")}"`); const fileContentResult = readIfExist(absoluteFilePath); if (fileContentResult.ok === false) throw new Error(fileContentResult.error); return this.maybeReplaceSlots({ contentString: fileContentResult.data, slotPrefix: validatedOptions.slotPrefix, runtimeEnv: validatedOptions.runtimeEnv, transform: (contentString) => { const parserResult = validatedOptions.parser.load(contentString); if (!parserResult.ok) throw parserResult.error; return parserResult.data; } }); } #getFileExtension(filePath) { return node_path.default.extname(filePath).slice(1); } }; //#endregion exports.ConfigBuilder = ConfigBuilder; exports.ConfigParser = ConfigParser; exports.EnvironmentVariableSource = EnvironmentVariableSource; exports.FileSource = FileSource; exports.ObjectSource = ObjectSource; Object.defineProperty(exports, 'z', { enumerable: true, get: function () { return zod.z; } }); //# sourceMappingURL=index.cjs.map