rsbuild-plugin-mkcert
Version:
Provide certificates for rsbuild's https dev service
660 lines (647 loc) • 19.3 kB
JavaScript
import os from 'os';
import path from 'path';
import child_process from 'child_process';
import crypto from 'crypto';
import fs from 'fs';
import util from 'util';
import process$1 from 'process';
import { logger } from '@rsbuild/core';
import pc from 'picocolors';
import Debug from 'debug';
import axios from 'axios';
import { Octokit } from '@octokit/rest';
const PKG_NAME = "rsbuild-plugin-mkcert";
const PLUGIN_NAME = PKG_NAME.replace(/-/g, ":");
const PLUGIN_DATA_DIR = path.join(os.homedir(), `.${PKG_NAME}`);
const exists = async (filePath) => {
try {
await fs.promises.access(filePath);
return true;
} catch (error) {
return false;
}
};
const mkdir = async (dirname) => {
const isExist = await exists(dirname);
if (!isExist) {
await fs.promises.mkdir(dirname, { recursive: true });
}
};
const ensureDirExist = async (filePath, strip = false) => {
const dirname = strip ? path.dirname(filePath) : filePath;
await mkdir(dirname);
};
const readFile = async (filePath) => {
const isExist = await exists(filePath);
return isExist ? (await fs.promises.readFile(filePath)).toString() : void 0;
};
const writeFile = async (filePath, data) => {
await ensureDirExist(filePath, true);
await fs.promises.writeFile(filePath, data);
await fs.promises.chmod(filePath, 511);
};
const readDir = async (source) => {
return fs.promises.readdir(source);
};
const copyDir = async (source, dest) => {
try {
await fs.promises.cp(source, dest, {
recursive: true
});
} catch (error) {
console.log(`${PLUGIN_NAME}:`, error);
}
};
const exec = async (cmd, options) => {
return util.promisify(child_process.exec)(cmd, options);
};
const isIPV4 = (family) => {
return family === "IPv4" || family === 4;
};
const getLocalV4Ips = () => {
const interfaceDict = os.networkInterfaces();
const addresses = [];
for (const key in interfaceDict) {
const interfaces = interfaceDict[key];
if (interfaces) {
for (const item of interfaces) {
if (isIPV4(item.family)) {
addresses.push(item.address);
}
}
}
}
return addresses;
};
const getDefaultHosts = () => {
return ["localhost", ...getLocalV4Ips()];
};
const getHash = async (filePath) => {
const content = await readFile(filePath);
if (content) {
const hash = crypto.createHash("sha256");
hash.update(content);
return hash.digest("hex");
}
return void 0;
};
const isObj = (obj) => Object.prototype.toString.call(obj) === "[object Object]";
const mergeObj = (target, source) => {
if (!(isObj(target) && isObj(source))) {
return target;
}
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const targetValue = target[key];
const sourceValue = source[key];
if (isObj(targetValue) && isObj(sourceValue)) {
mergeObj(targetValue, sourceValue);
} else {
target[key] = sourceValue;
}
}
}
};
const deepMerge = (target, ...source) => {
return source.reduce((a, b) => mergeObj(a, b), target);
};
const prettyLog = (obj) => {
return JSON.stringify(obj, null, 2);
};
const escape = (path2) => {
return `"${path2}"`;
};
const debug = Debug(PLUGIN_NAME);
var __defProp$4 = Object.defineProperty;
var __defNormalProp$4 = (obj, key, value) => key in obj ? __defProp$4(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField$4 = (obj, key, value) => {
__defNormalProp$4(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
const CONFIG_FILE_NAME = "config.json";
class Config {
constructor({ savePath }) {
/**
* The mkcert version
*/
__publicField$4(this, "version");
__publicField$4(this, "record");
__publicField$4(this, "configFilePath");
this.configFilePath = path.resolve(savePath, CONFIG_FILE_NAME);
}
async init() {
const str = await readFile(this.configFilePath);
const options = str ? JSON.parse(str) : void 0;
if (options) {
this.version = options.version;
this.record = options.record;
}
}
async serialize() {
await writeFile(this.configFilePath, prettyLog(this));
}
// deep merge
async merge(obj) {
const currentStr = prettyLog(this);
deepMerge(this, obj);
const nextStr = prettyLog(this);
debug(
`Receive parameter
${prettyLog(
obj
)}
Update config from
${currentStr}
to
${nextStr}`
);
await this.serialize();
}
getRecord() {
return this.record;
}
getVersion() {
return this.version;
}
}
const request = axios.create();
request.interceptors.response.use(
(res) => {
return res;
},
(error) => {
debug("Request error: %o", error);
return Promise.reject(error);
}
);
class Downloader {
static create() {
return new Downloader();
}
constructor() {
}
async download(downloadUrl, savedPath) {
debug("Downloading the mkcert executable from %s", downloadUrl);
const { data } = await request.get(downloadUrl, {
responseType: "arraybuffer"
});
await writeFile(savedPath, data);
debug("The mkcert has been saved to %s", savedPath);
}
}
var __defProp$3 = Object.defineProperty;
var __defNormalProp$3 = (obj, key, value) => key in obj ? __defProp$3(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField$3 = (obj, key, value) => {
__defNormalProp$3(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
class Record {
constructor(options) {
__publicField$3(this, "config");
this.config = options.config;
}
getHosts() {
return this.config.getRecord()?.hosts;
}
getHash() {
return this.config.getRecord()?.hash;
}
contains(hosts) {
const oldHosts = this.getHosts();
if (!oldHosts) {
return false;
}
for (const host of hosts) {
if (!oldHosts.includes(host)) {
return false;
}
}
return true;
}
// whether the files has been tampered with
equal(hash) {
const oldHash = this.getHash();
if (!oldHash) {
return false;
}
return oldHash.key === hash.key && oldHash.cert === hash.cert;
}
async update(record) {
await this.config.merge({ record });
}
}
var __defProp$2 = Object.defineProperty;
var __defNormalProp$2 = (obj, key, value) => key in obj ? __defProp$2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField$2 = (obj, key, value) => {
__defNormalProp$2(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
class BaseSource {
getPlatformIdentifier() {
const arch = process.arch === "x64" ? "amd64" : process.arch;
return process.platform === "win32" ? `windows-${arch}.exe` : `${process.platform}-${arch}`;
}
}
class GithubSource extends BaseSource {
static create() {
return new GithubSource();
}
constructor() {
super();
}
async getSourceInfo() {
const octokit = new Octokit();
const { data } = await octokit.repos.getLatestRelease({
owner: "FiloSottile",
repo: "mkcert"
});
const platformIdentifier = this.getPlatformIdentifier();
const version = data.tag_name;
const downloadUrl = data.assets.find(
(item) => item.name.includes(platformIdentifier)
)?.browser_download_url;
if (!(version && downloadUrl)) {
return void 0;
}
return {
downloadUrl,
version
};
}
}
const _CodingSource = class _CodingSource extends BaseSource {
static create() {
return new _CodingSource();
}
constructor() {
super();
}
async request(data) {
return request({
data,
method: "POST",
url: _CodingSource.CODING_API,
headers: {
Authorization: _CodingSource.CODING_AUTHORIZATION
}
});
}
/**
* Get filename of Coding.net artifacts
*
* @see https://liuweigl.coding.net/p/github/artifacts/885241/generic/packages
*
* @returns name
*/
getPackageName() {
return `mkcert-${this.getPlatformIdentifier()}`;
}
async getSourceInfo() {
const { data: VersionData } = await this.request({
Action: "DescribeArtifactVersionList",
ProjectId: _CodingSource.CODING_PROJECT_ID,
Repository: _CodingSource.REPOSITORY,
Package: this.getPackageName(),
PageSize: 1
});
const version = VersionData.Response.Data?.InstanceSet[0]?.Version;
if (!version) {
return void 0;
}
const { data: FileData } = await this.request({
Action: "DescribeArtifactFileDownloadUrl",
ProjectId: _CodingSource.CODING_PROJECT_ID,
Repository: _CodingSource.REPOSITORY,
Package: this.getPackageName(),
PackageVersion: version
});
const downloadUrl = FileData.Response.Url;
if (!downloadUrl) {
return void 0;
}
return {
downloadUrl,
version
};
}
};
__publicField$2(_CodingSource, "CODING_API", "https://e.coding.net/open-api");
__publicField$2(_CodingSource, "CODING_AUTHORIZATION", "token 000f7831ec425079439b0f55f55c729c9280d66e");
__publicField$2(_CodingSource, "CODING_PROJECT_ID", 8524617);
__publicField$2(_CodingSource, "REPOSITORY", "mkcert");
let CodingSource = _CodingSource;
var __defProp$1 = Object.defineProperty;
var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField$1 = (obj, key, value) => {
__defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
const parseVersion = (version) => {
const str = version.trim().replace(/v/i, "");
return str.split(".");
};
class VersionManger {
constructor(props) {
__publicField$1(this, "config");
this.config = props.config;
}
async update(version) {
try {
await this.config.merge({ version });
} catch (err) {
debug("Failed to record mkcert version info: %o", err);
}
}
compare(version) {
const currentVersion = this.config.getVersion();
if (!currentVersion) {
return {
currentVersion,
nextVersion: version,
breakingChange: false,
shouldUpdate: true
};
}
let breakingChange = false;
let shouldUpdate = false;
const newVersion = parseVersion(version);
const oldVersion = parseVersion(currentVersion);
for (let i = 0; i < newVersion.length; i++) {
if (newVersion[i] > oldVersion[i]) {
shouldUpdate = true;
breakingChange = i === 0;
break;
}
}
return {
breakingChange,
shouldUpdate,
currentVersion,
nextVersion: version
};
}
}
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
class Mkcert {
constructor(options) {
__publicField(this, "force");
__publicField(this, "autoUpgrade");
__publicField(this, "sourceType");
__publicField(this, "savePath");
__publicField(this, "logger", logger);
__publicField(this, "source");
__publicField(this, "localMkcert");
__publicField(this, "savedMkcert");
__publicField(this, "keyFilePath");
__publicField(this, "certFilePath");
__publicField(this, "config");
__publicField(this, "getLatestHash", async () => {
return {
key: await getHash(this.keyFilePath),
cert: await getHash(this.certFilePath)
};
});
const {
force,
autoUpgrade,
source,
mkcertPath,
savePath = PLUGIN_DATA_DIR,
keyFileName = "dev.pem",
certFileName = "cert.pem"
} = options;
this.force = force;
this.logger = logger;
this.autoUpgrade = autoUpgrade;
this.localMkcert = mkcertPath;
this.savePath = path.resolve(savePath);
this.keyFilePath = path.resolve(savePath, keyFileName);
this.certFilePath = path.resolve(savePath, certFileName);
this.sourceType = source || "github";
if (this.sourceType === "github") {
this.source = GithubSource.create();
} else if (this.sourceType === "coding") {
this.source = CodingSource.create();
} else {
this.source = this.sourceType;
}
this.savedMkcert = path.resolve(
savePath,
process$1.platform === "win32" ? "mkcert.exe" : "mkcert"
);
this.config = new Config({ savePath: this.savePath });
}
static create(options) {
return new Mkcert(options);
}
async getMkcertBinary() {
let binary;
if (this.localMkcert) {
if (await exists(this.localMkcert)) {
binary = this.localMkcert;
} else {
this.logger.error(
pc.red(
`${this.localMkcert} does not exist, please check the mkcertPath parameter`
)
);
}
} else if (await exists(this.savedMkcert)) {
binary = this.savedMkcert;
}
return binary;
}
async checkCAExists() {
const files = await readDir(this.savePath);
return files.some((file) => file.includes("rootCA"));
}
async retainExistedCA() {
if (await this.checkCAExists()) {
return;
}
const mkcertBinary = await this.getMkcertBinary();
const commandStatement = `${escape(mkcertBinary)} -CAROOT`;
debug(`Exec ${commandStatement}`);
const commandResult = await exec(commandStatement);
const caDirPath = path.resolve(
commandResult.stdout.toString().replace(/\n/g, "")
);
if (caDirPath === this.savePath) {
return;
}
const caDirExists = await exists(caDirPath);
if (!caDirExists) {
return;
}
await copyDir(caDirPath, this.savePath);
}
async getCertificate() {
const key = await readFile(this.keyFilePath);
const cert = await readFile(this.certFilePath);
return {
key,
cert
};
}
async createCertificate(hosts) {
const names = hosts.join(" ");
const mkcertBinary = await this.getMkcertBinary();
if (!mkcertBinary) {
debug(
`Mkcert does not exist, unable to generate certificate for ${names}`
);
}
await ensureDirExist(this.savePath);
await this.retainExistedCA();
const cmd = `${escape(mkcertBinary)} -install -key-file ${escape(
this.keyFilePath
)} -cert-file ${escape(this.certFilePath)} ${names}`;
await exec(cmd, {
env: {
...process$1.env,
CAROOT: this.savePath,
JAVA_HOME: void 0
}
});
this.logger.info(
`The list of generated files:
${this.keyFilePath}
${this.certFilePath}`
);
}
async regenerate(record, hosts) {
await this.createCertificate(hosts);
const hash = await this.getLatestHash();
record.update({ hosts, hash });
}
async init() {
await ensureDirExist(this.savePath);
await this.config.init();
const mkcertBinary = await this.getMkcertBinary();
if (!mkcertBinary) {
await this.initMkcert();
} else if (this.autoUpgrade) {
await this.upgradeMkcert();
}
}
async getSourceInfo() {
const sourceInfo = await this.source.getSourceInfo();
if (!sourceInfo) {
const message = typeof this.sourceType === "string" ? `Unsupported platform. Unable to find a binary file for ${process$1.platform} platform with ${process$1.arch} arch on ${this.sourceType === "github" ? "https://github.com/FiloSottile/mkcert/releases" : "https://liuweigl.coding.net/p/github/artifacts?hash=8d4dd8949af543159c1b5ac71ff1ff72"}` : 'Please check your custom "source", it seems to return invalid result';
throw new Error(message);
}
return sourceInfo;
}
async initMkcert() {
const sourceInfo = await this.getSourceInfo();
debug("The mkcert does not exist, download it now");
await this.downloadMkcert(sourceInfo.downloadUrl, this.savedMkcert);
}
async upgradeMkcert() {
const versionManger = new VersionManger({ config: this.config });
const sourceInfo = await this.getSourceInfo();
if (!sourceInfo) {
this.logger.error(
"Can not obtain download information of mkcert, update skipped"
);
return;
}
const versionInfo = versionManger.compare(sourceInfo.version);
if (!versionInfo.shouldUpdate) {
debug("Mkcert is kept latest version, update skipped");
return;
}
if (versionInfo.breakingChange) {
debug(
"The current version of mkcert is %s, and the latest version is %s, there may be some breaking changes, update skipped",
versionInfo.currentVersion,
versionInfo.nextVersion
);
return;
}
debug(
"The current version of mkcert is %s, and the latest version is %s, mkcert will be updated",
versionInfo.currentVersion,
versionInfo.nextVersion
);
await this.downloadMkcert(sourceInfo.downloadUrl, this.savedMkcert);
versionManger.update(versionInfo.nextVersion);
}
async downloadMkcert(sourceUrl, distPath) {
const downloader = Downloader.create();
await downloader.download(sourceUrl, distPath);
}
async renew(hosts) {
const record = new Record({ config: this.config });
if (this.force) {
debug(`Certificate is forced to regenerate`);
await this.regenerate(record, hosts);
}
if (!record.contains(hosts)) {
debug(
`The hosts changed from [${record.getHosts()}] to [${hosts}], start regenerate certificate`
);
await this.regenerate(record, hosts);
return;
}
const hash = await this.getLatestHash();
if (!record.equal(hash)) {
debug(
`The hash changed from ${prettyLog(record.getHash())} to ${prettyLog(
hash
)}, start regenerate certificate`
);
await this.regenerate(record, hosts);
return;
}
debug("Neither hosts nor hash has changed, skip regenerate certificate");
}
/**
* Get certificates
*
* @param hosts host collection
* @returns cretificates
*/
async install(hosts) {
if (hosts.length) {
await this.renew(hosts);
}
return await this.getCertificate();
}
}
const plugin = (options = {}) => {
return {
name: PLUGIN_NAME,
setup(api) {
api.modifyRsbuildConfig(async (config, { mergeRsbuildConfig }) => {
const { server } = config;
const { hosts = [], ...mkcertOptions } = options;
const mkcert = Mkcert.create({
...mkcertOptions
});
await mkcert.init();
const allHosts = [...getDefaultHosts(), ...hosts];
if (server?.host) {
allHosts.push(server.host);
}
const uniqueHosts = Array.from(new Set(allHosts)).filter(Boolean);
const certificate = await mkcert.install(uniqueHosts);
const httpsConfig = {
key: certificate.key && Buffer.from(certificate.key),
cert: certificate.cert && Buffer.from(certificate.cert)
};
return mergeRsbuildConfig(config, {
server: {
https: httpsConfig
}
});
});
}
};
};
export { BaseSource, plugin as default };