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