vite-plugin-web-extension
Version:

1,401 lines (1,381 loc) • 45.7 kB
JavaScript
// src/plugins/manifest-loader-plugin.ts
import * as vite4 from "vite";
// src/constants.ts
var MANIFEST_LOADER_PLUGIN_NAME = `web-extension:manifest`;
var LABELED_STEP_PLUGIN_NAME = `web-extension:labeled-step`;
var MULTIBUILD_COMPLETE_PLUGIN_NAME = `web-extension:multibuild`;
var BUNDLE_TRACKER_PLUGIN_NAME = `web-extension:bundle-tracker`;
var HMR_REWRITE_PLUGIN_NAME = `web-extension:hmr-rewrite`;
// src/logger.ts
var RESET = "\x1B[0m";
var BOLD = "\x1B[1m";
var DIM = "\x1B[2m";
var RED = "\x1B[91m";
var GREEN = "\x1B[92m";
var YELLOW = "\x1B[93m";
var BLUE = "\x1B[94m";
var VIOLET = "\x1B[95m";
var CYAN = "\x1B[96m";
function createLogger(verbose, disableColor) {
if (disableColor) {
RESET = "";
BOLD = "";
DIM = "";
RED = "";
GREEN = "";
YELLOW = "";
BLUE = "";
VIOLET = "";
CYAN = "";
}
return {
verbose(message) {
if (!verbose)
return;
console.debug(
message.split("\n").map(
(line) => ` ${BOLD}${DIM}${MANIFEST_LOADER_PLUGIN_NAME}${RESET} ${line}`
).join("\n")
);
},
log(message) {
console.log(message);
},
warn(message) {
console.warn(
message.split("\n").map(
(line) => `${BOLD}${YELLOW}[${MANIFEST_LOADER_PLUGIN_NAME}] WARN: ${line}${RESET}`
).join("\n")
);
},
error(message, err) {
console.error(
message.split("\n").map(
(line) => `${BOLD}${RED}[${MANIFEST_LOADER_PLUGIN_NAME}] ERROR: ${line}${RESET}`
).join("\n")
);
console.error(err);
}
};
}
// src/build/build-context.ts
import { inspect as inspect2 } from "util";
import * as vite3 from "vite";
// src/utils.ts
import path from "path";
function compact(array) {
return array.filter((item) => item != null);
}
function trimExtension(filename) {
return filename == null ? void 0 : filename.replace(path.extname(filename), "");
}
function colorizeFilename(filename) {
let color = CYAN;
if (filename.match(/\.(html|pug)$/))
color = GREEN;
if (filename.match(/\.(css|scss|stylus|sass|png|jpg|jpeg|webp|webm|svg|ico)$/))
color = VIOLET;
return `${color}${filename}${RESET}`;
}
function defineNoRollupInput() {
const tempId = "virtual:temp.js";
const tempResolvedId = "\0" + tempId;
const tempContent = "export const temp = true;";
return {
/**
* Config used to ensure no inputs are required.
*/
config: {
build: {
lib: {
entry: tempId,
formats: ["es"],
// ES is the most minimal format. Since this is excluded from the bundle, this doesn't matter
name: tempId,
fileName: tempId
}
}
},
/**
* Handle resolving the temp entry id.
*/
resolveId(id) {
if (id.includes(tempId))
return tempResolvedId;
},
/**
* Handle loading a non-empty, basic JS script for the temp input
*/
load(id) {
if (id === tempResolvedId)
return tempContent;
},
/**
* Remove the temporary input from the final bundle.
*/
cleanupBundle(bundle) {
const tempAsset = Object.entries(bundle).find(
([_, asset]) => asset.type === "chunk" && asset.facadeModuleId === tempResolvedId
) ?? [];
if ((tempAsset == null ? void 0 : tempAsset[0]) && bundle[tempAsset[0]])
delete bundle[tempAsset[0]];
}
};
}
function getRootDir(config) {
const cwd = process.cwd();
const configFileDir = config.configFile ? path.resolve(cwd, config.configFile) : cwd;
return path.resolve(configFileDir, config.root);
}
function getOutDir(config) {
const { outDir } = config.build;
return path.resolve(getRootDir(config), outDir);
}
function getPublicDir(config) {
if (config.publicDir === "")
return;
return path.resolve(getRootDir(config), config.publicDir ?? "public");
}
function getInputPaths(paths, input) {
let inputs;
if (typeof input === "string")
inputs = [input];
else if (Array.isArray(input))
inputs = input;
else if ("entry" in input)
inputs = getInputPaths(paths, input.entry);
else
inputs = Object.values(input);
return inputs.map((file) => {
if (path.isAbsolute(file))
return path.relative(paths.rootDir, file).replaceAll("\\", "/");
return file.replaceAll("\\", "/");
});
}
function resolveBrowserTagsInObject(browser, object) {
if (Array.isArray(object)) {
return object.map((item) => resolveBrowserTagsInObject(browser, item)).filter((item) => !!item);
} else if (typeof object === "object") {
return Object.keys(object).reduce((newObject, key) => {
if (!key.startsWith("{{") || key.startsWith(`{{${browser}}}.`)) {
newObject[key.replace(`{{${browser}}}.`, "")] = resolveBrowserTagsInObject(browser, object[key]);
}
return newObject;
}, {});
} else if (typeof object === "string") {
if (!object.startsWith("{{") || object.startsWith(`{{${browser}}}.`)) {
return object.replace(`{{${browser}}}.`, "");
}
return void 0;
} else {
return object;
}
}
function withTimeout(promise, duration) {
return new Promise((res, rej) => {
const timeout = setTimeout(() => {
rej(`Promise timed out after ${duration}ms`);
}, duration);
promise.then(res).catch(rej).finally(() => clearTimeout(timeout));
});
}
function getOutputFile(file) {
return file.replace(/\.(pug)$/, ".html").replace(/\.(scss|stylus|sass)$/, ".css").replace(/\.(jsx|ts|tsx)$/, ".js");
}
// src/plugins/labeled-step-plugin.ts
function labeledStepPlugin(logger, total, index, paths) {
let finalConfig;
let buildCount = 0;
function printFirstBuild() {
var _a, _b;
logger.log("");
const progressLabel = `(${index + 1}/${total})`;
const input = ((_b = (_a = finalConfig.build) == null ? void 0 : _a.rollupOptions) == null ? void 0 : _b.input) || finalConfig.build.lib;
if (!input) {
logger.warn(`Building unknown config ${progressLabel}`);
return;
}
const inputs = getInputPaths(paths, input);
logger.log(
`Building ${inputs.map(colorizeFilename).join(", ")} ${progressLabel}`
);
}
function printRebuilds() {
var _a, _b;
const input = (_b = (_a = finalConfig.build) == null ? void 0 : _a.rollupOptions) == null ? void 0 : _b.input;
if (input == null) {
logger.warn("Rebuilding unknown config");
return;
}
const files = getInputPaths(paths, input);
logger.log(`Rebuilding ${files.map(colorizeFilename).join(", ")}`);
}
return {
name: LABELED_STEP_PLUGIN_NAME,
configResolved(config) {
finalConfig = config;
if (buildCount == 0)
printFirstBuild();
else
printRebuilds();
buildCount++;
}
};
}
// src/plugins/multibuild-complete-plugin.ts
import Lock from "async-lock";
function createMultibuildCompleteManager(onBuildsSucceeded) {
let activeBuilds = 0;
const buildStatuses = {};
let nextBuildId = 0;
let hasTriggeredCallback = false;
const lock = new Lock();
const lockKey = "builds";
function incrementBuildCount(buildId) {
return lock.acquire(lockKey, () => {
activeBuilds++;
hasTriggeredCallback = false;
delete buildStatuses[buildId];
});
}
function decreaseBuildCount(buildId, err) {
return lock.acquire(lockKey, async () => {
activeBuilds--;
if (err == null)
delete buildStatuses[buildId];
else
buildStatuses[buildId] = err;
});
}
function checkCompleted() {
return lock.acquire(lockKey, async () => {
if (activeBuilds === 0 && Object.values(buildStatuses).length === 0 && !hasTriggeredCallback) {
hasTriggeredCallback = true;
await onBuildsSucceeded();
}
});
}
return {
plugin() {
const buildId = nextBuildId++;
incrementBuildCount(buildId);
let hasBuildOnce = false;
return {
name: MULTIBUILD_COMPLETE_PLUGIN_NAME,
enforce: "post",
async buildStart() {
if (hasBuildOnce)
await incrementBuildCount(buildId);
hasBuildOnce = true;
},
/**
* This hook is called regardless of if the build threw an error, so it's the only reliable
* place that can decrement the build counter regardless of build success.
*/
async buildEnd(err) {
await decreaseBuildCount(buildId, err);
},
/**
* Call the completed callback AFTER the bundle has closed, so output files have been
* written to the disk.
*
* This is only called on success. Only when the SLOWEST build finishes on success. So we
* still need to check to make sure all builds have finished and were successful. We also
* only want to cal the callback from one of the plugin instances, not all of them. So we
* only call the callback from the first plugin instance that finished.
*/
async closeBundle() {
await checkCompleted();
}
};
}
};
}
// src/plugins/bundle-tracker-plugin.ts
function bundleTrackerPlugin() {
let chunks;
return {
name: BUNDLE_TRACKER_PLUGIN_NAME,
buildStart() {
chunks = void 0;
},
writeBundle(_, bundle) {
chunks = Object.values(bundle).map((chunk) => chunk.fileName);
},
getChunks: () => chunks
};
}
// src/build/getViteConfigsForInputs.ts
import path3 from "path";
import * as vite2 from "vite";
// src/plugins/hmr-rewrite-plugin.ts
import * as vite from "vite";
import { parseHTML } from "linkedom";
import path2 from "path";
import { inspect } from "util";
function hmrRewritePlugin(config) {
const { paths, logger, server } = config;
let inputIds = [];
const serverOptions = server.config.server;
let hmrOptions = typeof server.config.server.hmr === "object" ? server.config.server.hmr : void 0;
function serializeDefine(define) {
let res = `{`;
const keys = Object.keys(define);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const val = define[key];
res += `${JSON.stringify(key)}: ${handleDefineValue(val)}`;
if (i !== keys.length - 1) {
res += `, `;
}
}
return res + `}`;
}
function handleDefineValue(value) {
if (typeof value === "undefined")
return "undefined";
if (typeof value === "string")
return value;
return JSON.stringify(value);
}
return {
name: HMR_REWRITE_PLUGIN_NAME,
config(config2) {
var _a, _b;
inputIds = Object.values(((_b = (_a = config2.build) == null ? void 0 : _a.rollupOptions) == null ? void 0 : _b.input) ?? {}).map(
(inputId) => vite.normalizePath(inputId)
);
return {
server: {
hmr: {
protocol: "http:",
host: "localhost",
port: 5173
}
},
define: {
// Coped from node_modules/vite, do a global search for: vite:client-inject
// These are used in node_modules/vite/dist/client/client.mjs, check there to see if a var
// can be null or not.
__MODE__: JSON.stringify(config2.mode || null),
__BASE__: JSON.stringify(serverOptions.base || "/"),
__DEFINES__: serializeDefine(config2.define || {}),
__SERVER_HOST__: JSON.stringify(serverOptions.host || "localhost"),
__HMR_PROTOCOL__: JSON.stringify((hmrOptions == null ? void 0 : hmrOptions.protocol) || null),
__HMR_HOSTNAME__: JSON.stringify((hmrOptions == null ? void 0 : hmrOptions.host) || "localhost"),
__HMR_PORT__: JSON.stringify(
(hmrOptions == null ? void 0 : hmrOptions.clientPort) || (hmrOptions == null ? void 0 : hmrOptions.port) || 5173
),
__HMR_DIRECT_TARGET__: JSON.stringify(
`${serverOptions.host ?? "localhost"}:${serverOptions.port ?? 5173}${config2.base || "/"}`
),
__HMR_BASE__: JSON.stringify(serverOptions.base ?? "/"),
__HMR_TIMEOUT__: JSON.stringify((hmrOptions == null ? void 0 : hmrOptions.timeout) || 3e4),
__HMR_ENABLE_OVERLAY__: JSON.stringify((hmrOptions == null ? void 0 : hmrOptions.overlay) !== false)
}
};
},
async transform(code, id) {
if (!id.endsWith(".html") || !inputIds.includes(id))
return;
const baseUrl = "http://localhost:5173";
const serverCode = await server.transformIndexHtml(id, code);
const { document } = parseHTML(serverCode);
const pointToDevServer = (querySelector, attr) => {
document.querySelectorAll(querySelector).forEach((element) => {
const src = element.getAttribute(attr);
if (!src)
return;
const before = element.outerHTML;
if (path2.isAbsolute(src)) {
element.setAttribute(attr, baseUrl + src);
} else if (src.startsWith(".")) {
const abs = path2.resolve(path2.dirname(id), src);
const pathname = path2.relative(paths.rootDir, abs);
element.setAttribute(attr, `${baseUrl}/${pathname}`);
}
const after = element.outerHTML;
if (before !== after) {
logger.verbose(
"Transformed for dev mode: " + inspect({ before, after })
);
}
});
};
pointToDevServer("script[type=module]", "src");
pointToDevServer("link[rel=stylesheet]", "href");
return document.toString();
}
};
}
// src/build/getViteConfigsForInputs.ts
var HTML_ENTRY_REGEX = /\.(html)$/;
var SCRIPT_ENTRY_REGEX = /\.(js|ts|mjs|mts)$/;
var CSS_ENTRY_REGEX = /\.(css|scss|sass|less|stylus)$/;
var CombinedViteConfigs = class {
/**
* A single config that builds all the HTML pages.
*/
html;
/**
* A single config that builds all the HTML pages for sandbox. These are separate from `html`
* because we want to properly tree-shake out any browser API usages, since those APIs aren't
* available in sandbox pages.
*/
sandbox;
/**
* All other JS inputs from the manifest and additional inputs are separated into their own
* configs.
*
* Unlike tsup, Vite cannot be given multiple entry-points, and produce individual bundles for
* each entrypoint. Vite can only produce code-split outputs that share other files, which
* extensions cannot consume. So we build each of these separately.
*/
scripts;
/**
* CSS files cannot be built with vite 5 as the input to lib mode.
*/
css;
/**
* Similar to scripts, but for other file "types". Sometimes CSS, SCSS, JSON, images, etc, can be
* passed into Vite directly. The most common case of this in extensions are CSS files listed for
* content scripts.
*/
other;
/**
* The total number of configs required to build the extension.
*/
get count() {
return this.all.length;
}
/**
* Return all the configs as an array.
*/
get all() {
return compact(
[this.html, this.sandbox, this.scripts, this.css, this.other].flat()
);
}
applyBaseConfig(baseConfig) {
var _a, _b, _c;
if (this.html)
this.html = vite2.mergeConfig(baseConfig, this.html);
if (this.sandbox)
this.sandbox = vite2.mergeConfig(baseConfig, this.sandbox);
this.scripts = (_a = this.scripts) == null ? void 0 : _a.map(
(config) => vite2.mergeConfig(baseConfig, config)
);
this.css = (_b = this.css) == null ? void 0 : _b.map((config) => vite2.mergeConfig(baseConfig, config));
this.other = (_c = this.other) == null ? void 0 : _c.map(
(config) => vite2.mergeConfig(baseConfig, config)
);
}
};
function getViteConfigsForInputs(options) {
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p;
const { paths, additionalInputs, manifest, mode, logger, server } = options;
const configs = new CombinedViteConfigs();
const processedInputs = /* @__PURE__ */ new Set();
const hasBeenProcessed = (input) => processedInputs.has(input);
function getMultiPageConfig(entries, baseConfig) {
const newEntries = entries.filter((entry) => !hasBeenProcessed(entry));
newEntries.forEach((entry) => processedInputs.add(entry));
if (newEntries.length === 0)
return;
const plugins = mode === 2 /* DEV */ ? [
hmrRewritePlugin({
server,
paths,
logger
})
] : [];
const inputConfig = {
plugins,
build: {
rollupOptions: {
input: newEntries.reduce((input, entry) => {
input[trimExtension(entry)] = path3.resolve(paths.rootDir, entry);
return input;
}, {}),
output: {
// Configure the output filenames so they appear in the same folder
// - popup/index.html
// - popup/index.js
entryFileNames: `[name].js`,
chunkFileNames: `[name].js`,
/**
* [name] for assetFileNames is either the filename or whole path. So if you
* have two `index.html` files in different directories, they would overwrite each
* other as `dist/index.css`.
*
* See [#47](https://github.com/aklinker1/vite-plugin-web-extension/issues/47) for
* more details.
*/
assetFileNames: ({ name }) => {
if (!name)
return "[name].[ext]";
if (name && path3.isAbsolute(name)) {
name = path3.relative(paths.rootDir, name);
}
return `${trimExtension(name)}.[ext]`;
}
}
}
}
};
return vite2.mergeConfig(baseConfig, inputConfig);
}
function getIndividualConfig(entry, baseConfig) {
if (hasBeenProcessed(entry))
return;
processedInputs.add(entry);
const moduleId = trimExtension(entry);
const inputConfig = {
build: {
watch: mode !== 0 /* BUILD */ ? {} : void 0,
lib: {
name: "_",
entry,
formats: ["iife"],
fileName: () => moduleId + ".js"
}
},
define: {
// See https://github.com/aklinker1/vite-plugin-web-extension/issues/96
"process.env.NODE_ENV": `"${options.viteMode}"`
}
};
return vite2.mergeConfig(baseConfig, inputConfig);
}
function getHtmlConfig(entries) {
return getMultiPageConfig(entries, options.baseHtmlViteConfig);
}
function getSandboxConfig(entries) {
return getMultiPageConfig(entries, options.baseSandboxViteConfig);
}
function getScriptConfig(entry) {
return getIndividualConfig(entry, options.baseScriptViteConfig);
}
function getOtherConfig(entry) {
return getIndividualConfig(entry, options.baseOtherViteConfig);
}
function getCssConfig(entry) {
return getMultiPageConfig([entry], options.baseOtherViteConfig);
}
const {
htmlAdditionalInputs,
otherAdditionalInputs,
scriptAdditionalInputs,
cssAdditionalInputs
} = separateAdditionalInputs(additionalInputs);
const htmlEntries = simplifyEntriesList([
(_a = manifest.action) == null ? void 0 : _a.default_popup,
manifest.devtools_page,
manifest.options_page,
(_b = manifest.options_ui) == null ? void 0 : _b.page,
(_c = manifest.browser_action) == null ? void 0 : _c.default_popup,
(_d = manifest.page_action) == null ? void 0 : _d.default_popup,
(_e = manifest.side_panel) == null ? void 0 : _e.default_path,
(_f = manifest.sidebar_action) == null ? void 0 : _f.default_panel,
(_g = manifest.background) == null ? void 0 : _g.page,
(_h = manifest.chrome_url_overrides) == null ? void 0 : _h.bookmarks,
(_i = manifest.chrome_url_overrides) == null ? void 0 : _i.history,
(_j = manifest.chrome_url_overrides) == null ? void 0 : _j.newtab,
(_k = manifest.chrome_settings_overrides) == null ? void 0 : _k.homepage,
htmlAdditionalInputs
]);
const sandboxEntries = simplifyEntriesList([(_l = manifest.sandbox) == null ? void 0 : _l.pages]);
configs.html = getHtmlConfig(htmlEntries);
configs.sandbox = getSandboxConfig(sandboxEntries);
compact(
simplifyEntriesList([
(_m = manifest.background) == null ? void 0 : _m.service_worker,
(_n = manifest.background) == null ? void 0 : _n.scripts,
(_o = manifest.content_scripts) == null ? void 0 : _o.flatMap(
(cs) => cs.js
),
scriptAdditionalInputs
]).map(getScriptConfig)
).forEach((scriptConfig) => {
configs.scripts ?? (configs.scripts = []);
configs.scripts.push(scriptConfig);
});
compact(
simplifyEntriesList([
(_p = manifest.content_scripts) == null ? void 0 : _p.flatMap(
(cs) => cs.css
),
cssAdditionalInputs
]).map(getCssConfig)
).forEach((cssConfig) => {
configs.css ?? (configs.css = []);
configs.css.push(cssConfig);
});
compact(
simplifyEntriesList([otherAdditionalInputs]).map(getOtherConfig)
).forEach((otherConfig) => {
configs.other ?? (configs.other = []);
configs.other.push(otherConfig);
});
validateCombinedViteConfigs(configs);
return configs;
}
function separateAdditionalInputs(additionalInputs) {
const scriptAdditionalInputs = [];
const otherAdditionalInputs = [];
const htmlAdditionalInputs = [];
const cssAdditionalInputs = [];
additionalInputs == null ? void 0 : additionalInputs.forEach((input) => {
if (HTML_ENTRY_REGEX.test(input))
htmlAdditionalInputs.push(input);
else if (SCRIPT_ENTRY_REGEX.test(input))
scriptAdditionalInputs.push(input);
else if (CSS_ENTRY_REGEX.test(input))
cssAdditionalInputs.push(input);
else
scriptAdditionalInputs.push(input);
});
return {
scriptAdditionalInputs,
otherAdditionalInputs,
htmlAdditionalInputs,
cssAdditionalInputs
};
}
function simplifyEntriesList(a) {
return compact((a == null ? void 0 : a.flat()) ?? []).filter(
(a2) => !a2.startsWith("public:")
);
}
function validateCombinedViteConfigs(configs) {
if (configs.count === 0) {
throw Error(
"No inputs found in manifest.json. Run Vite with `--debug` for more details."
);
}
}
// src/build/build-context.ts
function createBuildContext({
pluginOptions,
logger
}) {
let bundles = {};
let activeWatchers = [];
function getBuildConfigs({
paths,
userConfig,
manifest,
server,
onSuccess,
mode,
viteMode
}) {
const entryConfigs = getViteConfigsForInputs({
paths,
manifest,
mode,
logger,
server,
additionalInputs: pluginOptions.additionalInputs,
baseHtmlViteConfig: pluginOptions.htmlViteConfig ?? {},
baseSandboxViteConfig: {},
baseScriptViteConfig: pluginOptions.scriptViteConfig ?? {},
baseOtherViteConfig: {},
viteMode
});
const multibuildManager = createMultibuildCompleteManager(async () => {
if (mode == 1 /* WATCH */)
printCompleted();
await (onSuccess == null ? void 0 : onSuccess());
});
const totalEntries = entryConfigs.count;
return entryConfigs.all.map(
(config, i) => vite3.mergeConfig(config, {
// Reapply the mode passed into the top level build
mode: viteMode,
// We shouldn't clear the screen for these internal builds
clearScreen: false,
// Don't empty the outDir, this is handled in the parent build process
build: { emptyOutDir: false },
plugins: [
labeledStepPlugin(logger, totalEntries, i, paths),
multibuildManager.plugin()
]
})
);
}
function printSummary(paths, buildConfigs) {
if (buildConfigs.length === 0)
return;
const lines = ["", `${BOLD}Build Steps${RESET}`];
buildConfigs.forEach((config, i) => {
var _a, _b, _c;
const input = ((_b = (_a = config.build) == null ? void 0 : _a.rollupOptions) == null ? void 0 : _b.input) ?? ((_c = config.build) == null ? void 0 : _c.lib);
if (!input)
return;
const inputs = getInputPaths(paths, input);
if (inputs.length === 1) {
lines.push(
` ${i + 1}. Building ${colorizeFilename(inputs[0])} indvidually`
);
} else {
lines.push(
` ${i + 1}. Bundling ${inputs.length} entrypoints together:`
);
inputs.forEach(
(relativePath) => lines.push(` ${DIM}\u2022${RESET} ${colorizeFilename(relativePath)}`)
);
}
});
logger.log(lines.join("\n"));
}
function printCompleted() {
logger.log(`
${GREEN}\u2713${RESET} All steps completed.
`);
}
function waitForWatchBuildComplete(watcher) {
return new Promise((res, rej) => {
watcher.on("event", async (e) => {
switch (e.code) {
case "END":
res();
break;
case "ERROR":
rej(e.error);
break;
}
});
});
}
return {
async rebuild(rebuildOptions) {
var _a, _b, _c;
const { paths, mode } = rebuildOptions;
await Promise.all(activeWatchers.map((watcher) => watcher.close()));
activeWatchers = [];
const buildConfigs = getBuildConfigs(rebuildOptions);
if (pluginOptions.printSummary)
printSummary(paths, buildConfigs);
logger.verbose("Final configs: " + inspect2(buildConfigs, void 0, 7));
process.env.VITE_PLUGIN_WEB_EXTENSION_CHILD_BUILD = "true";
try {
for (const config of buildConfigs) {
const bundleTracker = bundleTrackerPlugin();
(config.plugins ?? (config.plugins = [])).push(bundleTracker);
const output = await vite3.build(config);
if ("on" in output) {
activeWatchers.push(output);
await waitForWatchBuildComplete(output);
}
const input = ((_a = config.build) == null ? void 0 : _a.lib) ?? ((_c = (_b = config.build) == null ? void 0 : _b.rollupOptions) == null ? void 0 : _c.input);
if (input) {
const chunks = bundleTracker.getChunks() ?? [];
for (const file of getInputPaths(paths, input)) {
bundles[file] = chunks;
}
}
}
if (mode === 0 /* BUILD */) {
printCompleted();
}
} finally {
process.env.VITE_PLUGIN_WEB_EXTENSION_CHILD_BUILD = "";
}
},
getBundles() {
return bundles;
}
};
}
// src/plugins/manifest-loader-plugin.ts
import path5 from "path";
import fs2 from "fs-extra";
import { inspect as inspect5 } from "util";
// src/extension-runner/web-ext-runner.ts
import * as webExtLogger from "web-ext-run/util/logger";
import webExt from "web-ext-run";
import { inspect as inspect3 } from "util";
// src/config.ts
import JSON2 from "json5";
import YAML from "yaml";
import os from "os";
import path4 from "path";
import fs from "fs/promises";
async function loadConfig({
paths,
logger,
overrides
}) {
const directories = Array.from(
/* @__PURE__ */ new Set([paths.rootDir, process.cwd(), os.homedir()])
);
const parsers = {
"": [JSON2, YAML],
".json": [JSON2],
".json5": [JSON2],
".yml": [YAML],
".yaml": [YAML]
};
const names = [".webextrc", "webext.config"];
const files = {};
directories.forEach((dir) => {
names.forEach((name) => {
Object.entries(parsers).forEach(([ext, parsers2]) => {
files[path4.resolve(dir, `${name}${ext}`)] = parsers2;
});
});
});
const layers = [{ config: overrides ?? {} }];
for (const [file, parsers2] of Object.entries(files)) {
parsersLoop:
for (const parser of parsers2) {
try {
const layer = { file, config: {} };
const text = await fs.readFile(file, "utf-8");
layer.config = parser.parse(text);
if (typeof layer.config !== "object")
throw Error("Config not an object");
layers.push(layer);
break parsersLoop;
} catch (err) {
}
}
}
return {
layers,
config: layers.map((layer) => layer.config).reduceRight((prevConfig, nextConfig) => {
return { ...prevConfig, ...nextConfig };
})
};
}
// src/extension-runner/web-ext-runner.ts
function createWebExtRunner(options) {
const { pluginOptions, paths, logger } = options;
let runner;
return {
async openBrowser() {
webExtLogger.consoleStream.write = ({ level, msg, name }) => {
if (level >= ERROR_LOG_LEVEL)
logger.error(name, msg);
if (level >= WARN_LOG_LEVEL)
logger.warn(msg);
};
const initialConfig = await loadConfig2({ pluginOptions, logger, paths });
const target = pluginOptions.browser === "chrome" ? "chromium" : pluginOptions.browser === null || pluginOptions.browser === "firefox" ? "firefox-desktop" : initialConfig.target ?? "chromium";
const sourceDir = paths.outDir;
const config = {
...initialConfig,
target,
sourceDir,
// The plugin handles reloads, so disable auto-reload behaviors in web-ext
noReload: true,
noInput: true
};
config.args ?? (config.args = []);
config.args.push("--disable-features=DisableLoadExtensionCommandLineSwitch");
logger.verbose("web-ext config:" + inspect3(config));
runner = await webExt.cmd.run(
config,
{
// Don't call `process.exit(0)` after starting web-ext
shouldExitProgram: false
}
);
},
async reload() {
await runner.reloadAllExtensions();
logger.log("");
},
async exit() {
return runner.exit();
}
};
}
var WARN_LOG_LEVEL = 40;
var ERROR_LOG_LEVEL = 50;
async function loadConfig2({
pluginOptions,
paths,
logger
}) {
const res = await loadConfig({
overrides: pluginOptions.webExtConfig,
paths,
logger
});
logger.verbose("Config result: " + inspect3(res, void 0, 3));
return res.config;
}
// src/manifest-validation.ts
import dns from "dns";
import https from "https";
import { inspect as inspect4 } from "util";
import Ajv from "ajv";
var SCHEMA_URL = new URL("https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/chrome-manifest.json");
function createManifestValidator(options) {
const { logger } = options;
let schema;
const ajv = new Ajv();
ajv.addFormat("permission", /.*/);
ajv.addFormat("content-security-policy", /.*/);
ajv.addFormat("glob-pattern", /.*/);
ajv.addFormat("match-pattern", /.*/);
ajv.addFormat("mime-type", /.*/);
function isOffline() {
const isOffline2 = new Promise((res) => {
dns.resolve(SCHEMA_URL.hostname, (err) => {
if (err == null) {
res(false);
} else {
logger.verbose("DNS not resolved");
logger.verbose(inspect4(err));
res(true);
}
});
});
return withTimeout(isOffline2, 1e3).catch(() => true);
}
async function loadSchema() {
if (schema != null)
return;
logger.verbose(`Loading JSON schema from ${SCHEMA_URL.href}...`);
schema = await get(SCHEMA_URL.href);
}
function get(url) {
return new Promise((res, rej) => {
https.get(url, (response) => {
let responseBody = "";
response.on("data", (chunk) => {
responseBody += chunk;
});
response.on("end", () => {
res(JSON.parse(responseBody));
});
}).on("error", (err) => rej(err));
});
}
return async (manifest) => {
if (schema == null && await isOffline())
return logger.warn(
"Cannot connect to json.schemastore.org, skipping validation"
);
logger.verbose(`Validating manifest...`);
if (manifest == null)
throw Error(`Manifest cannot be ${manifest}`);
await loadSchema();
logger.verbose(`Loaded JSON schema: ${inspect4(schema)}`);
const success = await ajv.validate(schema, manifest);
if (success) {
logger.verbose("Manifest is valid");
return;
}
throw Error(
`Manifest is not valid: ${JSON.stringify(ajv.errors, null, 2)}`
);
};
}
// src/csp.ts
var ContentSecurityPolicy = class _ContentSecurityPolicy {
static DIRECTIVE_ORDER = {
"default-src": 0,
"script-src": 1,
"object-src": 2
};
data;
constructor(csp) {
if (csp) {
const sections = csp.split(";").map((section) => section.trim());
this.data = sections.reduce((data, section) => {
const [key, ...values] = section.split(" ").map((item) => item.trim());
if (key)
data[key] = values;
return data;
}, {});
} else {
this.data = {};
}
}
/**
* Ensure a set of values are listed under a directive.
*/
add(directive, ...newValues) {
const values = this.data[directive] ?? [];
newValues.forEach((newValue) => {
if (!values.includes(newValue))
values.push(newValue);
});
this.data[directive] = values;
return this;
}
toString() {
const directives = Object.entries(this.data).sort(([l], [r]) => {
const lo = _ContentSecurityPolicy.DIRECTIVE_ORDER[l] ?? 2;
const ro = _ContentSecurityPolicy.DIRECTIVE_ORDER[r] ?? 2;
return lo - ro;
});
return directives.map((entry) => entry.flat().join(" ")).join("; ") + ";";
}
};
// src/build/renderManifest.ts
function renderManifest(input, bundles) {
var _a, _b, _c;
const output = JSON.parse(JSON.stringify(input));
replaceEntrypoint(bundles, output.action, "default_popup");
replaceEntrypoint(bundles, output, "devtools_page");
replaceEntrypoint(bundles, output, "options_page");
replaceEntrypoint(bundles, output.options_ui, "page");
replaceEntrypoint(bundles, output.browser_action, "default_popup");
replaceEntrypoint(bundles, output.page_action, "default_popup");
replaceEntrypoint(bundles, output.side_panel, "default_path");
replaceEntrypoint(bundles, output.sidebar_action, "default_panel");
replaceEntrypointArray(bundles, (_a = output.sandbox) == null ? void 0 : _a.pages);
replaceEntrypoint(bundles, output.background, "service_worker");
replaceEntrypoint(bundles, output.background, "page");
replaceEntrypointArray(bundles, (_b = output.background) == null ? void 0 : _b.scripts);
(_c = output.content_scripts) == null ? void 0 : _c.forEach((cs) => {
replaceEntrypointArray(bundles, cs.css);
replaceEntrypointArray(bundles, cs.js, (generated) => {
if (!generated.endsWith("css"))
return;
cs.css ?? (cs.css = []);
cs.css.push(generated);
});
});
return output;
}
function findReplacement(entry, bundles) {
const output = getOutputFile(entry);
const generatedFiles = bundles[entry];
if (generatedFiles == null)
throw Error("Render Manifest: Bundle output not found for: " + entry);
const replacementIndex = generatedFiles.indexOf(output);
if (replacementIndex < 0)
throw Error(`Entrypoint output for ${entry} (${output}) not found`);
const [replacement] = generatedFiles.slice(
replacementIndex,
replacementIndex + 1
);
return {
replacement,
generatedFiles
};
}
function replaceEntrypoint(bundles, parent, key, onGeneratedFile) {
const entry = parent == null ? void 0 : parent[key];
if (entry == null)
return;
if (entry.startsWith("public:")) {
parent[key] = entry.replace("public:", "");
} else {
const { replacement, generatedFiles } = findReplacement(entry, bundles);
parent[key] = replacement;
if (onGeneratedFile)
generatedFiles.forEach(onGeneratedFile);
}
}
function replaceEntrypointArray(bundles, parent, onGeneratedFile) {
if (parent == null)
return;
for (let i = 0; i < parent.length; i++) {
replaceEntrypoint(bundles, parent, i, onGeneratedFile);
}
}
// src/plugins/manifest-loader-plugin.ts
function manifestLoaderPlugin(options) {
const noInput = defineNoRollupInput();
const logger = createLogger(options.verbose, options.disableColors);
const ctx = createBuildContext({ logger, pluginOptions: options });
const validateManifest = createManifestValidator({ logger });
let mode = 0 /* BUILD */;
let userConfig;
let resolvedConfig;
let extensionRunner;
let paths;
let isError = false;
function configureBuildMode(config, env) {
var _a;
if (env.command === "serve") {
logger.verbose("Dev mode");
mode = 2 /* DEV */;
} else if ((_a = config.build) == null ? void 0 : _a.watch) {
logger.verbose("Watch mode");
mode = 1 /* WATCH */;
} else {
logger.verbose("Build mode");
mode = 0 /* BUILD */;
}
}
async function loadManifest() {
let manifestTemplate;
if (typeof options.manifest === "function") {
logger.verbose("Loading manifest from function");
manifestTemplate = await options.manifest();
} else {
const manifestPath = path5.resolve(paths.rootDir, options.manifest);
logger.verbose(
`Loading manifest from file @ ${manifestPath} (root: ${paths.rootDir})`
);
manifestTemplate = await fs2.readJson(manifestPath);
}
logger.verbose(
"Manifest template: " + inspect5(manifestTemplate, void 0, 5)
);
const resolvedManifest = resolveBrowserTagsInObject(
options.browser ?? "chrome",
manifestTemplate
);
logger.verbose("Manifest with entrypoints: " + inspect5(resolvedManifest));
return resolvedManifest;
}
let browserOpened = false;
async function openBrowser() {
logger.log("\nOpening browser...");
extensionRunner = createWebExtRunner({
pluginOptions: options,
paths,
logger
});
await extensionRunner.openBrowser();
browserOpened = true;
logger.log("Done!");
}
async function buildExtension({
emitFile,
server
}) {
const manifestWithInputs = await loadManifest();
await ctx.rebuild({
paths,
userConfig,
manifest: manifestWithInputs,
mode,
server,
viteMode: resolvedConfig.mode,
onSuccess: async () => {
await (extensionRunner == null ? void 0 : extensionRunner.reload());
}
});
const renderedManifest = renderManifest(
manifestWithInputs,
ctx.getBundles()
);
const finalManifest = options.transformManifest ? await options.transformManifest(renderedManifest) : renderedManifest;
if (mode === 2 /* DEV */) {
applyDevServerCsp(finalManifest);
}
if (!options.skipManifestValidation) {
await validateManifest(finalManifest);
}
emitFile({
type: "asset",
source: JSON.stringify(finalManifest),
fileName: "manifest.json",
name: "manifest.json"
});
if (options.bundleInfoJsonPath) {
emitFile({
type: "asset",
source: JSON.stringify(ctx.getBundles()),
fileName: options.bundleInfoJsonPath
});
}
await copyPublicDirToOutDir({ mode, paths });
if (mode === 2 /* DEV */ && options.onBundleReady) {
logger.verbose("Running onBundleReady");
await options.onBundleReady();
}
if (mode === 2 /* DEV */ && !options.disableAutoLaunch) {
await openBrowser();
}
}
return {
name: MANIFEST_LOADER_PLUGIN_NAME,
// Runs during: Build, dev, watch
async config(config, env) {
if (options.browser != null) {
logger.verbose(`Building for browser: ${options.browser}`);
}
configureBuildMode(config, env);
userConfig = config;
return vite4.mergeConfig(
{
server: {
// Set the server origin so assets contain the entire url in dev mode, not just the
// absolute path. See #79. This does not effect scripts or links. They are updated
// manually in the hmr-rewrite-plugin
origin: "http://localhost:5173"
},
build: {
// Since this plugin schedules multiple builds, we can't let any of the builds empty the
// outDir. Instead, the plugin cleans up the outDir manually in `onBuildStart`
emptyOutDir: false
}
},
// We only want to output the manifest.json, so we don't need an input.
noInput.config
);
},
// Runs during: Build, dev, watch
configResolved(config) {
resolvedConfig = config;
paths = {
rootDir: getRootDir(config),
outDir: getOutDir(config),
publicDir: getPublicDir(config)
};
},
configureServer(server) {
var _a;
(_a = server.httpServer) == null ? void 0 : _a.on("listening", () => {
if (mode === 2 /* DEV */) {
buildExtension({
server,
async emitFile(asset) {
await fs2.writeFile(
path5.resolve(paths.outDir, asset.fileName ?? "unknown"),
asset.source ?? "{}",
"utf8"
);
logger.log(
"\n\x1B[32m\u2713\x1B[0m Wrote \x1B[95mmanifest.json\x1B[0m"
);
}
});
}
});
},
// Runs during: Build, dev, watch
async buildStart() {
var _a;
if ((_a = userConfig.build) == null ? void 0 : _a.emptyOutDir) {
logger.verbose("Removing build.outDir...");
await fs2.rm(getOutDir(resolvedConfig), {
recursive: true,
force: true
});
}
options.watchFilePaths.forEach((file) => this.addWatchFile(file));
if (typeof options.manifest === "string") {
this.addWatchFile(path5.resolve(paths.rootDir, options.manifest));
}
if (mode !== 2 /* DEV */) {
await buildExtension({
emitFile: (asset) => void this.emitFile(asset)
});
}
},
// Runs during: build, dev, watch
resolveId(id) {
return noInput.resolveId(id);
},
// Runs during: build, dev, watch
load(id) {
return noInput.load(id);
},
// Runs during: build, watch
buildEnd(err) {
isError = err != null;
},
// Runs during: build, watch, dev (only when pressing ctrl+C to stop server)
async closeBundle() {
if (isError || mode === 0 /* BUILD */ || options.disableAutoLaunch) {
return;
}
if (mode === 2 /* DEV */)
return await extensionRunner.exit();
await openBrowser();
},
// Runs during: build, watch
generateBundle(_, bundle) {
noInput.cleanupBundle(bundle);
},
// Runs during: build, watch
async writeBundle() {
if (options.onBundleReady) {
logger.verbose("Running onBundleReady");
await options.onBundleReady();
}
},
// Runs during: watch, dev
async watchChange(id) {
if (
// Only run this hook for `vite build --watch`, not `vite dev`
mode === 2 /* DEV */ || // Don't reload if the browser isn't opened yet
!browserOpened || // Don't reload if the change was a file written to the output directory
id.startsWith(paths.outDir)
)
return;
const relativePath = path5.relative(paths.rootDir, id);
logger.log(
`
${colorizeFilename(relativePath)} changed, restarting browser`
);
await (extensionRunner == null ? void 0 : extensionRunner.exit());
}
};
}
async function copyPublicDirToOutDir({
mode,
paths
}) {
if (mode === 0 /* BUILD */ || !paths.publicDir || !await fs2.pathExists(paths.publicDir)) {
return;
}
await fs2.copy(paths.publicDir, paths.outDir);
}
async function applyDevServerCsp(manifest) {
var _a;
if (manifest.manifest_version === 3) {
manifest.host_permissions ?? (manifest.host_permissions = []);
manifest.host_permissions.push("http://localhost/*");
} else {
manifest.permissions ?? (manifest.permissions = []);
manifest.permissions.push("http://localhost/*");
}
const csp = new ContentSecurityPolicy(
manifest.manifest_version === 3 ? ((_a = manifest.content_security_policy) == null ? void 0 : _a.extension_pages) ?? "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';" : manifest.content_security_policy ?? "script-src 'self'; object-src 'self';"
// default CSP for MV2
);
csp.add("script-src", "http://localhost:*");
if (manifest.manifest_version === 3) {
manifest.content_security_policy ?? (manifest.content_security_policy = {});
manifest.content_security_policy.extension_pages = csp.toString();
} else {
manifest.content_security_policy = csp.toString();
}
}
// src/index.ts
import fs3 from "fs-extra";
function webExtension(options = {}) {
if (process.env.VITE_PLUGIN_WEB_EXTENSION_CHILD_BUILD === "true") {
return [];
}
const internalOptions = {
additionalInputs: options.additionalInputs ?? [],
disableAutoLaunch: options.disableAutoLaunch ?? false,
manifest: options.manifest ?? "manifest.json",
printSummary: options.printSummary ?? true,
skipManifestValidation: options.skipManifestValidation ?? false,
watchFilePaths: options.watchFilePaths ?? [],
browser: options.browser,
htmlViteConfig: options.htmlViteConfig,
scriptViteConfig: options.scriptViteConfig,
transformManifest: options.transformManifest,
webExtConfig: options.webExtConfig,
bundleInfoJsonPath: options.bundleInfoJsonPath,
onBundleReady: options.onBundleReady,
verbose: process.argv.includes("-d") || process.argv.includes("--debug"),
disableColors: process.env.CI === "true" || process.env.DISABLE_COLORS === "true"
// TODO: document env var
};
return manifestLoaderPlugin(internalOptions);
}
function readJsonFile(file) {
return fs3.readJsonSync(file);
}
export {
webExtension as default,
readJsonFile
};
//# sourceMappingURL=index.js.map