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