diff --git a/packages/analytics/modules/analytics/index.ts b/packages/analytics/modules/analytics/index.ts index 07af9a307..c0eddc258 100644 --- a/packages/analytics/modules/analytics/index.ts +++ b/packages/analytics/modules/analytics/index.ts @@ -73,9 +73,8 @@ export const analytics = createAnalytics(useAppConfig().analytics); } }); - // Ensure analytics is initialized in every context, mainly the background. - // TODO: Once there's a way to filter which entrypoints a plugin is applied to, only apply this to the background - addWxtPlugin(wxt, pluginModuleId); + // Ensure analytics is initialized in the background only. + addWxtPlugin(wxt, pluginModuleId, (entry) => entry.type === 'background'); // Fix issues with dependencies addViteConfig(wxt, () => ({ diff --git a/packages/wxt/e2e/tests/modules.test.ts b/packages/wxt/e2e/tests/modules.test.ts index 56ef03849..4bc710f24 100644 --- a/packages/wxt/e2e/tests/modules.test.ts +++ b/packages/wxt/e2e/tests/modules.test.ts @@ -243,6 +243,77 @@ describe('Module Helpers', () => { await expect(project.serializeOutput()).resolves.toContain(expectedText); }); + + it('should apply plugins only to entrypoints matching their filter', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/background.ts', + 'export default defineBackground(() => {})', + ); + project.addFile( + 'entrypoints/content.ts', + ` + export default defineContentScript({ + matches: ["*://*/*"], + main: () => {}, + }) + `, + ); + project.addFile('entrypoints/popup/index.html', ''); + project.addFile('entrypoints/options/index.html', ''); + + const backgroundPluginPath = project.addFile( + 'modules/test/background-plugin.ts', + `export default defineWxtPlugin(() => console.log("BACKGROUND_PLUGIN"))`, + ); + const popupPluginPath = project.addFile( + 'modules/test/popup-plugin.ts', + `export default defineWxtPlugin(() => console.log("POPUP_PLUGIN"))`, + ); + project.addFile( + 'modules/test.ts', + ` + import { defineWxtModule, addWxtPlugin } from 'wxt/modules'; + + export default defineWxtModule((wxt) => { + addWxtPlugin(wxt, "${normalizePath(backgroundPluginPath)}", (entrypoint) => entrypoint.type === 'background'); + addWxtPlugin(wxt, "${normalizePath(popupPluginPath)}", (entrypoint) => entrypoint.name === 'popup'); + }); + `, + ); + + await project.build(); + + await expect( + project.serializeFile('.output/chrome-mv3/background.js'), + ).resolves.toContain('BACKGROUND_PLUGIN'); + await expect( + project.serializeFile('.output/chrome-mv3/content-scripts/content.js'), + ).resolves.not.toContain('BACKGROUND_PLUGIN'); + + const getInjectedScript = async (htmlFile: string) => { + const html = await readFile( + project.resolvePath('.output/chrome-mv3', htmlFile), + 'utf-8', + ); + const match = html.match(/]*\ssrc="([^"]+)"/); + if (!match) throw Error(`No script tag in ${htmlFile}`); + return readFile( + project.resolvePath( + '.output/chrome-mv3', + match[1].replace(/^\//, ''), + ), + 'utf-8', + ); + }; + + await expect(getInjectedScript('popup.html')).resolves.toContain( + 'POPUP_PLUGIN', + ); + await expect(getInjectedScript('options.html')).resolves.not.toContain( + 'POPUP_PLUGIN', + ); + }); }); describe('imports', () => { diff --git a/packages/wxt/e2e/tests/output-structure.test.ts b/packages/wxt/e2e/tests/output-structure.test.ts index 8dd06e61d..7d9b3a34b 100644 --- a/packages/wxt/e2e/tests/output-structure.test.ts +++ b/packages/wxt/e2e/tests/output-structure.test.ts @@ -381,7 +381,7 @@ describe('Output Directory Structure', () => { .toMatchInlineSnapshot(` ".output/chrome-mv3/background.js ---------------------------------------- - import { n as logHello } from "./chunks/_virtual_wxt-plugins-BdnAIYoG.js"; + import { t as logHello } from "./chunks/log-DNePel8J.js"; function defineBackground(arg) { if (arg == null || typeof arg === "function") return { main: arg }; return arg; diff --git a/packages/wxt/src/internal/builders/vite/index.ts b/packages/wxt/src/internal/builders/vite/index.ts index 2ad47772a..149884a20 100644 --- a/packages/wxt/src/internal/builders/vite/index.ts +++ b/packages/wxt/src/internal/builders/vite/index.ts @@ -198,13 +198,11 @@ export class ViteBuilder implements WxtBuilder { config.plugins ??= []; config.plugins.push( wxtPlugins.devHtmlPrerender(this.config, server), - wxtPlugins.resolveVirtualModules(this.config), wxtPlugins.devServerGlobals(this.config, server), wxtPlugins.tsconfigPaths(this.config), wxtPlugins.noopBackground(), wxtPlugins.addRuntimeEnvEntries(this.config), wxtPlugins.defineImportMeta(), - wxtPlugins.wxtPluginLoader(this.config), wxtPlugins.resolveAppConfig(this.config), ); if ( diff --git a/packages/wxt/src/internal/builders/vite/plugins/__tests__/wxt-plugin-loader.test.ts b/packages/wxt/src/internal/builders/vite/plugins/__tests__/wxt-plugin-loader.test.ts new file mode 100644 index 000000000..7ae99088b --- /dev/null +++ b/packages/wxt/src/internal/builders/vite/plugins/__tests__/wxt-plugin-loader.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { getPluginModules } from '../wxt-plugin-loader'; +import { + fakeBackgroundEntrypoint, + fakeContentScriptEntrypoint, +} from '../../../../../internal-utils/testing/fake-objects'; +import type { WxtPluginEntry } from '../../../../../types'; + +describe('Plugin Loader', () => { + describe('getPluginModules', () => { + const background = fakeBackgroundEntrypoint(); + const contentScript = fakeContentScriptEntrypoint(); + + it('includes plugins without an apply function', () => { + const plugins: WxtPluginEntry[] = [{ module: 'a' }]; + expect(getPluginModules(plugins, background)).toEqual(['a']); + }); + + it('includes only plugins whose apply function returns true', () => { + const plugins: WxtPluginEntry[] = [ + { module: 'a', apply: (entry) => entry.type === 'background' }, + { module: 'b', apply: (entry) => entry.type === 'content-script' }, + ]; + expect(getPluginModules(plugins, background)).toEqual(['a']); + expect(getPluginModules(plugins, contentScript)).toEqual(['b']); + }); + + it('includes every plugin when the entrypoint is unresolved', () => { + const plugins: WxtPluginEntry[] = [ + { module: 'a', apply: () => false }, + { module: 'b' }, + ]; + expect(getPluginModules(plugins, undefined)).toEqual(['a', 'b']); + }); + }); +}); diff --git a/packages/wxt/src/internal/builders/vite/plugins/resolve-virtual-modules.ts b/packages/wxt/src/internal/builders/vite/plugins/resolve-virtual-modules.ts index ae041257f..1c006f383 100644 --- a/packages/wxt/src/internal/builders/vite/plugins/resolve-virtual-modules.ts +++ b/packages/wxt/src/internal/builders/vite/plugins/resolve-virtual-modules.ts @@ -3,6 +3,7 @@ import { resolve } from 'path'; import type { Plugin } from 'vite'; import { ResolvedConfig } from '../../../../types'; import { normalizePath } from '../../../../internal-utils/path-utils'; +import { getEntrypointName } from '../../../../internal-utils/entrypoint-utils'; import { VirtualModuleId, virtualModuleNames } from '../../../virtual-modules'; /** @@ -37,7 +38,16 @@ export function resolveVirtualModules(config: ResolvedConfig): Plugin[] { resolve(config.wxtModuleDir, `dist/virtual/${name}.mjs`), 'utf-8', ); - return template.replace(`virtual:user-${name}`, inputPath); + const entrypointName = getEntrypointName( + config.entrypointsDir, + inputPath, + ); + return template + .replace(`virtual:user-${name}`, inputPath) + .replace( + 'virtual:wxt-plugins', + `virtual:wxt-plugins?entrypoint=${encodeURIComponent(entrypointName)}`, + ); }, }, }; diff --git a/packages/wxt/src/internal/builders/vite/plugins/wxt-plugin-loader.ts b/packages/wxt/src/internal/builders/vite/plugins/wxt-plugin-loader.ts index 3943458a1..a6a01bd44 100644 --- a/packages/wxt/src/internal/builders/vite/plugins/wxt-plugin-loader.ts +++ b/packages/wxt/src/internal/builders/vite/plugins/wxt-plugin-loader.ts @@ -1,58 +1,101 @@ import { parseHTML } from 'linkedom'; import type * as vite from 'vite'; import { normalizePath } from '../../../../internal-utils/path-utils'; -import { ResolvedConfig } from '../../../../types'; +import { getEntrypointName } from '../../../../internal-utils/entrypoint-utils'; +import { Entrypoint, ResolvedConfig, WxtPluginEntry } from '../../../../types'; + +export function getPluginModules( + plugins: Array, + entrypoint: Entrypoint | undefined, +): string[] { + return plugins + .map( + (plugin): WxtPluginEntry => + typeof plugin === 'string' ? { module: plugin } : plugin, + ) + .filter( + (plugin) => entrypoint == null || (plugin.apply?.(entrypoint) ?? true), + ) + .map((plugin) => plugin.module); +} /** * Resolve and load plugins for each entrypoint. This handles both JS * entrypoints via the `virtual:wxt-plugins` import, and HTML files by adding - * `virtual:wxt-html-plugins` to the document's `` + * `virtual:wxt-html-plugins` to the document's ``. Accepts an optional + * `?entrypoint=` query to evaluate plugin `apply` hooks per request. + * + * @param getEntrypoints Returns the most recently resolved entrypoints. + * Injected by the caller instead of read from a global, so this plugin + * doesn't depend on global `wxt` state. */ -export function wxtPluginLoader(config: ResolvedConfig): vite.Plugin { +export function wxtPluginLoader( + config: ResolvedConfig, + getEntrypoints: () => Entrypoint[], +): vite.Plugin { const virtualModuleId = 'virtual:wxt-plugins'; const resolvedVirtualModuleId = '\0' + virtualModuleId; const virtualHtmlModuleId = 'virtual:wxt-html-plugins'; const resolvedVirtualHtmlModuleId = '\0' + virtualHtmlModuleId; + function getEntrypointFromId(id: string): Entrypoint | undefined { + const queryIndex = id.indexOf('?'); + if (queryIndex === -1) return undefined; + const entrypointNameFromQueryParam = new URLSearchParams( + id.slice(queryIndex), + ).get('entrypoint'); + if (!entrypointNameFromQueryParam) return undefined; + return getEntrypoints().find( + (entry) => entry.name === entrypointNameFromQueryParam, + ); + } + return { name: 'wxt:plugin-loader', resolveId: { filter: { id: [ - new RegExp(`^${virtualModuleId}$`), - new RegExp(`^${virtualHtmlModuleId}$`), + new RegExp(`^${virtualModuleId}`), + new RegExp(`^${virtualHtmlModuleId}`), ], }, handler(id) { - if (id === virtualModuleId) { - return resolvedVirtualModuleId; - } - - return resolvedVirtualHtmlModuleId; + return '\0' + id; }, }, load: { filter: { id: [ - new RegExp(`^${resolvedVirtualModuleId}$`), - new RegExp(`^${resolvedVirtualHtmlModuleId}$`), + new RegExp(`^${resolvedVirtualModuleId}`), + new RegExp(`^${resolvedVirtualHtmlModuleId}`), ], }, handler(id) { - if (id === resolvedVirtualModuleId) { - // Import and init all plugins - const imports = config.plugins + const bareId = id.slice(1); + const entrypoint = getEntrypointFromId(bareId); + + if ( + bareId === virtualModuleId || + bareId.startsWith(`${virtualModuleId}?`) + ) { + // Import and init only the plugins that apply to this entrypoint + const modules = getPluginModules(config.plugins, entrypoint); + const imports = modules .map( - (plugin, i) => - `import initPlugin${i} from '${normalizePath(plugin)}';`, + (module, i) => + `import initPlugin${i} from '${normalizePath(module)}';`, ) .join('\n'); - const initCalls = config.plugins + const initCalls = modules .map((_, i) => ` initPlugin${i}();`) .join('\n'); return `${imports}\n\nexport function initPlugins() {\n${initCalls}\n}`; } else { - return `import { initPlugins } from '${virtualModuleId}'; + // Forward resolved entrypoint to match the JS module. + const jsModuleId = entrypoint + ? `${virtualModuleId}?entrypoint=${encodeURIComponent(entrypoint.name)}` + : virtualModuleId; + return `import { initPlugins } from '${jsModuleId}'; try { initPlugins(); } catch (err) { @@ -64,11 +107,23 @@ export function wxtPluginLoader(config: ResolvedConfig): vite.Plugin { transformIndexHtml: { // Use "pre" so the new script is added before vite bundles all the scripts order: 'pre', - handler(html, _ctx) { + handler(html, ctx) { + const entrypointName = getEntrypointName( + config.entrypointsDir, + ctx.filename, + ); + const entrypoint = getEntrypoints().find( + (entry) => entry.name === entrypointName, + ); + const query = entrypoint + ? `?entrypoint=${encodeURIComponent(entrypoint.name)}` + : ''; + const htmlModuleId = `${virtualHtmlModuleId}${query}`; + const src = config.command === 'serve' - ? `${config.dev.server?.origin}/@id/${virtualHtmlModuleId}` - : virtualHtmlModuleId; + ? `${config.dev.server?.origin}/@id/${htmlModuleId}` + : htmlModuleId; const { document } = parseHTML(html); const existing = document.querySelector(`script[src='${src}']`); diff --git a/packages/wxt/src/internal/builtin-modules/index.ts b/packages/wxt/src/internal/builtin-modules/index.ts index 4617e8925..3d5a02bb4 100644 --- a/packages/wxt/src/internal/builtin-modules/index.ts +++ b/packages/wxt/src/internal/builtin-modules/index.ts @@ -2,9 +2,11 @@ import { WxtModule } from '../../types'; import faviconPermission from './favicon-permission'; import unimport from './unimport'; import escapeUnicode from './escape-unicode'; +import plugins from './plugins'; export const builtinModules: WxtModule[] = [ unimport, faviconPermission, escapeUnicode, + plugins, ]; diff --git a/packages/wxt/src/internal/builtin-modules/plugins.ts b/packages/wxt/src/internal/builtin-modules/plugins.ts new file mode 100644 index 000000000..15a552463 --- /dev/null +++ b/packages/wxt/src/internal/builtin-modules/plugins.ts @@ -0,0 +1,26 @@ +import { addViteConfig, defineWxtModule } from '../../modules'; +import type { Entrypoint } from '../../types'; +import { resolveVirtualModules } from '../builders/vite/plugins/resolve-virtual-modules'; +import { wxtPluginLoader } from '../builders/vite/plugins/wxt-plugin-loader'; + +/** + * Owns the virtual `wxt-plugins`/`wxt-html-plugins` modules used by + * `addWxtPlugin`. Keeps the latest resolved entrypoints in module-local state + * instead of on the global `wxt` object. + */ +export default defineWxtModule({ + name: 'wxt:built-in:plugins', + setup(wxt) { + let entrypoints: Entrypoint[] = []; + wxt.hooks.hook('entrypoints:resolved', (_, resolved) => { + entrypoints = resolved; + }); + + addViteConfig(wxt, () => ({ + plugins: [ + resolveVirtualModules(wxt.config), + wxtPluginLoader(wxt.config, () => entrypoints), + ], + })); + }, +}); diff --git a/packages/wxt/src/modules/index.ts b/packages/wxt/src/modules/index.ts index 14b4e7116..6a739a121 100644 --- a/packages/wxt/src/modules/index.ts +++ b/packages/wxt/src/modules/index.ts @@ -129,16 +129,26 @@ export function addViteConfig( * * @example * export default defineWxtModule((wxt) => { - * addWxtPlugin(wxt, 'wxt-module-analytics/client-plugin'); + * addWxtPlugin( + * wxt, + * 'wxt-module-analytics/background-plugin', + * (entrypoint) => entrypoint.type === 'background', + * ); * }); * * @param wxt The wxt instance provided by the module's setup function. * @param plugin An import from an NPM module, or an absolute file path to the * file to load at runtime. + * @param apply Optional. Return `true` to load the plugin in the given + * entrypoint. Defaults to loading the plugin in every entrypoint. */ -export function addWxtPlugin(wxt: Wxt, plugin: string): void { +export function addWxtPlugin( + wxt: Wxt, + plugin: string, + apply?: (entrypoint: Entrypoint) => boolean, +): void { wxt.hooks.hook('config:resolved', (wxt) => { - wxt.config.plugins.push(plugin); + wxt.config.plugins.push({ module: plugin, apply }); }); } diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 1e2b9b477..b4e389f87 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -1681,13 +1681,28 @@ export interface ResolvedConfig { builtinModules: WxtModule[]; userModules: WxtModuleWithMetadata[]; /** - * An array of string to import plugins from. These paths should be resolvable - * by vite, and they should `export default defineWxtPlugin(...)`. + * Plugins registered via `addWxtPlugin`. Each entry's `module` should be + * resolvable by vite, and should `export default defineWxtPlugin(...)`. An + * optional `apply` function restricts which entrypoints the plugin loads in. * * @example - * ['@wxt-dev/module-vue/plugin', 'wxt-module-google-analytics/plugin']; + * [{ module: '@wxt-dev/module-vue/plugin' }]; */ - plugins: string[]; + plugins: Array; +} + +/** + * A plugin registered via `addWxtPlugin`, along with an optional `apply` + * function controlling which entrypoints it's loaded in. + */ +export interface WxtPluginEntry { + /** Import path or absolute file path to the plugin module. */ + module: string; + /** + * Return `true` to include the plugin in this entrypoint. When omitted, the + * plugin is included in every entrypoint. + */ + apply?: (entrypoint: Entrypoint) => boolean; } export interface FsCache {