UNPKG

rsbuild-plugin-mkcert

Version:

Provide certificates for rsbuild's https dev service

678 lines (661 loc) 20.4 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); const os = require('os'); const path = require('path'); const child_process = require('child_process'); const crypto = require('crypto'); const fs = require('fs'); const util = require('util'); const process$1 = require('process'); const core = require('@rsbuild/core'); const pc = require('picocolors'); const Debug = require('debug'); const axios = require('axios'); const rest = require('@octokit/rest'); function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } const os__default = /*#__PURE__*/_interopDefaultCompat(os); const path__default = /*#__PURE__*/_interopDefaultCompat(path); const child_process__default = /*#__PURE__*/_interopDefaultCompat(child_process); const crypto__default = /*#__PURE__*/_interopDefaultCompat(crypto); const fs__default = /*#__PURE__*/_interopDefaultCompat(fs); const util__default = /*#__PURE__*/_interopDefaultCompat(util); const process__default = /*#__PURE__*/_interopDefaultCompat(process$1); const pc__default = /*#__PURE__*/_interopDefaultCompat(pc); const Debug__default = /*#__PURE__*/_interopDefaultCompat(Debug); const axios__default = /*#__PURE__*/_interopDefaultCompat(axios); const PKG_NAME = "rsbuild-plugin-mkcert"; const PLUGIN_NAME = PKG_NAME.replace(/-/g, ":"); const PLUGIN_DATA_DIR = path__default.join(os__default.homedir(), `.${PKG_NAME}`); const exists = async (filePath) => { try { await fs__default.promises.access(filePath); return true; } catch (error) { return false; } }; const mkdir = async (dirname) => { const isExist = await exists(dirname); if (!isExist) { await fs__default.promises.mkdir(dirname, { recursive: true }); } }; const ensureDirExist = async (filePath, strip = false) => { const dirname = strip ? path__default.dirname(filePath) : filePath; await mkdir(dirname); }; const readFile = async (filePath) => { const isExist = await exists(filePath); return isExist ? (await fs__default.promises.readFile(filePath)).toString() : void 0; }; const writeFile = async (filePath, data) => { await ensureDirExist(filePath, true); await fs__default.promises.writeFile(filePath, data); await fs__default.promises.chmod(filePath, 511); }; const readDir = async (source) => { return fs__default.promises.readdir(source); }; const copyDir = async (source, dest) => { try { await fs__default.promises.cp(source, dest, { recursive: true }); } catch (error) { console.log(`${PLUGIN_NAME}:`, error); } }; const exec = async (cmd, options) => { return util__default.promisify(child_process__default.exec)(cmd, options); }; const isIPV4 = (family) => { return family === "IPv4" || family === 4; }; const getLocalV4Ips = () => { const interfaceDict = os__default.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__default.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__default(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__default.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__default.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 rest.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", core.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 = core.logger; this.autoUpgrade = autoUpgrade; this.localMkcert = mkcertPath; this.savePath = path__default.resolve(savePath); this.keyFilePath = path__default.resolve(savePath, keyFileName); this.certFilePath = path__default.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__default.resolve( savePath, process__default.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__default.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__default.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__default.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__default.platform} platform with ${process__default.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 } }); }); } }; }; exports.BaseSource = BaseSource; exports.default = plugin;