@layerfig/config
Version:
Layer and runtime-validate type-safe configs for JavaScript apps.
253 lines (244 loc) • 8.81 kB
JavaScript
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