UNPKG

vite-plugin-browserext-hmr

Version:

基于 Vite 构建的浏览器扩展开发环境,提供热更新功能,让扩展开发更加高效。

1,178 lines (1,168 loc) 51.2 kB
import { basename, resolve, dirname, relative } from 'node:path'; import { statSync, readFileSync, existsSync, rmSync } from 'node:fs'; import { EventEmitter } from 'node:events'; import { normalizePath, build } from 'vite'; import { parseHTML } from 'linkedom'; import { black, green, bold, gray, cyan } from 'colorette'; import { fileURLToPath } from 'node:url'; import * as esbuild from 'esbuild'; import { createHash } from 'node:crypto'; import { stripVTControlCharacters } from 'node:util'; // 使用esbuild压缩混淆代码 async function minifyCode(code, config = { minify: true, format: "esm", }) { try { const result = await esbuild.transform(code, { ...config, }); return result.code; } catch (error) { console.error("代码压缩混淆失败:", error); return code; // 如果压缩失败,返回原始代码 } } // 比较文件路径 function arePathsSameFileSync(path1, path2) { try { const stat1 = statSync(path1); const stat2 = statSync(path2); return stat1.dev === stat2.dev && stat1.ino === stat2.ino; } catch (error) { return false; } } function formatDevUrl(src, serverConfig) { const port = serverConfig?.port || 3000; const srcPath = src.startsWith("/") ? src : `/${src}`; return `http://localhost:${port}${srcPath}`; } function extractImports(content) { const importRegex = /import\s+.*?['"].*?['"];?/gs; const imports = content.match(importRegex) || []; const cleanedContent = content.replace(importRegex, ""); return { imports, cleanedContent }; } // 生成热更新客户端代码 const generateHotReloadCode = async (path) => { try { const port = global.__server?.config?.server?.port || 3000; const socketProtocol = global.__server?.config?.server?.https ? "wss" : "ws"; const base = global.__server?.config?.base; const socketHost = `localhost:${port}${base}`; const wsToken = `${global.__server?.config?.webSocketToken}`; const overlay = global.__server?.config?.server?.hmr?.overlay !== false; const hmrConfigName = basename(global.__server?.config?.configFile || "vite.config.js"); const base$1 = global.__server?.config?.base || "/"; const isItB = path === true ? true : path && isItBackgroundUrl(normalizePath(path) ?.replace(normalizePath(process.cwd()), "") ?.replace(/^\//, "") ?.replace(/.ts$/, ".js") || ""); let backgroundEntrypoint = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "./background-entrypoint.js"), "utf-8"); let backgroundEntrypointClient = (!isItB && readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "./background-entrypoint-client.js"), "utf-8")) || ""; if (backgroundEntrypoint) { backgroundEntrypoint = backgroundEntrypoint .replace("__socketProtocol__", `"${socketProtocol}"`) .replace("__socketHost__", `"${socketHost}"`) .replace("__overlay__", `${overlay}`) .replace("__wsToken__", `"${wsToken}"`); } if (backgroundEntrypointClient) { backgroundEntrypointClient = backgroundEntrypointClient .replace("__hmrConfigName__", `"${hmrConfigName}"`) .replace("__base$1__", `"${base$1}"`); } // 取import和内容 const { imports: imports1, cleanedContent: content1 } = extractImports(backgroundEntrypoint); const { imports: imports2, cleanedContent: content2 } = extractImports(backgroundEntrypointClient); const mergedImports = [...new Set([...imports1, ...imports2])].join("\n"); const code = `${mergedImports}\n(function(){\n${content1}\n${content2}\n})()`; return code; } catch (err) { console.log(err); return ""; } }; const numberFormatter = new Intl.NumberFormat("en", { maximumFractionDigits: 2, minimumFractionDigits: 2, }); const displaySize = (bytes) => { return `${numberFormatter.format(bytes / 1e3)} kB`; }; function getHash(input, length = 12) { const hashStr = createHash("sha256").update(input).digest("hex"); if (length) { return hashStr.slice(0, length); } return hashStr; } function cleanStack(stack) { return stack .split(/\n/) .filter((l) => /^\s*at/.test(l)) .join("\n"); } function prepareError(err$2) { return { message: stripVTControlCharacters(err$2.message)?.split("/\n")?.shift(), stack: stripVTControlCharacters(cleanStack(err$2.stack || "")), id: err$2.id, frame: stripVTControlCharacters(err$2.frame || ""), plugin: "vite-plugin-browserext-hmr", pluginCode: err$2.pluginCode?.toString(), loc: err$2.loc, }; } const isItBackgroundUrl = (path) => { const background = global.__finalManifests.background; const { service_worker, scripts } = background ?? {}; if (service_worker) { return service_worker?.replace(/^\//, "") === path?.replace(/^\//, ""); } else if (scripts) { return scripts?.some((i) => i?.replace(/^\//, "") === path?.replace(/^\//, "")); } }; // 防抖函数 function debounce(func, delay, immediate) { let timer; return function () { if (timer) clearTimeout(timer); { let firstRun = !timer; timer = setTimeout(() => { timer = null; }, delay); if (firstRun) { func.apply(this, arguments); } } }; } // 输出文件 const outputFile = async (files) => { const fs = await import('fs-extra'); for (let index = 0; index < files.length; index++) { const element = files[index]; const { fileName, code } = element; if (!fileName) continue; const dir = global.__config?.build?.outDir ?? "dist"; const name = `${fileName.startsWith("/") ? "" : `/`}${fileName}`; const filePath = `${dir}${name}`; await fs.outputFile(filePath, code, "utf-8"); const size = Buffer.byteLength(code); if (name.endsWith(".map") || name.endsWith("manifest.json")) continue; console.log(`${black(dir)}${green(name)}`, " ", black(bold(displaySize(size)))); } }; /** * 处理manifest.json * @param manifestPath manifest.json文件路径 * @param manifestData 需要写入到manifest.json的数据 * @returns */ function manifestJsonProcessPlugin({ manifestPath, manifestData, }) { return { name: "vitePluginBrowserextHmr:manifestJsonProcessPlugin", enforce: "post", // 在生成bundle时写入manifest.json文件 async generateBundle() { try { // 从manifestPath读取现有的manifest.json文件 let baseManifest = {}; let basePackage = {}; const packagePath = resolve(process.cwd(), "package.json"); if (existsSync(packagePath)) { try { const packageConent = readFileSync(packagePath, "utf-8"); basePackage = JSON.parse(packageConent); } catch (err) { basePackage = {}; } } if (manifestPath && existsSync(manifestPath)) { try { const manifestContent = readFileSync(manifestPath, "utf-8"); baseManifest = JSON.parse(manifestContent); } catch (error) { console.error(`读取manifest文件失败: ${manifestPath}`, error); // 如果读取失败,使用默认的基础manifest baseManifest = { name: "Chrome Extension", version: "1.0.0", manifest_version: 3, description: "Chrome Extension built with Vite", }; } } else { console.log(gray(`未找到manifest文件或未指定路径,使用默认配置`)); // 使用默认的基础manifest baseManifest = { name: "Chrome Extension", version: "1.0.0", manifest_version: 3, description: "Chrome Extension built with Vite", }; } // 合并用户提供的manifest数据 const finalManifest = process.env.BUILD_CRX_NOTIFIER === "dev" ? { ...baseManifest, ...manifestData, host_permissions: ["*://*/*", "http://localhost/*"], content_security_policy: { extension_pages: `script-src 'self' 'wasm-unsafe-eval' http://localhost:${global.__server?.config?.server?.port || 3000}; object-src 'self';`, sandbox: `script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:${global.__server?.config?.server?.port || 3000}; sandbox allow-scripts allow-forms allow-popups allow-modals; child-src 'self';`, }, permissions: [ ...(baseManifest?.permissions || []), "tabs", "scripting", ], } : { ...baseManifest, ...manifestData, }; const finalManifests = { ...finalManifest, name: basePackage.name || finalManifest.name, version: basePackage.version || finalManifest.version, description: basePackage.description || finalManifest.description, }; // 没有service-worker.js的时候加一个来支持热更新 if (!(finalManifests?.background?.service_worker || finalManifests?.background?.scripts?.length) && process.env.BUILD_CRX_NOTIFIER === "dev") { finalManifests.background = { service_worker: "service-worker.js" }; const source = await generateHotReloadCode(true); this.emitFile({ type: "asset", fileName: "service-worker.js", source, }); } // 开发环境给content_scripts里面加webcomponents Polyfill if (finalManifests.content_scripts?.length && process.env.BUILD_CRX_NOTIFIER === "dev") { for (let index = 0; index < finalManifests.content_scripts.length; index++) { const item = finalManifests.content_scripts[index]; if (item.js?.length) { item.js = [...new Set(["webcomponents-bundle.js", ...item.js])]; } } this.emitFile({ type: "asset", fileName: "webcomponents-bundle.js", source: readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "./webcomponents-bundle.js"), "utf-8"), }); } global.__manifestVersion = finalManifests.manifest_version; global.__finalManifests = finalManifests; // 给devtools_page生成devtools.js文件用以注册devtools面板 if (finalManifests.devtools_page) { // 根据devtools_page生成devtools.js文件 try { const devtoolsPage = resolve(process.cwd(), finalManifests.devtools_page); if (!existsSync(devtoolsPage)) { return; } this.emitFile({ type: "asset", fileName: `${normalizePath(dirname(devtoolsPage.replace(process.cwd(), "")))?.replace(/^\//, "") ?? ""}/devtools.js`, source: `chrome.devtools.panels.create("${finalManifests.name}", "","${finalManifests.devtools_page}",);`, }); } catch (error) { console.error("devtools.js生成失败:", error); } } const finalManifestsCopy = JSON.parse(JSON.stringify(finalManifests)); // 置空注入脚本为动态注册预留位置 if (process.env.BUILD_CRX_NOTIFIER === "dev") { const manifestContent_Map = JSON.stringify(finalManifests, null, 2); this.emitFile({ type: "asset", fileName: "manifest.map.json", source: manifestContent_Map, }); finalManifestsCopy.content_scripts = undefined; } // 将manifest对象转换为JSON字符串 const manifestContent = JSON.stringify(finalManifestsCopy, null, 2); // 使用emitFile将manifest.json写入到dist目录 this.emitFile({ type: "asset", fileName: "manifest.json", source: manifestContent, }); // console.log(cyan("\n✓ 已生成manifest.json文件")); } catch (error) { console.log(gray("\n生成manifest.json文件失败:"), error); } }, }; } const filterItem = (i) => ![ "vite-plugin-browserext-hmr", "vitePluginBrowserextHmr:manifestJsonProcessPlugin", "vitePluginBrowserextHmr:jsTypeEntryFileOutputPlugin", "vitePluginBrowserextHmr:fileCopyPlugin", ].includes(i.name); function createBuilderWatch(jsItem) { return { name: "vitePluginBrowserextHmr:createBuilderWatch", enforce: "post", config(config) { if (!config.plugins) config.plugins = []; if (!config.build) config.build = {}; if (!config.build?.rollupOptions) config.build.rollupOptions = {}; config.build.sourcemap = process.env.BUILD_CRX_NOTIFIER === "dev" ? true : config.build.sourcemap; config.plugins = config.plugins?.filter(filterItem); config.build.rollupOptions.input = undefined; config.build.lib = { entry: jsItem.path, formats: ["iife"], name: jsItem.name, fileName: jsItem.name, }; config.build.rollupOptions.output = { extend: true, assetFileNames: (assetInfo) => { // 入口文件 const findName = jsItem.name === assetInfo.name?.split?.(".")?.shift?.() ? jsItem : undefined; // 获取js文件的相对路径 const relativePath = normalizePath(findName ? relative(process.cwd(), findName.path) : ""); const assetFileNames = `${relativePath?.split?.(".")?.shift?.()}.css`; global.__content_scripts_css[assetFileNames] = assetFileNames; return assetFileNames; }, entryFileNames: (chunkInfo) => { // 入口文件 const findName = jsItem.name === chunkInfo.name ? jsItem : undefined; // 获取js文件的相对路径 const relativePath = normalizePath(findName ? relative(process.cwd(), findName.path) : ""); return `${relativePath?.split?.(".")?.shift?.()}.js`; }, }; if (config?.build?.rollupOptions?.plugins?.length) { config.build.rollupOptions.plugins = config.build.rollupOptions.plugins?.filter(filterItem); } }, resolveId(id) { // 处理虚拟脚本 if (process.env.BUILD_CRX_NOTIFIER === "dev" && id.startsWith("virtualBuildCrxNotifierPluginHrm")) { return id; } }, async load(id) { // 处理虚拟脚本 if (process.env.BUILD_CRX_NOTIFIER === "dev" && id.startsWith("virtualBuildCrxNotifierPluginHrm")) { const pathStr = typeof jsItem.path === "string" ? jsItem.path : jsItem.path?.toString?.(); const hotReloadCode = await generateHotReloadCode(pathStr); return hotReloadCode; } }, async transform(code, id) { const pathStr = typeof jsItem.path === "string" ? jsItem.path : jsItem.path?.toString?.(); if (process.env.BUILD_CRX_NOTIFIER === "dev" && arePathsSameFileSync(pathStr, id)) { // 将热更新脚本用虚拟脚本的方式引入 const codeStr = `import "virtualBuildCrxNotifierPluginHrm";\n` + code; return { code: codeStr, map: null }; } return { code, map: null }; }, async renderChunk(code) { const pathStr = typeof jsItem.path === "string" ? jsItem.path : jsItem.path?.toString?.(); const isBackground = isItBackgroundUrl(normalizePath(pathStr) ?.replace(normalizePath(process.cwd()), "") ?.replace(/.ts$/, ".js") || ""); if (process.env.BUILD_CRX_NOTIFIER === "dev" && isBackground) { return { code: `try{\n${code}\n}catch(e){console.log(e)}`, map: null, }; } return { code, map: null }; }, async generateBundle() { if (Object.keys(global.__content_scripts_css || {})?.length) { const contentScripts = global.__finalManifests?.content_scripts ?? []; // 确保 jsItem.path 是字符串类型 const pathStr = typeof jsItem.path === "string" ? jsItem.path : jsItem.path?.toString?.(); // 当前入口js文件路径加文件名(不包含后缀) const path = normalizePath(pathStr) ?.replace(normalizePath(process.cwd()), "") ?.split(".") ?.shift?.() ?.replace(/^\//, ""); contentScripts?.forEach((item, j) => { // 得到content_scripts的js文件路径(不包含后缀) const itemPath = item.js?.map((js) => normalizePath(js)?.split(".")?.shift?.()?.replace(/^\//, "")); const match = itemPath.includes(path); if (match) { const css = [ ...(contentScripts[j]?.css ?? []), ...Object.keys(global.__content_scripts_css || {}), ]?.reduce((previousValue, currentValue) => { if (!previousValue.includes(currentValue)) { previousValue.push(currentValue); } return previousValue; }, []); contentScripts[j].css = [...css]; } }); global.__content_scripts_css = {}; const finalManifests = { ...(global.__finalManifests ?? {}), content_scripts: contentScripts, }; const manifestContent = JSON.stringify(finalManifests, null, 2); // 使用emitFile将manifest.json写入到dist目录 global.__finalManifests = finalManifests; // 开发环境不生成css的静态注入 if (process.env.BUILD_CRX_NOTIFIER !== "dev") { this.emitFile({ type: "asset", fileName: "manifest.json", source: manifestContent, }); } else { this.emitFile({ type: "asset", fileName: "manifest.map.json", source: manifestContent, }); } } }, }; } async function singlePackJsFileMethod(jsItem, inlineConfig, configBuildConfig = { write: false, }) { const jsPathName = relative(process.cwd(), jsItem.path); if (!jsPathName || !existsSync(jsItem.path)) { console.log(gray(jsPathName + "文件不存在!")); return; } // 现在重新设置监听预设js、ts文件 const builder = await build({ ...inlineConfig, // mode: "production", define: { "process.env.NODE_ENV": '"production"', }, plugins: [createBuilderWatch(jsItem)], build: configBuildConfig, }); return builder; } // 单独监听和构建js入口部分-对应background和contentjs文件 const batchPackJsFilesMethod = async ({ jsPaths, inlineConfig }, configBuildConfig) => { const builderArray = []; for (let index = 0; index < jsPaths.length; index++) { const jsItem = jsPaths[index]; const builder = await singlePackJsFileMethod(jsItem, inlineConfig, configBuildConfig); if (builder) { builderArray.push({ ...jsItem, builder, }); } } return builderArray; }; /** * 处理打包时的js入口文件 * @param jsPaths js入口文件集合 * @param inlineConfig 打包的初始化配置 * @returns */ function jsTypeEntryFileOutputPlugin({ jsPaths, inlineConfig, }) { return { name: "vitePluginBrowserextHmr:jsTypeEntryFileOutputPlugin", enforce: "post", // 在打包结束后手动将js文件移到dist async closeBundle() { if (process.env.BUILD_CRX_NOTIFIER === "dev") return; const builderArray = await batchPackJsFilesMethod({ jsPaths, inlineConfig }, { write: false }); const fileArray = []; builderArray?.forEach((item) => { item?.builder?.forEach?.((builderItem) => { builderItem?.output?.forEach?.((outputItem) => { const { fileName, type, code, source } = outputItem; fileArray.push({ fileName, code: type === "chunk" ? code : source, }); }); }); }); await outputFile(fileArray); }, }; } /** * 拷贝文件 * @param copyPaths 相对路径-需要手动移动的文件 * @returns */ function fileCopyPlugin(copyPaths) { return { name: "vitePluginBrowserextHmr:fileCopyPlugin", enforce: "post", // 在打包结束后手动将静态文件拷贝到dist async generateBundle() { if (copyPaths?.length) { // 拷贝静态文件 const fs = await import('fs-extra'); for (let index = 0; index < copyPaths?.length; index++) { const element = copyPaths[index]; fs?.default?.copySync?.(element.src, element.dest); } } }, }; } /** * 配置插件 * @param param0 * @param config 配置 * @param htmlPaths 需要监听/处理的 HTML 文件路径数组(默认:空数组),用于插入接收热更新更新消息并重新加载插件 * @param jsPaths 需要监听/处理的 JS 文件路径数组(默认:空数组),用于插入接收热更新更新消息并重新加载插件 * @param manifestPath 需要监听/处理的 manifest.json 文件路径(默认:空字符串),用于插入接收热更新更新消息并重新加载插件 * @param manifestData 需要写入到manifest.json的数据(默认:空对象) * @param copyPaths 需要写入到manifest.json的数据(默认:空对象) * @param inlineConfig 初始化配置 * @returns */ function configPlugin({ config, htmlPaths, manifestPath, manifestData, jsPaths, copyPaths, inlineConfig, }) { if (process.env.BUILD_CRX_NOTIFIER === "dev") { config.base = "./"; } if (!config.plugins) { config.plugins = []; } if (!config.build) { config.build = { rollupOptions: {} }; } if (!config.build?.rollupOptions) { config.build.rollupOptions = {}; } const rollupOptions = config.build?.rollupOptions; // 添加重置html路径的插件 rollupOptions.plugins = [ ...(rollupOptions.plugins || []), manifestJsonProcessPlugin({ manifestPath, manifestData, }), jsTypeEntryFileOutputPlugin({ jsPaths, inlineConfig, }), fileCopyPlugin(copyPaths), ]; // 添加入口文件 rollupOptions.input = { ...rollupOptions.input, ...htmlPaths?.reduce((acc, item) => { const uniqueKey = item.name || getHash(item.path); acc[uniqueKey] = item.path; return acc; }, {}), }; // 添加输出路径 rollupOptions.output = { assetFileNames: "assets/[name]-[hash].[ext]", // 静态资源 // chunkFileNames: "js/[name]-[hash].js", // 代码分割中产生的 chunk // entryFileNames: (chunkInfo: any) => { // return `[name]/${chunkInfo.name}.js`; // }, // name: "[name].js", ...rollupOptions.output, }; return config; } /** * 启动web-ext-run进程 * @param param0 * @param wxtUserConfig 配置 * @param wxtUserConfig.browser 浏览器类型 * @param wxtUserConfig.binaries 浏览器二进制文件--览器地址栏输入 chrome://version/,然后文件地址就是这个只需要的浏览器二进制文件 * @param wxtUserConfig.chromiumProfile 浏览器配置文件 * @param wxtUserConfig.chromiumPref 浏览器偏好设置 * @param wxtUserConfig.chromiumArgs 浏览器参数 * @param wxtUserConfig.firefoxProfile 火狐浏览器配置文件 * @param wxtUserConfig.firefoxPrefs 火狐浏览器偏好设置 * @param wxtUserConfig.firefoxArgs 火狐浏览器参数 * @param wxtUserConfig.openConsole 是否打开浏览器控制台 * @param wxtUserConfig.openDevtools 是否打开浏览器开发者工具 * @param wxtUserConfig.startUrls 启动URL * @param wxtUserConfig.keepProfileChanges 是否保持浏览器配置 * @param wxtUserConfig.outDir 输出目录 * @returns */ const webExtRunPlugin = async ({ wxtUserConfig, }) => { const { cmd } = await import('web-ext-run'); global.__eventEmitter.on("web-ext-run-start", async () => { if (global.__runnerNum !== 0 || !global.__buildCompleted || global.__buildInProgress || global.__runner) return; await global.__runner?.exit().catch(() => { }); // 启动web-ext-run进程 global.__runner = await cmd .run({ browserConsole: wxtUserConfig?.openConsole, devtools: wxtUserConfig?.openDevtools, startUrl: wxtUserConfig?.startUrls, keepProfileChanges: wxtUserConfig?.keepProfileChanges, ...(wxtUserConfig.browser === "firefox" ? { firefox: wxtUserConfig?.binaries?.firefox, firefoxProfile: wxtUserConfig?.firefoxProfile, prefs: wxtUserConfig?.firefoxPrefs, args: wxtUserConfig?.firefoxArgs, } : { chromiumBinary: wxtUserConfig?.binaries?.[wxtUserConfig.browser], chromiumProfile: wxtUserConfig?.chromiumProfile, chromiumPref: { devtools: { synced_preferences_sync_disabled: { // Remove content scripts from sourcemap debugger ignore list so stack traces // and log locations show up properly, see: // https://github.com/wxt-dev/wxt/issues/236#issuecomment-1915364520 skipContentScripts: false, // Was renamed at some point, see: // https://github.com/wxt-dev/wxt/issues/912#issuecomment-2284288171 "skip-content-scripts": false, }, }, }, args: [ "--unsafely-disable-devtools-self-xss-warnings", "--disable-features=DisableLoadExtensionCommandLineSwitch", ...(wxtUserConfig?.chromiumArgs ?? []), ], }), target: wxtUserConfig.browser === "firefox" ? "firefox-desktop" : "chromium", sourceDir: wxtUserConfig.outDir, noReloadManagerExtension: true, noReload: true, noInput: true, }, { shouldExitProgram: false }) .catch((err) => { console.error("浏览器启动失败:", err); }); global.__runnerNum++; }); // 监听关闭事件,关闭web-ext-run进程 global.__eventEmitter.on("web-ext-run-close", async () => { if (global.__runnerNum > 0) { await global.__runner?.exit().catch(() => { }); global.__runnerNum--; } }); }; // 优化全局变量初始化,使用一个对象批量处理 const globalDefaults = { __buildInProgress: false, __buildCompleted: false, __server: {}, __inlineScriptContents: {}, __virtualInlineScript: "virtual:crx-inline-script", __eventEmitter: new EventEmitter(), __runner: null, __runnerNum: 0, __manifestVersion: 3, __finalManifests: {}, __config: {}, __jsCatch: new Map(), __builder: null, __content_scripts_css: {}, __dependenciesMap: new Map(), __changeFuncArray: [], __changeFuncArrayStatus: false, __changeStartFunc: () => { if (!global.__changeFuncArrayStatus) { global.__changeFuncArray?.pop?.()?.(); } }, __buildFuncArray: [], __buildStateFunc: () => { if (!global.__buildInProgress) { global.__buildFuncArray?.pop?.()?.(); } }, __errorState: null, }; for (const [key, value] of Object.entries(globalDefaults)) { if (global[key] === undefined) { global[key] = value; } } const inlineConfig = { root: undefined, base: undefined, mode: "development", configFile: undefined, configLoader: undefined, logLevel: undefined, clearScreen: undefined, build: {}, }; /** * 启动本地开发环境工具浏览器插件热更新,主动重新加载插件实现更新 * 用于配置开发模式下的端口、文件监听路径及延迟行为 * * @param param0 * @param options.htmlPaths - 需要监听/处理的 HTML 文件路径数组(默认:空数组),用于插入接收热更新更新消息并重新加载插件 * @param options.jsPaths - 需要监听/处理的 JS 文件路径数组(默认:空数组),用于插入接收热更新更新消息并重新加载插件 * @param options.manifestPath - 需要监听/处理的 manifest.json 文件路径(默认:空字符串),用于插入接收热更新更新消息并重新加载插件 * @param options.copyPaths - 需要拷贝的文件路径 * @param options.manifestData - 需要写入到manifest.json的数据(默认:空对象) * @param options.wxtUserConfig - 浏览器配置 * @param options.wxtUserConfig.browser - 浏览器类型 * @param options.wxtUserConfig.binaries - 浏览器二进制文件 * @param options.wxtUserConfig.binaries.chrome - Chrome浏览器二进制文件 * @param options.wxtUserConfig.binaries.firefox - Firefox浏览器二进制文件 * @param options.wxtUserConfig.binaries.edge - Edge浏览器二进制文件 * @param options.wxtUserConfig.chromiumProfile - 浏览器配置文件 * @param options.wxtUserConfig.chromiumPref - 浏览器偏好设置 * @param options.wxtUserConfig.chromiumArgs - 浏览器参数 * @param options.wxtUserConfig.firefoxProfile - 火狐浏览器配置文件 * @param options.wxtUserConfig.firefoxPrefs - 火狐浏览器偏好设置 * @param options.wxtUserConfig.firefoxArgs - 火狐浏览器参数 * @param options.wxtUserConfig.openConsole - 是否打开浏览器控制台 * @param options.wxtUserConfig.openDevtools - 是否打开浏览器开发者工具 * @param options.wxtUserConfig.startUrls - 启动URL * @param options.wxtUserConfig.keepProfileChanges - 是否保持浏览器配置 * @param options.wxtUserConfig.outDir - 输出目录 * @returns */ function vitePluginBrowserextHmr({ htmlPaths = [], jsPaths = [], manifestPath, manifestData = {}, // 需要写入到manifest.json的数据 copyPaths, wxtUserConfig, }) { // 更新依赖和返回需要输出的文件 const updateDependencies = (item) => { const fileArray = []; item?.builder?.forEach?.((builderItem) => { const moduleIds = Object.keys(builderItem.output[0]?.modules); const name = item.name; const path = item.path; global.__dependenciesMap.set(name, moduleIds ?.filter((i) => { return !i .replace(normalizePath(process.cwd()), "") .startsWith("/node_modules"); }) ?.map((i) => { return { name, path, dependencies: i, }; })); builderItem?.output?.forEach?.((outputItem) => { const { fileName, type, code, source } = outputItem; fileArray.push({ fileName, code: type === "chunk" ? code : source, }); }); }); return fileArray; }; const updateFile = async (jsItem) => { const builder = await singlePackJsFileMethod(jsItem, inlineConfig, { write: false, }); const fileArray = updateDependencies({ ...jsItem, builder }); await outputFile(fileArray); }; const buildJs = async () => { const builderArray = await batchPackJsFilesMethod({ jsPaths, inlineConfig }, { write: false }); const fileArray = []; builderArray?.forEach((item) => { const fileItemArray = updateDependencies(item); fileArray.push(...fileItemArray); }); await outputFile(fileArray); }; // 执行build命令生成dist目录 const runBuild = async () => { try { // 设置全局构建状态 global.__buildInProgress = true; // 添加web-ext-run插件 webExtRunPlugin({ wxtUserConfig: { browser: "chromium", binaries: { chromium: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", }, openConsole: true, openDevtools: true, keepProfileChanges: true, outDir: global.__config.build?.outDir || "dist", ...wxtUserConfig, }, }); const builder = await build({ ...inlineConfig, build: { write: true }, }); // const fileArray: any[] = []; // builder?.output?.forEach?.((outputItem: any) => { // const { fileName, type, code, source } = outputItem; // fileArray.push({ // fileName, // code: type === "chunk" ? code : source, // }); // }); // await outputFile(fileArray); global.__builder = builder; await buildJs(); console.log(cyan("✓ 构建结束")); // 标记构建已完成 global.__buildCompleted = true; global.__buildInProgress = false; global.__errorState = null; global.__eventEmitter.emit("web-ext-run-start"); } catch (err) { try { console.log(gray("构建失败:"), err); const errlog = prepareError(err); global.__errorState = errlog; } catch (err$1) { console.log(gray("构建失败:"), err$1); } } finally { // 无论成功还是失败,都重置构建进行中状态 global.__buildInProgress = false; if (global.__buildFuncArray?.length > 0) { global.__eventEmitter.emit("buildStart"); } } }; return { name: "vite-plugin-browserext-hmr", configureServer(server) { process.env.BUILD_CRX_NOTIFIER = "dev"; // 开发环境初始化的时候首先删除dist目录 if (process.env.BUILD_CRX_NOTIFIER === "dev" && !global.__buildCompleted) { const distPath = resolve(process.cwd(), "dist"); if (existsSync(distPath)) { rmSync(distPath, { recursive: true }); } } const _printUrls = server.printUrls; const colorUrl = (url) => cyan(url.replace(/:(\d+)\//, (_, port) => `:${bold(port)}/`)); server.printUrls = () => { _printUrls(); for (const localUrl of server.resolvedUrls?.local ?? []) { const appUrl = localUrl.endsWith("/") ? localUrl : `${localUrl}/`; const serverUrl = server.config.base && appUrl.endsWith(server.config.base) ? appUrl.slice(0, -server.config.base.length) : appUrl.slice(0, -1); htmlPaths?.forEach((item) => { const path = normalizePath(item.path?.replace(process.cwd(), "")); const inspectorUrl = `${serverUrl}/${global.__config?.build?.outDir}${path.startsWith("/") ? "" : "/"}${path}`; console.log(` ${green("\u279C")} ${bold("crx-notifier Inspector")}: ${colorUrl(`${inspectorUrl}`)}`); }); } }; const connection = debounce(() => { if (global.__errorState) { global.__server.ws.send({ type: "crx-error", err: global.__errorState, }); } global.__server.ws.send({ type: "custom", event: "crx-reload-content_scripts-register", data: global.__finalManifests.content_scripts?.map((i) => ({ js: i.js, css: i.css, matches: i.matches, persistAcrossSessions: false, })), }); }, 200); // 客户端链接时如果有错误就发送错误信息 server?.ws?.on?.("connection", connection); global.__eventEmitter.off("buildStart", global.__buildStateFunc); // 监听构建完成 global.__eventEmitter.on("buildStart", global.__buildStateFunc); const listeningFunc = async () => { global.__server = server; global.__buildFuncArray = [() => runBuild()]; // 如果正在构建,则将任务添加到队列中 if (!global.__buildInProgress) { global.__buildFuncArray?.pop?.()?.(); } // await runBuild(); }; // 服务器启动后执行 server.httpServer?.once("listening", listeningFunc); /**------------------------------添加文件监听功能------------------------------- */ const changeFunc = async (changedPath) => { const dependencies = Array.from(global.__dependenciesMap.values()) .flatMap((items) => items) .find((items) => arePathsSameFileSync(items.dependencies, changedPath)); try { global.__changeFuncArrayStatus = true; // 检查变化的文件是否是我们监听的文件 if (!dependencies || !dependencies.path || !existsSync(changedPath)) return; const changedConent = readFileSync(changedPath, "utf-8"); const conentKey = getHash(changedConent, null); // 内容变化判断 if (global.__jsCatch?.get(changedPath) === conentKey) return; global.__jsCatch?.set(changedPath, conentKey); await updateFile(dependencies); const path = normalizePath(dependencies.path) ?.replace(normalizePath(process.cwd()), "") ?.replace(/^\//, "") ?.replace(/.ts$/, ".js") || ""; const isBackground = isItBackgroundUrl(path); // 通知扩展重新加载 if (isBackground) { global.__server.ws.send({ type: "custom", event: "crx-reload", }); } else { const contentScripts = global.__finalManifests.content_scripts; const scriptArray = []; contentScripts?.forEach((item) => { const itemPath = item.js?.map((js) => normalizePath(js)?.replace(/^\//, "")); const match = itemPath.includes(path); if (match) { scriptArray.push(item); } }); if (scriptArray.length) { global.__server.ws.send({ type: "custom", event: "crx-reload-content_scripts", data: scriptArray?.map((i) => ({ js: i.js, css: i.css, matches: i.matches, persistAcrossSessions: false, })), }); } } global.__errorState = null; } catch (err) { try { console.log(gray(`构建文件 ${relative(process.cwd(), dependencies.path)} 失败:`), err); const errlog = prepareError(err); global.__errorState = errlog; global.__server.ws.send({ type: "crx-error", err: errlog, }); } catch (err) { console.log(gray(`构建失败:`), err); } } finally { global.__changeFuncArrayStatus = false; if (global.__changeFuncArray?.length > 0) { global.__eventEmitter.emit("changeStart"); } } }; global.__eventEmitter.off("changeStart", global.__changeStartFunc); // 监听构建完成 global.__eventEmitter.on("changeStart", global.__changeStartFunc); const changeFunc1 = async (changedPath) => { global.__changeFuncArray = [() => changeFunc(changedPath)]; // 如果正在构建,则将任务添加到队列中 if (!global.__changeFuncArrayStatus) { global.__changeFuncArray?.pop?.()?.(); } }; // 监听文件变化事件 server.watcher.on("change", changeFunc1); /**------------------------------------------------------------------------ */ }, config(config) { const configPluginResult = configPlugin({ config, htmlPaths, manifestPath, manifestData, jsPaths, copyPaths, inlineConfig, }); return configPluginResult; }, configResolved(resolvedConfig) { // 重置webSocketToken为固定的值 resolvedConfig.webSocketToken = "vitePluginBrowserextHmr"; global.__config = resolvedConfig; }, resolveId(id) { // 处理虚拟脚本 if (process.env.BUILD_CRX_NOTIFIER === "dev" && id.startsWith(global.__virtualInlineScript)) { return id; } }, load(id) { // 处理虚拟脚本 if (process.env.BUILD_CRX_NOTIFIER === "dev" && id.startsWith(global.__virtualInlineScript)) { const key = id.substring(id.indexOf("?") + 1); return global.__inlineScriptContents[key]; } }, async transformIndexHtml(html, ctx) { const { document } = parseHTML(html); const path = ctx.path; let baseManifest = {}; if (existsSync(manifestPath)) { const manifestContent = readFileSync(manifestPath, "utf-8"); baseManifest = JSON.parse(manifestContent); } // 给devtools_page生成devtools.js文件用以注册devtools面板 if (baseManifest.devtools_page) { if (path.replace(process.cwd(), "").includes(baseManifest.devtools_page)) { const devtoolsPanel = document.createElement("script"); devtoolsPanel.src = `./devtools.js`; document.body.appendChild(devtoolsPanel); } } if (process.env.BUILD_CRX_NOTIFIER !== "dev") { // 处理内联脚本为外联脚本 const inlineScripts = document.querySelectorAll("script:not([src])"); for (let i = 0; i < inlineScripts.length; i++) { const script = inlineScripts[i]; const textContent = script.textContent ?? ""; const key = getHash(textContent); const pathDirname = dirname(path); const fileName = `${pathDirname.replace(/^\//, "")}/${key}.js`; const source = await minifyCode(textContent, { minify: false, format: "esm", }); this.emitFile({ type: "asset", fileName, source, }); const virtualScript = document.createElement("script"); virtualScript.type = "module"; virtualScript.src = `./${key}.js`; script.replaceWith(virtualScript); } return document.toString(); } // 注入Vite客户端脚本,用于热更新 const viteClientScript = document.createElement("script"); viteClientScript.setAttribute("type", "module"); viteClientScript.setAttribute("src", `http://localhost:${global.__server?.config?.server?.port || 3000}/@vite/client`); document.head.insertBefore(viteClientScript, document.head.firstChild); // 处理内联脚本为虚拟脚本 const inlineScripts = document.querySelectorAll("script:not([src])"); for (let i = 0; i < inlineScripts.length; i++) { const script = inlineScripts[i]; const textContent = await minifyCode(script.textContent ?? "", { minify: false, format: "esm", }); const key = getHash(textContent); global.__inlineScriptContents[key] = textContent; const virtualScript = document.createElement("script"); virtualScript.type = "module"; virtualScript.src = `http://localhost:${global.__server?.config?.server?.port || 3000}/@id/${global.__virtualInlineScript}?${key}`; script.replaceWith(virtualScript); } return document.toString(); }, async transform(code, id) { if (process.env.BUILD_CRX_NOTIFIER !== "dev") { return { code, map: null }; } // 开发模式-匹配中的html文件加sw监听 if (htmlPaths.some((item) => arePathsSameFileSync(item.path, id))) { // 处理html文件路径为开发环境路径 const { document } = parseHTML(code); document .querySelectorAll('script[type="module"]') .forEach((script) => { const src = script.getAttribute("src"); if (!src || src.startsWith("http") || src.startsWith("/@vite/client")) return; script.setAttribute("src", formatDevUrl(src, global.__server?.config?.server)); }); return { code: document.toString(), map: null }; } return { code, map: null }; }, }; } export { vitePluginBrowserextHmr };