UNPKG

@layerfig/config

Version:

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

341 lines (328 loc) 11.5 kB
import { z } from "zod"; import { get, merge, set } from "es-toolkit/compat"; import { z as z$2 } from "zod/mini"; import path from "node:path"; import fs from "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 = 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 }); set(tempObject, keyParts, value); } return tempObject; } }; const EnvironmentVariableSourceOptions = z$2.object({ prefix: z$2._default(z$2.optional(z$2.string()), "APP"), prefixSeparator: z$2._default(z$2.optional(z$2.string()), "_"), separator: z$2._default(z$2.optional(z$2.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 = z$2.union([ z$2.boolean(), z$2.null(), z$2.number(), z$2.string(), z$2.undefined() ]); const RuntimeEnv = z$2.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 */ z$2.transform((env) => { return Object.assign({}, env); }), z$2.record(z$2.string(), RuntimeEnvValue) ); const DEFAULT_SLOT_PREFIX = "$"; //#endregion //#region src/server/types.ts const ServerConfigBuilderOptionsSchema = z$2.object({ absoluteConfigFolderPath: z$2._default(z$2.string().check(z$2.refine((value) => path.isAbsolute(value), "Path must be absolute")), path.resolve(process.cwd(), "./config")), runtimeEnv: z$2._default(RuntimeEnv, process.env), parser: z$2._default(z$2.custom(), basicJsonParser), validate: z$2.custom(), slotPrefix: z$2._default(z$2.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 = merge({}, partialConfig, data); } return this.#options.validate(partialConfig, 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 (fs.existsSync(filePath)) { const fileContent = fs.readFileSync(filePath, "utf8"); const fileContentResult = z$2.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 = path.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 path.extname(filePath).slice(1); } }; //#endregion export { ConfigBuilder, ConfigParser, EnvironmentVariableSource, FileSource, ObjectSource, z }; //# sourceMappingURL=index.js.map