UNPKG

@layerfig/config

Version:

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

253 lines (244 loc) 8.81 kB
import { z, z as z$1 } from "zod/mini"; import { get, merge, set } from "es-toolkit/compat"; //#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$1.object({ prefix: z$1._default(z$1.optional(z$1.string()), "APP"), prefixSeparator: z$1._default(z$1.optional(z$1.string()), "_"), separator: z$1._default(z$1.optional(z$1.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/types.ts const RuntimeEnvValue = z$1.union([ z$1.boolean(), z$1.null(), z$1.number(), z$1.string(), z$1.undefined() ]); const RuntimeEnv = z$1.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$1.transform((env) => { return Object.assign({}, env); }), z$1.record(z$1.string(), RuntimeEnvValue) ); const DEFAULT_SLOT_PREFIX = "$"; //#endregion //#region src/client/types.ts /** * This notation only serves to not have the following error: * * ``` * RollupError: src/client/types.ts(12,14): error TS2742: The inferred type of * 'ClientConfigBuilderOptionsSchema' cannot be named without a * reference to '../../../../node_modules/zod/v4/mini/external.d.cts'. * This is likely not portable. A type annotation is necessary. * ``` */ const ClientConfigBuilderOptionsSchema = z$1.object({ runtimeEnv: z$1._default(RuntimeEnv, import.meta.env || {}), validate: z$1.custom(), slotPrefix: z$1._default(z$1.string(), DEFAULT_SLOT_PREFIX) }); const ClientSources = z$1.union([z$1.instanceof(ObjectSource), z$1.instanceof(EnvironmentVariableSource)], { error: "Invalid source. Client ConfigBuilder only Accepts ObjectSource or EnvironmentVariableSource" }); //#endregion //#region src/client/config-builder.ts var ConfigBuilder = class { #options; #sources = []; constructor(options) { this.#options = ClientConfigBuilderOptionsSchema.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({ runtimeEnv: this.#options.runtimeEnv, slotPrefix: this.#options.slotPrefix }); partialConfig = merge({}, partialConfig, data); } return this.#options.validate(partialConfig, z); } addSource(source) { const validatedSourceResult = ClientSources.safeParse(source); if (!validatedSourceResult.success) throw new Error(z.prettifyError(validatedSourceResult.error)); this.#sources.push(validatedSourceResult.data); return this; } }; //#endregion export { ConfigBuilder, EnvironmentVariableSource, ObjectSource, z }; //# sourceMappingURL=client.js.map