c12
智能配置加载器
特性
- 通过
unjs/jiti
加载JSON
,CJS
,TS
和ESM
配置 - 通过
unjs/rc9
加载RC
配置 - 通过
unjs/defu
合并多个配置 - 通过
dotenv
加载.env
配置 - 从最近的
package.json
中读取配置 - 可从本地或
git
仓库中扩展配置 - 可重写环境特有的配置
- 可配置监听达到自动刷新和
HMR
加载优先级
- 通过选项重写的配置
- 在
CWD
中的配置文件 - 在
CWD
中的RC
文件 - 在用户目录的全局
RC
文件 - 在
package.json
中的配置 - 通过选项配置的默认配置
- 扩展配置
源码
源码核心也就三个文件,但其内部逻辑还是可以仔细看看的。
ts
import { promises as fsp, existsSync } from "node:fs";
import { resolve } from "pathe";
import * as dotenv from "dotenv";
export interface DotenvOptions {
//* 项目根目录,绝对路径或相对cwd的路径
cwd: string;
//* 从哪个文件中加载环境变量,绝对路径或相对cwd的路径
fileName?: string;
//* 是否允许插值表达式
//* 比如
//* ```env
//* BASE_DIR="/test"
//* # resolves to "/test/further"
//* ANOTHER_DIR="${BASE_DIR}/further"
//* ```
interpolate?: boolean;
//* 环境变量键值对
env?: NodeJS.ProcessEnv;
}
export type Env = typeof process.env;
//* 加载并插值环境变量到process.env上
export async function setupDotenv(options: DotenvOptions): Promise<Env> {
const targetEnvironment = options.env ?? process.env;
//* 加载环境变量
const environment = await loadDotenv({
cwd: options.cwd,
fileName: options.fileName ?? ".env",
env: targetEnvironment,
interpolate: options.interpolate ?? true,
});
for (const key in environment) {
//* 如果不是下划线开头且目标环境没有此环境变量,则设置到目标环境上
if (!key.startsWith("_") && targetEnvironment[key] === undefined) {
targetEnvironment[key] = environment[key];
}
}
return environment;
}
//* 加载环境变量到一个对象里
export async function loadDotenv(options: DotenvOptions): Promise<Env> {
//- Object.create(null) 和 {} 的区别?
//* 没有任何默认的key,比如constructor和__proto__
const environment = Object.create(null);
const dotenvFile = resolve(options.cwd, options.fileName!);
if (existsSync(dotenvFile)) {
const parsed = dotenv.parse(await fsp.readFile(dotenvFile, "utf8"));
Object.assign(environment, parsed);
}
//* 标志是否已应用到环境中
if (!options.env?._applied) {
Object.assign(environment, options.env);
environment._applied = true;
}
//* 允许插值表达式,则处理插值表达式
if (options.interpolate) {
interpolate(environment);
}
return environment;
}
// Based on https://github.com/motdotla/dotenv-expand
function interpolate(
target: Record<string, any>,
source: Record<string, any> = {},
parse = (v: any) => v
) {
function getValue(key: string) {
// Source value 'wins' over target value
return source[key] === undefined ? target[key] : source[key];
}
function interpolate(value: unknown, parents: string[] = []): any {
if (typeof value !== "string") {
return value;
}
//* 匹配所有插值
const matches: string[] = value.match(/(.?\${?(?:[\w:]+)?}?)/g) || [];
return parse(
//* 遍历插值
matches.reduce((newValue, match) => {
const parts = /(.?)\${?([\w:]+)?}?/g.exec(match) || [];
//* 前缀
const prefix = parts[1];
let value, replacePart: string;
//* 如果本意是表达插值字符串呢,则不进行插值解析
//* 即 \${var} => ${var}
if (prefix === "\\") {
replacePart = parts[0] || "";
value = replacePart.replace("\\$", "$");
//- 这里是想直接表示插值表达式的字符串,可以直接 return value
//- 走下文的逻辑亦可
} else {
const key = parts[2];
replacePart = (parts[0] || "").slice(prefix.length);
//* 防止插值递归
if (parents.includes(key)) {
console.warn(
`Please avoid recursive environment variables ( loop: ${parents.join(
" > "
)} > ${key} )`
);
return "";
}
//* 这里得到的value有可能还是插值表达式,则递归
//* 但是要注意,之前进行插值解析了的key,再次解析就会产生死循环
value = getValue(key);
//* 递归解析
value = interpolate(value, [...parents, key]);
}
return value === undefined
? newValue
//* 替换成真正的值
: newValue.replace(replacePart, value);
}, value)
);
}
//* 遍历进行插值表达式处理
for (const key in target) {
target[key] = interpolate(getValue(key));
}
}
ts
import type { JITI } from "jiti";
import type { JITIOptions } from "jiti/dist/types";
import type { DotenvOptions } from "./dotenv";
export interface ConfigLayerMeta {
name?: string;
[key: string]: any;
}
export type UserInputConfig = Record<string, any>;
export interface C12InputConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
> {
$test?: T;
$development?: T;
$production?: T;
$env?: Record<string, T>;
$meta?: MT;
}
export type InputConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
> = C12InputConfig<T, MT> & T;
export interface SourceOptions<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
> {
meta?: MT;
overrides?: T;
[key: string]: any;
}
export interface ConfigLayer<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
> {
config: T | null;
source?: string;
sourceOptions?: SourceOptions<T, MT>;
meta?: MT;
cwd?: string;
configFile?: string;
}
export interface ResolvedConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
> extends ConfigLayer<T, MT> {
layers?: ConfigLayer<T, MT>[];
cwd?: string;
}
export interface LoadConfigOptions<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
> {
name?: string;
cwd?: string;
configFile?: string;
rcFile?: false | string;
globalRc?: boolean;
dotenv?: boolean | DotenvOptions;
envName?: string | false;
packageJson?: boolean | string | string[];
defaults?: T;
defaultConfig?: T;
overrides?: T;
resolve?: (
id: string,
options: LoadConfigOptions<T, MT>
) =>
| null
| undefined
| ResolvedConfig<T, MT>
| Promise<ResolvedConfig<T, MT> | undefined | null>;
jiti?: JITI;
jitiOptions?: JITIOptions;
extend?:
| false
| {
extendKey?: string | string[];
};
}
export type DefineConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
> = (input: InputConfig<T, MT>) => InputConfig<T, MT>;
export function createDefineConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
>(): DefineConfig<T, MT> {
return (input: InputConfig<T, MT>) => input;
}
ts
import { existsSync } from "node:fs";
import { rm } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve, extname, dirname, basename } from "pathe";
import createJiti from "jiti";
import * as rc9 from "rc9";
import { defu } from "defu";
import { findWorkspaceDir, readPackageJSON } from "pkg-types";
import { setupDotenv } from "./dotenv";
import type {
UserInputConfig,
ConfigLayerMeta,
LoadConfigOptions,
ResolvedConfig,
ConfigLayer,
SourceOptions,
InputConfig,
} from "./types";
export async function loadConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
>(options: LoadConfigOptions<T, MT>): Promise<ResolvedConfig<T, MT>> {
//* 格式化配置参数
options.cwd = resolve(process.cwd(), options.cwd || ".");
//* 默认文件名为 config
options.name = options.name || "config";
//* 环境名
options.envName = options.envName ?? process.env.NODE_ENV;
//* 配置文件
options.configFile =
options.configFile ??
(options.name === "config" ? "config" : `${options.name}.config`);
//* rc文件
options.rcFile = options.rcFile ?? `.${options.name}rc`;
//* 扩展
if (options.extend !== false) {
options.extend = {
extendKey: "extends",
...options.extend,
};
}
//* 创建jiti实例
options.jiti =
options.jiti ||
createJiti(undefined as unknown as string, {
interopDefault: true,
requireCache: false,
esmResolve: true,
...options.jitiOptions,
});
//* 创建上下文
const r: ResolvedConfig<T, MT> = {
config: {} as any,
cwd: options.cwd,
configFile: resolve(options.cwd, options.configFile),
layers: [],
};
//* 加载.env文件
if (options.dotenv) {
//* 加载并存放到环境变量
await setupDotenv({
cwd: options.cwd,
...(options.dotenv === true ? {} : options.dotenv),
});
}
//* 加载配置文件
const { config, configFile } = await resolveConfig(".", options);
if (configFile) {
r.configFile = configFile;
}
//* 加载rc文件
const configRC = {};
if (options.rcFile) {
//* 全局rc文件
if (options.globalRc) {
Object.assign(
configRC,
rc9.readUser({ name: options.rcFile, dir: options.cwd })
);
const workspaceDir = await findWorkspaceDir(options.cwd).catch(() => {});
if (workspaceDir) {
Object.assign(
configRC,
rc9.read({ name: options.rcFile, dir: workspaceDir })
);
}
}
Object.assign(
configRC,
rc9.read({ name: options.rcFile, dir: options.cwd })
);
}
//* 从package.json中加载配置
const pkgJson = {};
if (options.packageJson) {
//* 可以加载多个key下的数据
const keys = (
Array.isArray(options.packageJson)
? options.packageJson
: [
typeof options.packageJson === "string"
? options.packageJson
: options.name,
]
).filter((t) => t && typeof t === "string");
const pkgJsonFile = await readPackageJSON(options.cwd).catch(() => {});
const values = keys.map((key) => pkgJsonFile?.[key]);
Object.assign(pkgJson, defu({}, ...values));
}
//* 合并数据,左侧优先级最高
r.config = defu(
options.overrides,
config,
configRC,
pkgJson,
options.defaultConfig
) as T;
//* 扩展配置
if (options.extend) {
await extendConfig(r.config, options);
r.layers = r.config._layers;
delete r.config._layers;
//* 再合并
r.config = defu(r.config, ...r.layers!.map((e) => e.config)) as T;
}
// Preserve unmerged sources as layers/
//* 保留未合并的layer
const baseLayers = [
options.overrides && {
config: options.overrides,
configFile: undefined,
cwd: undefined,
},
{ config, configFile: options.configFile, cwd: options.cwd },
options.rcFile && { config: configRC, configFile: options.rcFile },
options.packageJson && { config: pkgJson, configFile: "package.json" },
].filter((l) => l && l.config) as ConfigLayer<T, MT>[];
r.layers = [...baseLayers, ...r.layers!];
//* 默认值
if (options.defaults) {
r.config = defu(r.config, options.defaults) as T;
}
return r;
}
//* 扩展配置
async function extendConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
>(config: InputConfig<T, MT>, options: LoadConfigOptions<T, MT>) {
(config as any)._layers = config._layers || [];
if (!options.extend) {
return;
}
//* 允许多个key
let keys = options.extend.extendKey;
if (typeof keys === "string") {
keys = [keys];
}
const extendSources = [];
for (const key of keys as string[]) {
extendSources.push(
...(Array.isArray(config[key]) ? config[key] : [config[key]]).filter(Boolean)
);
delete config[key];
}
for (let extendSource of extendSources) {
const originalExtendSource = extendSource;
let sourceOptions = {};
if (extendSource.source) {
sourceOptions = extendSource.options || {};
extendSource = extendSource.source;
}
if (Array.isArray(extendSource)) {
sourceOptions = extendSource[1] || {};
extendSource = extendSource[0];
}
if (typeof extendSource !== "string") {
// TODO: Use error in next major versions
console.warn(
`Cannot extend config from \`${JSON.stringify(
originalExtendSource
)}\` in ${options.cwd}`
);
continue;
}
const _config = await resolveConfig(extendSource, options, sourceOptions);
if (!_config.config) {
// TODO: Use error in next major versions
console.warn(
`Cannot extend config from \`${extendSource}\` in ${options.cwd}`
);
continue;
}
await extendConfig(_config.config, { ...options, cwd: _config.cwd });
config._layers.push(_config);
if (_config.config._layers) {
config._layers.push(..._config.config._layers);
delete _config.config._layers;
}
}
}
const GIT_PREFIXES = ["github:", "gitlab:", "bitbucket:", "https://"];
// https://github.com/dword-design/package-name-regex
const NPM_PACKAGE_RE =
/^(@[\da-z~-][\d._a-z~-]*\/)?[\da-z~-][\d._a-z~-]*($|\/.*)/;
//* 解析配置
async function resolveConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
>(
source: string,
options: LoadConfigOptions<T, MT>,
sourceOptions: SourceOptions<T, MT> = {}
): Promise<ResolvedConfig<T, MT>> {
//* 用户自定义解析器
if (options.resolve) {
const res = await options.resolve(source, options);
if (res) {
return res;
}
}
//* 支持git协议,只要有一个满足
if (GIT_PREFIXES.some((prefix) => source.startsWith(prefix))) {
const { downloadTemplate } = await import("giget");
const url = new URL(source);
const gitRepo =
url.protocol + url.pathname.split("/").slice(0, 2).join("/");
const name = gitRepo.replace(/[#/:@\\]/g, "_");
//* 临时目录
const tmpDir = process.env.XDG_CACHE_HOME
? resolve(process.env.XDG_CACHE_HOME, "c12", name)
: resolve(homedir(), ".cache/c12", name);
if (existsSync(tmpDir)) {
await rm(tmpDir, { recursive: true });
}
//* 克隆git项目到本地临时目录
const cloned = await downloadTemplate(source, { dir: tmpDir });
source = cloned.dir;
}
//* 是npm包,则用jiti解析
if (NPM_PACKAGE_RE.test(source)) {
try {
source = options.jiti!.resolve(source, { paths: [options.cwd!] });
} catch {}
}
//* 从本地文件系统加载
const ext = extname(source);
const isDir = !ext || ext === basename(source); /* #71 */
const cwd = resolve(options.cwd!, isDir ? source : dirname(source));
if (isDir) {
source = options.configFile!;
}
const res: ResolvedConfig<T, MT> = {
config: undefined as unknown as T,
cwd,
source,
sourceOptions,
};
try {
res.configFile = options.jiti!.resolve(resolve(cwd, source), {
paths: [cwd],
});
} catch {}
if (!existsSync(res.configFile!)) {
return res;
}
//* 加载配置文件
res.config = options.jiti!(res.configFile!);
if (res.config instanceof Function) {
//* 如果是函数,则调用
//- 这里可以考虑传参进来
res.config = await res.config();
}
//* 扩展环境特定配置
if (options.envName) {
const envConfig = {
...res.config!["$" + options.envName],
...res.config!.$env?.[options.envName],
};
//* 合并
if (Object.keys(envConfig).length > 0) {
res.config = defu(envConfig, res.config);
}
}
//* 元数据
res.meta = defu(res.sourceOptions!.meta, res.config!.$meta) as MT;
delete res.config!.$meta;
//* 重写
if (res.sourceOptions!.overrides) {
res.config = defu(res.sourceOptions!.overrides, res.config) as T;
}
return res;
}
ts
import { watch, WatchOptions } from "chokidar";
import { debounce } from "perfect-debounce";
import { resolve } from "pathe";
import { diff } from "ohash";
import type {
UserInputConfig,
ConfigLayerMeta,
ResolvedConfig,
LoadConfigOptions,
} from "./types";
import { loadConfig } from "./loader";
export type ConfigWatcher<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
> = ResolvedConfig<T, MT> & {
watchingFiles: string[];
unwatch: () => Promise<void>;
};
export interface WatchConfigOptions<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
> extends LoadConfigOptions<T, MT> {
chokidarOptions?: WatchOptions;
debounce?: false | number;
onWatch?: (event: {
type: "created" | "updated" | "removed";
path: string;
}) => void | Promise<void>;
acceptHMR?: (context: {
getDiff: () => ReturnType<typeof diff>;
newConfig: ResolvedConfig<T, MT>;
oldConfig: ResolvedConfig<T, MT>;
}) => void | boolean | Promise<void | boolean>;
onUpdate?: (context: {
getDiff: () => ReturnType<typeof diff>;
newConfig: ResolvedConfig<T, MT>;
oldConfig: ResolvedConfig<T, MT>;
}) => void | Promise<void>;
}
const eventMap = {
add: "created",
change: "updated",
unlink: "removed",
} as const;
export async function watchConfig<
T extends UserInputConfig = UserInputConfig,
MT extends ConfigLayerMeta = ConfigLayerMeta
>(options: WatchConfigOptions<T, MT>): Promise<ConfigWatcher<T, MT>> {
let config = await loadConfig<T, MT>(options);
const configName = options.name || "config";
const configFileName =
options.configFile ??
(options.name === "config" ? "config" : `${options.name}.config`);
const watchingFiles = [
...new Set(
(config.layers || [])
.filter((l) => l.cwd)
.flatMap((l) => [
...["ts", "js", "mjs", "cjs", "cts", "mts", "json"].map((ext) =>
resolve(l.cwd!, configFileName + "." + ext)
),
l.source && resolve(l.cwd!, l.source),
// TODO: Support watching rc from home and workspace
options.rcFile &&
resolve(
l.cwd!,
typeof options.rcFile === "string"
? options.rcFile
: `.${configName}rc`
),
options.packageJson && resolve(l.cwd!, "package.json"),
])
.filter(Boolean)
),
] as string[];
//* 通过chokidar监听文件
const _fswatcher = watch(watchingFiles, {
ignoreInitial: true,
...options.chokidarOptions,
});
const onChange = async (event: string, path: string) => {
const type = eventMap[event as keyof typeof eventMap];
if (!type) {
return;
}
if (options.onWatch) {
await options.onWatch({
type,
path,
});
}
const oldConfig = config;
const newConfig = await loadConfig(options);
config = newConfig;
const changeCtx = {
newConfig,
oldConfig,
getDiff: () => diff(oldConfig.config, config.config),
};
if (options.acceptHMR) {
const changeHandled = await options.acceptHMR(changeCtx);
if (changeHandled) {
return;
}
}
if (options.onUpdate) {
await options.onUpdate(changeCtx);
}
};
//* 防抖
if (options.debounce === false) {
_fswatcher.on("all", onChange);
} else {
_fswatcher.on("all", debounce(onChange, options.debounce ?? 100));
}
const utils: Partial<ConfigWatcher<T, MT>> = {
watchingFiles,
unwatch: async () => {
await _fswatcher.close();
},
};
return new Proxy<ConfigWatcher<T, MT>>(utils as ConfigWatcher<T, MT>, {
get(_, prop) {
if (prop in utils) {
return utils[prop as keyof typeof utils];
}
return config[prop as keyof ResolvedConfig<T, MT>];
},
});
}