wxt
Version:
⚡ Next-gen Web Extension Framework
523 lines (522 loc) • 18.7 kB
JavaScript
import fs from "fs-extra";
import { resolve } from "path";
import { getEntrypointBundlePath } from "./entrypoints.mjs";
import { ContentSecurityPolicy } from "./content-security-policy.mjs";
import {
hashContentScriptOptions,
mapWxtOptionsToContentScript
} from "./content-scripts.mjs";
import { getPackageJson } from "./package.mjs";
import { normalizePath } from "./paths.mjs";
import { writeFileIfDifferent } from "./fs.mjs";
import defu from "defu";
import { wxt } from "../wxt.mjs";
export async function writeManifest(manifest, output) {
const str = wxt.config.mode === "production" ? JSON.stringify(manifest) : JSON.stringify(manifest, null, 2);
await fs.ensureDir(wxt.config.outDir);
await writeFileIfDifferent(resolve(wxt.config.outDir, "manifest.json"), str);
output.publicAssets.unshift({
type: "asset",
fileName: "manifest.json"
});
}
export 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 && // If the following limit is exceeded, Chrome will fail to load the extension.
// Error: "Too many commands specified for 'commands': The maximum is 4."
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 = // Firefox doesn't support version_name
wxt.config.browser === "firefox" || versionName === version ? void 0 : versionName;
addEntrypoints(manifest, entrypoints, 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
};
}
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 options2 = {};
if (popup.options.defaultIcon)
options2.default_icon = popup.options.defaultIcon;
if (popup.options.defaultTitle)
options2.default_title = popup.options.defaultTitle;
if (popup.options.browserStyle)
options2.browser_style = popup.options.browserStyle;
if (manifest.manifest_version === 3) {
manifest.action = {
...manifest.action,
...options2,
default_popup
};
} else {
const key = popup.options.mv2Key ?? "browser_action";
manifest[key] = {
...manifest[key],
...options2,
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 = {
open_in_tab: options.options.openInTab,
// @ts-expect-error: Not typed by @wxt-dev/browser, but supported by Firefox
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);
}
const runtimeContentScripts = contentScripts.filter(
(cs) => cs.options.registration === "runtime"
);
runtimeContentScripts.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-16.png
/^icon-([0-9]+)x[0-9]+\.png$/,
// icon-16x16.png
/^icon@([0-9]+)w\.png$/,
// icon@16w.png
/^icon@([0-9]+)h\.png$/,
// icon@16h.png
/^icon@([0-9]+)\.png$/,
// icon@16.png
/^icons?[/\\]([0-9]+)\.png$/,
// icon/16.png | icons/16.png
/^icons?[/\\]([0-9]+)x[0-9]+\.png$/
// icon/16x16.png | icons/16x16.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 permissonUrl = wxt.server?.origin;
if (permissonUrl) {
const permissionUrlInstance = new URL(permissonUrl);
permissionUrlInstance.port = "";
permissonUrl = permissionUrlInstance.toString();
}
const permission = `${permissonUrl}*`;
const allowedCsp = wxt.server?.origin ?? "http://localhost:*";
if (manifest.manifest_version === 3) {
addHostPermission(manifest, permission);
} else {
addPermission(manifest, permission);
}
const extensionPagesCsp = new ContentSecurityPolicy(
// @ts-expect-error: extension_pages exists, we convert MV2 CSPs to this earlier in the process
manifest.content_security_policy?.extension_pages ?? (manifest.manifest_version === 3 ? DEFAULT_MV3_EXTENSION_PAGES_CSP : DEFAULT_MV2_CSP)
);
const sandboxCsp = new ContentSecurityPolicy(
// @ts-expect-error: sandbox is not typed
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");
}
export 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;
return void 0;
}
export 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;
}
export 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);
}
export function stripPathFromMatchPattern(pattern) {
const protocolSepIndex = pattern.indexOf("://");
if (protocolSepIndex === -1) return pattern;
const startOfPath = pattern.indexOf("/", protocolSepIndex + 3);
return pattern.substring(0, startOfPath) + "/*";
}
export 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;
}
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.`
);
}
}
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);
}
keysToRemove.forEach((key) => {
delete manifest[key];
});
}
const mv2OnlyKeys = [
"page_action",
"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 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';";