wxt
Version:
⚡ Next-gen Web Extension Framework
403 lines (402 loc) • 19.4 kB
JavaScript
import { normalizePath } from "./paths.mjs";
import { getEntrypointBundlePath } from "./entrypoints.mjs";
import { wxt } from "../wxt.mjs";
import { writeFileIfDifferent } from "./fs.mjs";
import { ContentSecurityPolicy } from "./content-security-policy.mjs";
import { hashContentScriptOptions, mapWxtOptionsToContentScript } from "./content-scripts.mjs";
import { getPackageJson } from "./package.mjs";
import { addDiscoveredThemeIcons } from "./theme-icons.mjs";
import { mkdir } from "node:fs/promises";
import { resolve } from "path";
import defu from "defu";
//#region src/core/utils/manifest.ts
/** Writes the manifest to the output directory and the build output. */
async function writeManifest(manifest, output) {
const str = wxt.config.mode === "production" ? JSON.stringify(manifest) : JSON.stringify(manifest, null, 2);
await mkdir(wxt.config.outDir, { recursive: true });
await writeFileIfDifferent(resolve(wxt.config.outDir, "manifest.json"), str);
output.publicAssets.unshift({
type: "asset",
fileName: "manifest.json"
});
}
/** Generates the manifest based on the config and entrypoints. */
async function generateManifest(allEntrypoints, buildOutput) {
const entrypoints = allEntrypoints.filter((entry) => !entry.skipped);
const warnings = [];
const pkg = await getPackageJson();
let versionName = wxt.config.manifest.version_name ?? wxt.config.manifest.version ?? pkg?.version;
if (versionName == null) {
versionName = "0.0.0";
wxt.logger.warn("Extension version not found, defaulting to \"0.0.0\". Add a version to your `package.json` or `wxt.config.ts` file. For more details, see: https://wxt.dev/guide/key-concepts/manifest.html#version-and-version-name");
}
const version = wxt.config.manifest.version ?? simplifyVersion(versionName);
const baseManifest = {
manifest_version: wxt.config.manifestVersion,
name: pkg?.name,
description: pkg?.description,
version,
short_name: pkg?.shortName,
icons: discoverIcons(buildOutput)
};
const userManifest = wxt.config.manifest;
if (userManifest.manifest_version) {
delete userManifest.manifest_version;
wxt.logger.warn("`manifest.manifest_version` config was set, but ignored. To change the target manifest version, use the `manifestVersion` option or the `--mv2`/`--mv3` CLI flags.\nSee https://wxt.dev/guide/essentials/target-different-browsers.html#target-a-manifest-version");
}
let manifest = defu(userManifest, baseManifest);
if (wxt.config.command === "serve" && wxt.config.dev.reloadCommand) if (manifest.commands && Object.values(manifest.commands).filter((command) => command.suggested_key).length >= 4) warnings.push(["Extension already has 4 registered commands with suggested keys, WXT's reload command is disabled"]);
else {
manifest.commands ??= {};
manifest.commands["wxt:reload-extension"] = {
description: "Reload the extension during development",
suggested_key: { default: wxt.config.dev.reloadCommand }
};
}
manifest.version = version;
manifest.version_name = wxt.config.browser === "firefox" || versionName === version ? void 0 : versionName;
if (wxt.config.browser === "firefox" && !userManifest.browser_specific_settings?.gecko?.data_collection_permissions && !wxt.config.suppressWarnings?.firefoxDataCollection) wxt.logger.warn("Firefox requires `data_collection_permissions` for new extensions from November 3, 2025. Existing extensions are exempt for now.\nFor more details, see: https://extensionworkshop.com/documentation/develop/firefox-builtin-data-consent/\nTo suppress this warning, set `suppressWarnings.firefoxDataCollection` to `true` in your wxt config.\n");
if (wxt.config.browser === "firefox" && !manifest.browser_specific_settings?.gecko?.id && !wxt.config.suppressWarnings?.firefoxId) wxt.logger.warn("Firefox requires extension ID for MV3 and recommends it for MV2.\nFor more details, see: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings#id");
addEntrypoints(manifest, entrypoints, buildOutput);
if (wxt.config.browser === "firefox") addDiscoveredThemeIcons(manifest, buildOutput);
if (wxt.config.command === "serve") addDevModeCsp(manifest);
if (wxt.config.command === "serve") addDevModePermissions(manifest);
await wxt.hooks.callHook("build:manifestGenerated", wxt, manifest);
if (wxt.config.manifestVersion === 2) {
convertWebAccessibleResourcesToMv2(manifest);
convertActionToMv2(manifest);
convertCspToMv2(manifest);
moveHostPermissionsToPermissions(manifest);
}
if (wxt.config.manifestVersion === 3) validateMv3WebAccessibleResources(manifest);
stripKeys(manifest);
if (manifest.name == null) throw Error("Manifest 'name' is missing. Either:\n1. Set the name in your <rootDir>/package.json\n2. Set a name via the manifest option in your wxt.config.ts");
if (manifest.version == null) throw Error("Manifest 'version' is missing. Either:\n1. Add a version in your <rootDir>/package.json\n2. Pass the version via the manifest option in your wxt.config.ts");
return {
manifest,
warnings
};
}
/**
* Removes suffixes from the version, like X.Y.Z-alpha1 (which browsers don't
* allow), so it's a simple version number, like X or X.Y or X.Y.Z, which
* browsers allow.
*/
function simplifyVersion(versionName) {
const version = /^((0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}).*$/.exec(versionName)?.[1];
if (version == null) throw Error(`Cannot simplify package.json version "${versionName}" to a valid extension version, "X.Y.Z"`);
return version;
}
function addEntrypoints(manifest, entrypoints, buildOutput) {
const entriesByType = entrypoints.reduce((map, entrypoint) => {
map[entrypoint.type] ??= [];
map[entrypoint.type]?.push(entrypoint);
return map;
}, {});
const background = entriesByType["background"]?.[0];
const bookmarks = entriesByType["bookmarks"]?.[0];
const contentScripts = entriesByType["content-script"];
const devtools = entriesByType["devtools"]?.[0];
const history = entriesByType["history"]?.[0];
const newtab = entriesByType["newtab"]?.[0];
const options = entriesByType["options"]?.[0];
const popup = entriesByType["popup"]?.[0];
const sandboxes = entriesByType["sandbox"];
const sidepanels = entriesByType["sidepanel"];
if (background) {
const script = getEntrypointBundlePath(background, wxt.config.outDir, ".js");
if (wxt.config.browser === "firefox" && wxt.config.manifestVersion === 3) manifest.background = {
type: background.options.type,
scripts: [script]
};
else if (wxt.config.manifestVersion === 3) manifest.background = {
type: background.options.type,
service_worker: script
};
else manifest.background = {
persistent: background.options.persistent,
scripts: [script]
};
}
if (bookmarks) if (wxt.config.browser === "firefox") wxt.logger.warn("Bookmarks are not supported by Firefox. chrome_url_overrides.bookmarks was not added to the manifest");
else {
manifest.chrome_url_overrides ??= {};
manifest.chrome_url_overrides.bookmarks = getEntrypointBundlePath(bookmarks, wxt.config.outDir, ".html");
}
if (history) if (wxt.config.browser === "firefox") wxt.logger.warn("Bookmarks are not supported by Firefox. chrome_url_overrides.history was not added to the manifest");
else {
manifest.chrome_url_overrides ??= {};
manifest.chrome_url_overrides.history = getEntrypointBundlePath(history, wxt.config.outDir, ".html");
}
if (newtab) {
manifest.chrome_url_overrides ??= {};
manifest.chrome_url_overrides.newtab = getEntrypointBundlePath(newtab, wxt.config.outDir, ".html");
}
if (popup) {
const default_popup = getEntrypointBundlePath(popup, wxt.config.outDir, ".html");
const options = {};
if (popup.options.defaultIcon) options.default_icon = popup.options.defaultIcon;
if (popup.options.defaultTitle) options.default_title = popup.options.defaultTitle;
if (popup.options.defaultState && wxt.config.manifestVersion === 3) options.default_state = popup.options.defaultState;
if (popup.options.browserStyle) options.browser_style = popup.options.browserStyle;
if (popup.options.defaultArea) options.default_area = popup.options.defaultArea;
if (popup.options.themeIcons) options.theme_icons = popup.options.themeIcons;
const actionKey = wxt.config.manifestVersion === 2 ? popup.options.actionType ?? "browser_action" : wxt.config.browser === "firefox" && popup.options.actionType === "page_action" ? "page_action" : "action";
manifest[actionKey] = {
...manifest[actionKey],
...options,
default_popup
};
}
if (devtools) manifest.devtools_page = getEntrypointBundlePath(devtools, wxt.config.outDir, ".html");
if (options) {
const page = getEntrypointBundlePath(options, wxt.config.outDir, ".html");
manifest.options_ui = {
...wxt.config.browser !== "safari" && { open_in_tab: options.options.openInTab ?? false },
browser_style: wxt.config.browser === "firefox" ? options.options.browserStyle : void 0,
chrome_style: wxt.config.browser !== "firefox" ? options.options.chromeStyle : void 0,
page
};
}
if (sandboxes?.length) if (wxt.config.browser === "firefox") wxt.logger.warn("Sandboxed pages not supported by Firefox. sandbox.pages was not added to the manifest");
else manifest.sandbox = { pages: sandboxes.map((entry) => getEntrypointBundlePath(entry, wxt.config.outDir, ".html")) };
if (sidepanels?.length) {
const defaultSidepanel = sidepanels.find((entry) => entry.name === "sidepanel") ?? sidepanels[0];
const page = getEntrypointBundlePath(defaultSidepanel, wxt.config.outDir, ".html");
if (wxt.config.browser === "firefox") manifest.sidebar_action = {
default_panel: page,
browser_style: defaultSidepanel.options.browserStyle,
default_icon: defaultSidepanel.options.defaultIcon,
default_title: defaultSidepanel.options.defaultTitle,
open_at_install: defaultSidepanel.options.openAtInstall
};
else if (wxt.config.manifestVersion === 3) {
manifest.side_panel = { default_path: page };
addPermission(manifest, "sidePanel");
} else wxt.logger.warn("Side panel not supported by Chromium using MV2. side_panel.default_path was not added to the manifest");
}
if (contentScripts?.length) {
const cssMap = getContentScriptsCssMap(buildOutput, contentScripts);
if (wxt.config.command === "serve" && wxt.config.manifestVersion === 3) contentScripts.forEach((script) => {
script.options.matches?.forEach((matchPattern) => {
addHostPermission(manifest, matchPattern);
});
});
else {
const hashToEntrypointsMap = contentScripts.filter((cs) => cs.options.registration !== "runtime").reduce((map, script) => {
const hash = hashContentScriptOptions(script.options);
if (map.has(hash)) map.get(hash)?.push(script);
else map.set(hash, [script]);
return map;
}, /* @__PURE__ */ new Map());
const manifestContentScripts = Array.from(hashToEntrypointsMap.values()).map((scripts) => mapWxtOptionsToContentScript(scripts[0].options, scripts.map((entry) => getEntrypointBundlePath(entry, wxt.config.outDir, ".js")), getContentScriptCssFiles(scripts, cssMap)));
if (manifestContentScripts.length >= 0) {
manifest.content_scripts ??= [];
manifest.content_scripts.push(...manifestContentScripts);
}
contentScripts.filter((cs) => cs.options.registration === "runtime").forEach((script) => {
script.options.matches?.forEach((matchPattern) => {
addHostPermission(manifest, matchPattern);
});
});
}
const contentScriptCssResources = getContentScriptCssWebAccessibleResources(contentScripts, cssMap);
if (contentScriptCssResources.length > 0) {
manifest.web_accessible_resources ??= [];
manifest.web_accessible_resources.push(...contentScriptCssResources);
}
}
}
function discoverIcons(buildOutput) {
const icons = [];
const iconRegex = [
/^icon-([0-9]+)\.png$/,
/^icon-([0-9]+)x[0-9]+\.png$/,
/^icon@([0-9]+)w\.png$/,
/^icon@([0-9]+)h\.png$/,
/^icon@([0-9]+)\.png$/,
/^icons?[/\\]([0-9]+)\.png$/,
/^icons?[/\\]([0-9]+)x[0-9]+\.png$/
];
buildOutput.publicAssets.forEach((asset) => {
let size;
for (const regex of iconRegex) {
const match = asset.fileName.match(regex);
if (match?.[1] != null) {
size = match[1];
break;
}
}
if (size == null) return;
icons.push([size, normalizePath(asset.fileName)]);
});
return icons.length > 0 ? Object.fromEntries(icons) : void 0;
}
function addDevModeCsp(manifest) {
let permissionUrl = wxt.server?.origin;
if (permissionUrl) {
const permissionUrlInstance = new URL(permissionUrl);
permissionUrlInstance.port = "";
permissionUrl = permissionUrlInstance.toString();
}
const permission = `${permissionUrl}*`;
const allowedCsp = wxt.server?.origin ?? "http://localhost:*";
if (wxt.config.manifestVersion === 3) addHostPermission(manifest, permission);
else addPermission(manifest, permission);
const extensionPagesCsp = new ContentSecurityPolicy(manifest.content_security_policy?.extension_pages ?? (wxt.config.manifestVersion === 3 ? DEFAULT_MV3_EXTENSION_PAGES_CSP : DEFAULT_MV2_CSP));
const sandboxCsp = new ContentSecurityPolicy(manifest.content_security_policy?.sandbox ?? DEFAULT_MV3_SANDBOX_CSP);
if (wxt.config.command === "serve") {
extensionPagesCsp.add("script-src", allowedCsp);
sandboxCsp.add("script-src", allowedCsp);
}
manifest.content_security_policy ??= {};
manifest.content_security_policy.extension_pages = extensionPagesCsp.toString();
manifest.content_security_policy.sandbox = sandboxCsp.toString();
}
function addDevModePermissions(manifest) {
addPermission(manifest, "tabs");
if (wxt.config.manifestVersion === 3) addPermission(manifest, "scripting");
}
/**
* Returns the bundle paths to CSS files associated with a list of content
* scripts, or undefined if there is no associated CSS.
*/
function getContentScriptCssFiles(contentScripts, contentScriptCssMap) {
const css = [];
contentScripts.forEach((script) => {
if (script.options.cssInjectionMode === "manual" || script.options.cssInjectionMode === "ui") return;
const cssFile = contentScriptCssMap[script.name];
if (cssFile == null) return;
if (cssFile) css.push(cssFile);
});
if (css.length > 0) return css;
}
/**
* Content scripts configured with `cssInjectionMode: "ui"` need to add their
* CSS files to web accessible resources so they can be fetched as text and
* added to shadow roots that the UI is added to.
*/
function getContentScriptCssWebAccessibleResources(contentScripts, contentScriptCssMap) {
const resources = [];
contentScripts.forEach((script) => {
if (script.options.cssInjectionMode !== "ui") return;
const cssFile = contentScriptCssMap[script.name];
if (cssFile == null) return;
resources.push({
resources: [cssFile],
use_dynamic_url: true,
matches: script.options.matches?.map((matchPattern) => stripPathFromMatchPattern(matchPattern)) ?? []
});
});
return resources;
}
/**
* Based on the build output, return a Record of each content script's name to
* it CSS file if the script includes one.
*/
function getContentScriptsCssMap(buildOutput, scripts) {
const map = {};
const allChunks = buildOutput.steps.flatMap((step) => step.chunks);
scripts.forEach((script) => {
const relatedCss = allChunks.find((chunk) => chunk.fileName === `content-scripts/${script.name}.css`);
if (relatedCss != null) map[script.name] = relatedCss.fileName;
});
return map;
}
function addPermission(manifest, permission) {
manifest.permissions ??= [];
if (manifest.permissions.includes(permission)) return;
manifest.permissions.push(permission);
}
function addHostPermission(manifest, hostPermission) {
manifest.host_permissions ??= [];
if (manifest.host_permissions.includes(hostPermission)) return;
manifest.host_permissions.push(hostPermission);
}
/**
* - "<all_urls>" → "<all_urls>"
* - "_://play.google.com/books/_" → "_://play.google.com/_"
*/
function stripPathFromMatchPattern(pattern) {
const protocolSepIndex = pattern.indexOf("://");
if (protocolSepIndex === -1) return pattern;
const startOfPath = pattern.indexOf("/", protocolSepIndex + 3);
return pattern.substring(0, startOfPath) + "/*";
}
/**
* Converts all MV3 web accessible resources to their MV2 forms. MV3 web
* accessible resources are generated in this file, and may be defined by the
* user in their manifest. In both cases, when targeting MV2, automatically
* convert their definitions down to the basic MV2 array.
*/
function convertWebAccessibleResourcesToMv2(manifest) {
if (manifest.web_accessible_resources == null) return;
manifest.web_accessible_resources = Array.from(new Set(manifest.web_accessible_resources.flatMap((item) => {
if (typeof item === "string") return item;
return item.resources;
})));
}
function moveHostPermissionsToPermissions(manifest) {
if (!manifest.host_permissions?.length) return;
manifest.host_permissions.forEach((permission) => addPermission(manifest, permission));
delete manifest.host_permissions;
}
function convertActionToMv2(manifest) {
if (manifest.action == null || manifest.browser_action != null || manifest.page_action != null) return;
manifest.browser_action = manifest.action;
}
function convertCspToMv2(manifest) {
if (typeof manifest.content_security_policy === "string" || manifest.content_security_policy?.extension_pages == null) return;
manifest.content_security_policy = manifest.content_security_policy.extension_pages;
}
/** Make sure all resources are in MV3 format. If not, add a warning. */
function validateMv3WebAccessibleResources(manifest) {
if (manifest.web_accessible_resources == null) return;
const stringResources = manifest.web_accessible_resources.filter((item) => typeof item === "string");
if (stringResources.length > 0) throw Error(`Non-MV3 web_accessible_resources detected: ${JSON.stringify(stringResources)}. When manually defining web_accessible_resources, define them as MV3 objects ({ matches: [...], resources: [...] }), and WXT will automatically convert them to MV2 when necessary.`);
}
/** Remove keys from the manifest based on the build target. */
function stripKeys(manifest) {
let keysToRemove = [];
if (wxt.config.manifestVersion === 2) {
keysToRemove.push(...mv3OnlyKeys);
if (wxt.config.browser === "firefox") keysToRemove.push(...firefoxMv3OnlyKeys);
} else {
keysToRemove.push(...mv2OnlyKeys);
if (wxt.config.browser !== "firefox") keysToRemove.push(...chromeMv2OnlyKeys);
}
keysToRemove.forEach((key) => {
delete manifest[key];
});
}
const mv2OnlyKeys = [
"browser_action",
"automation",
"content_capabilities",
"converted_from_user_script",
"current_locale",
"differential_fingerprint",
"event_rules",
"file_browser_handlers",
"file_system_provider_capabilities",
"nacl_modules",
"natively_connectable",
"offline_enabled",
"platforms",
"replacement_web_app",
"system_indicator",
"user_scripts"
];
const mv3OnlyKeys = [
"action",
"export",
"optional_host_permissions",
"side_panel"
];
const chromeMv2OnlyKeys = ["page_action"];
const firefoxMv3OnlyKeys = ["host_permissions"];
const DEFAULT_MV3_EXTENSION_PAGES_CSP = "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';";
const DEFAULT_MV3_SANDBOX_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; child-src 'self';";
const DEFAULT_MV2_CSP = "script-src 'self'; object-src 'self';";
//#endregion
export { generateManifest, getContentScriptCssFiles, getContentScriptsCssMap, writeManifest };