vite-plugin-browserext-hmr
Version:
基于 Vite 构建的浏览器扩展开发环境,提供热更新功能,让扩展开发更加高效。
1,178 lines (1,168 loc) • 51.2 kB
JavaScript
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 };