Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/analytics/modules/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, () => ({
Expand Down
71 changes: 71 additions & 0 deletions packages/wxt/e2e/tests/modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<html></html>');
project.addFile('entrypoints/options/index.html', '<html></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(/<script[^>]*\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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/wxt/e2e/tests/output-structure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 0 additions & 2 deletions packages/wxt/src/internal/builders/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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']);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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)}`,
);
},
},
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string | WxtPluginEntry>,
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 `<head>`
* `virtual:wxt-html-plugins` to the document's `<head>`. Accepts an optional
* `?entrypoint=<name>` 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) {
Expand All @@ -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}']`);
Expand Down
2 changes: 2 additions & 0 deletions packages/wxt/src/internal/builtin-modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>[] = [
unimport,
faviconPermission,
escapeUnicode,
plugins,
];
26 changes: 26 additions & 0 deletions packages/wxt/src/internal/builtin-modules/plugins.ts
Original file line number Diff line number Diff line change
@@ -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),
],
}));
},
});
16 changes: 13 additions & 3 deletions packages/wxt/src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
}

Expand Down
Loading
Loading