UNPKG

vite-plugin-mkcert

Version:

Provide certificates for vite's https dev service

751 lines (735 loc) 22.3 kB
import path from "node:path"; import process$1 from "node:process"; import child_process from "node:child_process"; import util from "node:util"; import os from "node:os"; import crypto from "node:crypto"; import fs from "node:fs"; import debug from "debug"; import { ProxyAgent, fetch } from "undici"; //#region src/utils/command.ts const exec = async (cmd, options) => { return util.promisify(child_process.exec)(cmd, options); }; const execFile = async (file, args = [], options) => { return util.promisify(child_process.execFile)(file, args, options); }; const findCommandFromPath = async (command) => { const lookupCmd = process$1.platform === "win32" ? `where ${command}` : `command -v ${command}`; try { const { stdout } = await exec(lookupCmd); const output = stdout.toString().trim(); if (!output) return; return output.split(/\r?\n/)[0].trim() || void 0; } catch (_error) { return; } }; //#endregion //#region src/utils/constant.ts const PKG_NAME = "vite-plugin-mkcert"; const PLUGIN_NAME = PKG_NAME.replace(/-/g, ":"); const PLUGIN_DATA_DIR = path.join(os.homedir(), `.${PKG_NAME}`); //#endregion //#region src/utils/logger.ts const LOG_NAMESPACES = { debug: `${PLUGIN_NAME}:DEBUG`, info: `${PLUGIN_NAME}:INFO`, warn: `${PLUGIN_NAME}:WARN`, error: `${PLUGIN_NAME}:ERROR` }; const NAMESPACE_COLORS = { [LOG_NAMESPACES.debug]: 33, [LOG_NAMESPACES.info]: 46, [LOG_NAMESPACES.warn]: 214, [LOG_NAMESPACES.error]: 196 }; const defaultSelectColor = debug.selectColor.bind(debug); debug.selectColor = (namespace) => { return NAMESPACE_COLORS[namespace] ?? defaultSelectColor(namespace); }; const LEVEL_TO_NAMESPACES = { info: [ LOG_NAMESPACES.info, LOG_NAMESPACES.warn, LOG_NAMESPACES.error ], warn: [LOG_NAMESPACES.warn, LOG_NAMESPACES.error], error: [LOG_NAMESPACES.error], silent: [] }; const isLogLevel = (level) => { return level === "info" || level === "warn" || level === "error" || level === "silent"; }; const setLogLevel = (level) => { if (!isLogLevel(level)) return; debug.enable(LEVEL_TO_NAMESPACES[level].join(",")); }; const debug_log = debug(LOG_NAMESPACES.debug); const info_log = debug(LOG_NAMESPACES.info); info_log.log = console.info.bind(console); const warn_log = debug(LOG_NAMESPACES.warn); warn_log.log = console.warn.bind(console); const error_log = debug(LOG_NAMESPACES.error); error_log.log = console.error.bind(console); const prettyLog = (obj) => { return JSON.stringify(obj, null, 2); }; //#endregion //#region src/utils/fs.ts /** * Check if file exists * * @param filePath file path * @returns does the file exist */ const exists = async (filePath) => { try { await fs.promises.access(filePath); return true; } catch (_error) { return false; } }; const mkdir = async (dirname) => { if (!await exists(dirname)) await fs.promises.mkdir(dirname, { recursive: true }); }; const ensureDirExist = async (filePath, strip = false) => { await mkdir(strip ? path.dirname(filePath) : filePath); }; const readFile = async (filePath) => { return await exists(filePath) ? (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) { error_log("Failed to copy directory from %s to %s: %o", source, dest, error); } }; const getHash = async (filePath) => { const content = await readFile(filePath); if (content) { const hash = crypto.createHash("sha256"); hash.update(content); return hash.digest("hex"); } }; //#endregion //#region src/utils/object.ts 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.hasOwn(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); }; //#endregion //#region src/mkcert/config.ts const CONFIG_FILE_NAME = "config.json"; var Config = class { /** * The mkcert version */ version; record; configFilePath; constructor({ savePath }) { 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)); } async merge(obj) { const currentStr = prettyLog(this); deepMerge(this, obj); const nextStr = prettyLog(this); debug_log(`Receive parameter\n ${prettyLog(obj)}\nUpdate config from\n ${currentStr} \nto\n ${nextStr}`); await this.serialize(); } getRecord() { return this.record; } getVersion() { return this.version; } }; //#endregion //#region src/utils/request.ts const getProxyFromEnv = () => { return (process$1.env.HTTPS_PROXY || process$1.env.https_proxy || process$1.env.HTTP_PROXY || process$1.env.http_proxy)?.trim() || void 0; }; const proxyAgentMap = /* @__PURE__ */ new Map(); const getDispatcher = (proxy) => { const proxyUrl = proxy?.trim() || getProxyFromEnv(); if (!proxyUrl) return; let dispatcher = proxyAgentMap.get(proxyUrl); if (!dispatcher) { dispatcher = new ProxyAgent(proxyUrl); proxyAgentMap.set(proxyUrl, dispatcher); } return dispatcher; }; const readArrayBufferWithProgress = async (response, onDownloadProgress) => { if (!onDownloadProgress || !response.body) return Buffer.from(await response.arrayBuffer()); const totalHeader = response.headers.get("content-length"); const total = totalHeader ? Number(totalHeader) : void 0; const reader = response.body.getReader(); const chunks = []; let loaded = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { chunks.push(value); loaded += value.byteLength; onDownloadProgress({ loaded, total, percent: total ? loaded / total * 100 : void 0 }); } } return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), loaded); }; const parseResponse = async (response, responseType, onDownloadProgress) => { if (responseType === "arraybuffer") return readArrayBufferWithProgress(response, onDownloadProgress); if (responseType === "text") return await response.text(); const contentType = response.headers.get("content-type"); if (responseType === "json" || contentType?.includes("application/json")) return await response.json(); return await response.text(); }; async function doRequest(config) { const { method = "GET", url, data, headers, responseType, proxy, onDownloadProgress } = config; const init = { method }; if (data) { init.headers = { "Content-Type": "application/json", ...headers }; init.body = JSON.stringify(data); } else if (headers) init.headers = headers; const dispatcher = getDispatcher(proxy); if (dispatcher) init.dispatcher = dispatcher; try { const response = await fetch(url, init); if (!response.ok) throw new Error(`Request failed [${method}] ${url} (${response.status} ${response.statusText})`); return { data: await parseResponse(response, responseType, onDownloadProgress) }; } catch (error) { error_log("Request error: %o", error); throw error; } } const request = Object.assign(doRequest, { get: (url, config) => doRequest({ ...config, method: "GET", url }) }); //#endregion //#region src/utils/throttle.ts const throttle = (fn, wait = 300) => { let timeout; let lastInvokeTime = 0; let pendingArgs; const invoke = (args) => { lastInvokeTime = Date.now(); fn(...args); }; const throttled = ((...args) => { const remaining = wait - (Date.now() - lastInvokeTime); pendingArgs = args; if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = void 0; } invoke(args); pendingArgs = void 0; return; } if (!timeout) timeout = setTimeout(() => { timeout = void 0; if (!pendingArgs) return; invoke(pendingArgs); pendingArgs = void 0; }, remaining); }); throttled.cancel = () => { if (timeout) { clearTimeout(timeout); timeout = void 0; } pendingArgs = void 0; }; throttled.flush = () => { if (timeout) { clearTimeout(timeout); timeout = void 0; } if (!pendingArgs) return; invoke(pendingArgs); pendingArgs = void 0; }; return throttled; }; //#endregion //#region src/mkcert/downloader.ts var Downloader = class Downloader { static create() { return new Downloader(); } constructor() {} async download({ downloadUrl, savedPath, proxy, logProgress = true }) { let lastPercent = 0; debug_log("Downloading the mkcert executable from %s%s", downloadUrl, proxy ? ` via proxy ${proxy}` : ""); const { data } = await request.get(downloadUrl, { responseType: "arraybuffer", proxy, onDownloadProgress: throttle((progress) => { if (!logProgress) return; if (typeof progress.percent === "number") { const roundedPercent = Math.min(100, Math.floor(progress.percent)); const bucket = Math.floor(roundedPercent / 10) * 10; if (bucket >= lastPercent || roundedPercent === 100) { lastPercent = bucket; info_log(`Downloading mkcert binary... ${roundedPercent}%`); } } }, 300) }); await writeFile(savedPath, data); debug_log("The mkcert has been saved to %s", savedPath); } }; //#endregion //#region src/mkcert/record.ts var Record = class { config; constructor(options) { 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; } 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 }); } }; //#endregion //#region src/mkcert/source.ts var BaseSource = class { getPlatformIdentifier() { const arch = process.arch === "x64" ? "amd64" : process.arch; return process.platform === "win32" ? `windows-${arch}.exe` : `${process.platform}-${arch}`; } }; /** * Download mkcert from github.com */ var GithubSource = class GithubSource extends BaseSource { static create() { return new GithubSource(); } constructor() { super(); } async getSourceInfo() { const { data } = await request({ method: "GET", url: "https://api.github.com/repos/FiloSottile/mkcert/releases/latest" }); 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; return { downloadUrl, version }; } }; /** * Download mkcert from coding.net * * @see https://help.coding.net/openapi */ var CodingSource = class CodingSource extends BaseSource { static CODING_API = "https://e.coding.net/open-api"; static CODING_AUTHORIZATION = "token 000f7831ec425079439b0f55f55c729c9280d66e"; static CODING_PROJECT_ID = 8524617; static REPOSITORY = "mkcert"; 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() { /** * @see https://help.coding.net/openapi#e2106ec64e75af66f188463b1bb7e165 */ 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; /** * @see https://help.coding.net/openapi#63ad6bc7469373cef575e92bb92be71e */ 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; return { downloadUrl, version }; } }; //#endregion //#region src/mkcert/version.ts const parseVersion = (version) => { return version.trim().replace(/v/i, "").split("."); }; var VersionManger = class { config; constructor(props) { this.config = props.config; } async update(version) { try { await this.config.merge({ version }); } catch (err) { warn_log("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 }; } }; //#endregion //#region src/mkcert/index.ts var Mkcert = class Mkcert { force; autoUpgrade; sourceType; savePath; source; localMkcert; proxy; downloadProgress; savedMkcert; keyFilePath; certFilePath; config; static create(options) { return new Mkcert(options); } constructor(options) { const { force, autoUpgrade, source, mkcertPath, proxy, downloadProgress = true, savePath = PLUGIN_DATA_DIR, keyFileName = "dev.pem", certFileName = "cert.pem" } = options; this.force = force; this.autoUpgrade = autoUpgrade; this.localMkcert = mkcertPath; this.proxy = proxy?.trim() || void 0; this.downloadProgress = downloadProgress; this.savePath = path.resolve(savePath); this.keyFilePath = path.resolve(this.savePath, keyFileName); this.certFilePath = path.resolve(this.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(this.savePath, process$1.platform === "win32" ? "mkcert.exe" : "mkcert"); this.config = new Config({ savePath: this.savePath }); } async getMkcertBinary() { if (this.localMkcert) if (await exists(this.localMkcert)) return this.localMkcert; else warn_log(`${this.localMkcert} does not exist, please check the mkcertPath parameter`); if (await exists(this.savedMkcert)) return this.savedMkcert; return findCommandFromPath("mkcert"); } async checkCAExists() { return (await readDir(this.savePath)).some((file) => file.includes("rootCA")); } async retainExistedCA() { if (await this.checkCAExists()) return; const mkcertBinary = await this.getMkcertBinary(); if (!mkcertBinary) return; const commandArgs = ["-CAROOT"]; debug_log(`Exec ${mkcertBinary} ${commandArgs.join(" ")}`); const commandResult = await execFile(mkcertBinary, commandArgs); const caDirPath = path.resolve(commandResult.stdout.toString().trim()); if (caDirPath === this.savePath) return; if (!await exists(caDirPath)) return; await copyDir(caDirPath, this.savePath); } async getCertificate() { return { key: await readFile(this.keyFilePath), cert: await readFile(this.certFilePath) }; } async createCertificate(hosts) { const mkcertBinary = await this.getMkcertBinary(); if (!mkcertBinary) { error_log(`Mkcert does not exist, unable to generate certificate for ${hosts.join(", ")}`); throw new Error("Mkcert binary is not found"); } await ensureDirExist(this.savePath); await this.retainExistedCA(); await execFile(mkcertBinary, [ "-install", "-key-file", this.keyFilePath, "-cert-file", this.certFilePath, ...hosts ], { env: { ...process$1.env, CAROOT: this.savePath, JAVA_HOME: void 0 } }); debug_log(`The list of generated files:\n${this.keyFilePath}\n${this.certFilePath}`); } getLatestHash = async () => { return { key: await getHash(this.keyFilePath), cert: await getHash(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(); if (!await this.getMkcertBinary()) 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(); warn_log("The mkcert does not exist, download it now"); await this.downloadMkcert(sourceInfo.downloadUrl, this.savedMkcert, this.proxy); } async upgradeMkcert() { const versionManger = new VersionManger({ config: this.config }); const sourceInfo = await this.getSourceInfo(); if (!sourceInfo) { warn_log("Can not obtain download information of mkcert, update skipped"); return; } const versionInfo = versionManger.compare(sourceInfo.version); if (!versionInfo.shouldUpdate) { debug_log("Mkcert is kept latest version, update skipped"); return; } if (versionInfo.breakingChange) { debug_log("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_log("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, this.proxy); versionManger.update(versionInfo.nextVersion); } async downloadMkcert(sourceUrl, distPath, proxy) { await Downloader.create().download({ downloadUrl: sourceUrl, savedPath: distPath, proxy, logProgress: this.downloadProgress }); } async renew(hosts) { const record = new Record({ config: this.config }); if (this.force) { debug_log("Certificate is forced to regenerate"); await this.regenerate(record, hosts); return; } if (!record.contains(hosts)) { debug_log(`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_log(`The hash changed from ${prettyLog(record.getHash())} to ${prettyLog(hash)}, start regenerate certificate`); await this.regenerate(record, hosts); return; } debug_log("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(); } }; //#endregion //#region src/utils/network.ts /** * http://nodejs.cn/api/os/os_networkinterfaces.html */ const isIPV4 = (family) => { return family === "IPv4" || family === 4; }; const isIPV6 = (family) => { return family === "IPv6" || family === 6; }; const getLocalIps = (matcher) => { const interfaceDict = os.networkInterfaces(); const addresses = /* @__PURE__ */ new Set(); for (const key in interfaceDict) { const interfaces = interfaceDict[key]; if (interfaces) { for (const item of interfaces) if (matcher(item.family)) addresses.add(item.address); } } return Array.from(addresses); }; const getLocalV4Ips = () => { return getLocalIps(isIPV4); }; const getLocalV6Ips = () => { return getLocalIps(isIPV6).map((ip) => { return ip.split("%")[0]; }).filter(Boolean); }; const getDefaultHosts = () => { return [ "localhost", "::1", ...getLocalV4Ips(), ...getLocalV6Ips() ]; }; //#endregion //#region src/index.ts const plugin = ({ apply = "serve", hosts = [], logLevel, ...mkcertOptions } = {}) => { return { name: PLUGIN_NAME, apply, config: async ({ server = {}, logLevel: viteLogLevel }) => { setLogLevel(logLevel ?? viteLogLevel ?? "info"); if (typeof server.https === "boolean" && server.https === false) return; const mkcert = Mkcert.create(mkcertOptions); await mkcert.init(); const allHosts = [...getDefaultHosts(), ...hosts]; if (typeof server.host === "string") 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 { server: { https: httpsConfig }, preview: { https: httpsConfig } }; } }; }; //#endregion export { BaseSource, plugin as default };