Skip to content
On this page

c12

智能配置加载器

特性

  • 通过 unjs/jiti 加载 JSON, CJS, TSESM 配置
  • 通过 unjs/rc9 加载 RC 配置
  • 通过 unjs/defu 合并多个配置
  • 通过 dotenv 加载 .env 配置
  • 从最近的 package.json 中读取配置
  • 可从本地或 git 仓库中扩展配置
  • 可重写环境特有的配置
  • 可配置监听达到自动刷新和 HMR

加载优先级

  1. 通过选项重写的配置
  2. CWD 中的配置文件
  3. CWD 中的 RC 文件
  4. 在用户目录的全局 RC 文件
  5. package.json 中的配置
  6. 通过选项配置的默认配置
  7. 扩展配置

源码

源码核心也就三个文件,但其内部逻辑还是可以仔细看看的。

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>];
    },
  });
}