diff --git a/demo/src/entrypoints/background.ts b/demo/src/entrypoints/background.ts
index 264a5b58d..5f18ccb3e 100644
--- a/demo/src/entrypoints/background.ts
+++ b/demo/src/entrypoints/background.ts
@@ -1,37 +1,41 @@
import messages from 'public/_locales/en/messages.json';
-export default defineBackground(() => {
- console.log(browser.runtime.id);
- logId();
- console.log({
- browser: import.meta.env.BROWSER,
- chrome: import.meta.env.CHROME,
- firefox: import.meta.env.FIREFOX,
- manifestVersion: import.meta.env.MANIFEST_VERSION,
- messages,
- });
+export default defineBackground({
+ // type: 'module',
- // @ts-expect-error: should only accept entrypoints or public assets
- browser.runtime.getURL('/');
- browser.runtime.getURL('/background.js');
- browser.runtime.getURL('/icon/128.png');
- browser.runtime.getURL('/example.html#hash');
- browser.runtime.getURL('/example.html?query=param');
- // @ts-expect-error: should only allow hashes/query params on HTML files
- browser.runtime.getURL('/icon-128.png?query=param');
+ main() {
+ console.log(browser.runtime.id);
+ logId();
+ console.log({
+ browser: import.meta.env.BROWSER,
+ chrome: import.meta.env.CHROME,
+ firefox: import.meta.env.FIREFOX,
+ manifestVersion: import.meta.env.MANIFEST_VERSION,
+ messages,
+ });
- // @ts-expect-error: should only accept known message names
- browser.i18n.getMessage('test');
- browser.i18n.getMessage('prompt_for_name');
- browser.i18n.getMessage('hello', 'Aaron');
- browser.i18n.getMessage('bye', ['Aaron']);
- browser.i18n.getMessage('@@extension_id');
+ // @ts-expect-error: should only accept entrypoints or public assets
+ browser.runtime.getURL('/');
+ browser.runtime.getURL('/background.js');
+ browser.runtime.getURL('/icon/128.png');
+ browser.runtime.getURL('/example.html#hash');
+ browser.runtime.getURL('/example.html?query=param');
+ // @ts-expect-error: should only allow hashes/query params on HTML files
+ browser.runtime.getURL('/icon-128.png?query=param');
- console.log('WXT MODE:', {
- MODE: import.meta.env.MODE,
- DEV: import.meta.env.DEV,
- PROD: import.meta.env.PROD,
- });
+ // @ts-expect-error: should only accept known message names
+ browser.i18n.getMessage('test');
+ browser.i18n.getMessage('prompt_for_name');
+ browser.i18n.getMessage('hello', 'Aaron');
+ browser.i18n.getMessage('bye', ['Aaron']);
+ browser.i18n.getMessage('@@extension_id');
- storage.setItem('session:startTime', Date.now());
+ console.log('WXT MODE:', {
+ MODE: import.meta.env.MODE,
+ DEV: import.meta.env.DEV,
+ PROD: import.meta.env.PROD,
+ });
+
+ storage.setItem('session:startTime', Date.now());
+ },
});
diff --git a/e2e/tests/output-structure.test.ts b/e2e/tests/output-structure.test.ts
index e00bd2845..0f307eee4 100644
--- a/e2e/tests/output-structure.test.ts
+++ b/e2e/tests/output-structure.test.ts
@@ -227,4 +227,169 @@ describe('Output Directory Structure', () => {
true,
);
});
+
+ it('should generate ESM background script when type=module', async () => {
+ const project = new TestProject();
+ project.addFile(
+ 'utils/log.ts',
+ `export function logHello(name: string) {
+ console.log(\`Hello \${name}!\`);
+ }`,
+ );
+ project.addFile(
+ 'entrypoints/background.ts',
+ `export default defineBackground({
+ type: "module",
+ main() {
+ logHello("background");
+ },
+ })`,
+ );
+ project.addFile(
+ 'entrypoints/popup/index.html',
+ `
+
+
+
+ `,
+ );
+ project.addFile('entrypoints/popup/main.ts', `logHello('popup')`);
+
+ await project.build({
+ experimental: {
+ // Simplify the build output for comparison
+ includeBrowserPolyfill: false,
+ },
+ vite: () => ({
+ build: {
+ // Make output for snapshot readible
+ minify: false,
+ },
+ }),
+ });
+
+ expect(await project.serializeFile('.output/chrome-mv3/background.js'))
+ .toMatchInlineSnapshot(`
+ ".output/chrome-mv3/background.js
+ ----------------------------------------
+ import { l as logHello } from "./chunks/log-bezs0tt4.js";
+ function defineBackground(arg) {
+ if (typeof arg === "function")
+ return { main: arg };
+ return arg;
+ }
+ const definition = defineBackground({
+ type: "module",
+ main() {
+ logHello("background");
+ }
+ });
+ chrome;
+ function print(method, ...args) {
+ return;
+ }
+ var logger = {
+ debug: (...args) => print(console.debug, ...args),
+ log: (...args) => print(console.log, ...args),
+ warn: (...args) => print(console.warn, ...args),
+ error: (...args) => print(console.error, ...args)
+ };
+ try {
+ const res = definition.main();
+ if (res instanceof Promise) {
+ console.warn(
+ "The background's main() function return a promise, but it must be synchonous"
+ );
+ }
+ } catch (err) {
+ logger.error("The background crashed on startup!");
+ throw err;
+ }
+ "
+ `);
+ });
+
+ it('should generate IIFE background script when type=undefined', async () => {
+ const project = new TestProject();
+ project.addFile(
+ 'utils/log.ts',
+ `export function logHello(name: string) {
+ console.log(\`Hello \${name}!\`);
+ }`,
+ );
+ project.addFile(
+ 'entrypoints/background.ts',
+ `export default defineBackground({
+ main() {
+ logHello("background");
+ },
+ })`,
+ );
+ project.addFile(
+ 'entrypoints/popup/index.html',
+ `
+
+
+
+ `,
+ );
+ project.addFile('entrypoints/popup/main.ts', `logHello('popup')`);
+
+ await project.build({
+ experimental: {
+ // Simplify the build output for comparison
+ includeBrowserPolyfill: false,
+ },
+ vite: () => ({
+ build: {
+ // Make output for snapshot readible
+ minify: false,
+ },
+ }),
+ });
+
+ expect(await project.serializeFile('.output/chrome-mv3/background.js'))
+ .toMatchInlineSnapshot(`
+ ".output/chrome-mv3/background.js
+ ----------------------------------------
+ (function() {
+ "use strict";
+ function defineBackground(arg) {
+ if (typeof arg === "function")
+ return { main: arg };
+ return arg;
+ }
+ function logHello(name) {
+ console.log(\`Hello \${name}!\`);
+ }
+ const definition = defineBackground({
+ main() {
+ logHello("background");
+ }
+ });
+ chrome;
+ function print(method, ...args) {
+ return;
+ }
+ var logger = {
+ debug: (...args) => print(console.debug, ...args),
+ log: (...args) => print(console.log, ...args),
+ warn: (...args) => print(console.warn, ...args),
+ error: (...args) => print(console.error, ...args)
+ };
+ try {
+ const res = definition.main();
+ if (res instanceof Promise) {
+ console.warn(
+ "The background's main() function return a promise, but it must be synchonous"
+ );
+ }
+ } catch (err) {
+ logger.error("The background crashed on startup!");
+ throw err;
+ }
+ })();
+ "
+ `);
+ });
});
diff --git a/src/core/builders/vite/index.ts b/src/core/builders/vite/index.ts
index caeb5c9cd..79c197df2 100644
--- a/src/core/builders/vite/index.ts
+++ b/src/core/builders/vite/index.ts
@@ -75,23 +75,7 @@ export async function createViteBuilder(
* Return the basic config for building an entrypoint in [lib mode](https://vitejs.dev/guide/build.html#library-mode).
*/
const getLibModeConfig = (entrypoint: Entrypoint): vite.InlineConfig => {
- let virtualEntrypointType: VirtualEntrypointType | undefined;
- switch (entrypoint.type) {
- case 'background':
- case 'unlisted-script':
- virtualEntrypointType = entrypoint.type;
- break;
- case 'content-script':
- virtualEntrypointType =
- entrypoint.options.world === 'MAIN'
- ? 'content-script-main-world'
- : 'content-script-isolated-world';
- break;
- }
- const entry = virtualEntrypointType
- ? `virtual:wxt-${virtualEntrypointType}?${entrypoint.inputPath}`
- : entrypoint.inputPath;
-
+ const entry = getRollupEntry(entrypoint);
const plugins: NonNullable = [
wxtPlugins.entrypointGroupGlobals(entrypoint),
];
@@ -157,14 +141,16 @@ export async function createViteBuilder(
build: {
rollupOptions: {
input: entrypoints.reduce>((input, entry) => {
- input[entry.name] = entry.inputPath;
+ input[entry.name] = getRollupEntry(entry);
return input;
}, {}),
output: {
// Include a hash to prevent conflicts
chunkFileNames: 'chunks/[name]-[hash].js',
- // Include a hash to prevent conflicts
- entryFileNames: 'chunks/[name]-[hash].js',
+ // Place JS entrypoints in main directory without a hash. (popup.html & popup.js are
+ // next to each other). The unique entrypoint name requirement prevents conflicts with
+ // scripts of the same name
+ entryFileNames: '[name].js',
// We can't control the "name", so we need a hash to prevent conflicts
assetFileNames: 'assets/[name]-[hash].[ext]',
},
@@ -263,3 +249,26 @@ function getBuildOutputChunks(
if (Array.isArray(result)) return result.flatMap(({ output }) => output);
return result.output;
}
+
+/**
+ * Returns the input module ID (virtual or real file) for an entrypoint. The returned string should
+ * be passed as an input to rollup.
+ */
+function getRollupEntry(entrypoint: Entrypoint): string {
+ let virtualEntrypointType: VirtualEntrypointType | undefined;
+ switch (entrypoint.type) {
+ case 'background':
+ case 'unlisted-script':
+ virtualEntrypointType = entrypoint.type;
+ break;
+ case 'content-script':
+ virtualEntrypointType =
+ entrypoint.options.world === 'MAIN'
+ ? 'content-script-main-world'
+ : 'content-script-isolated-world';
+ break;
+ }
+ return virtualEntrypointType
+ ? `virtual:wxt-${virtualEntrypointType}?${entrypoint.inputPath}`
+ : entrypoint.inputPath;
+}
diff --git a/src/core/create-server.ts b/src/core/create-server.ts
index b3abb7727..f5e9a2bfa 100644
--- a/src/core/create-server.ts
+++ b/src/core/create-server.ts
@@ -8,7 +8,6 @@ import {
} from '~/types';
import {
getEntrypointBundlePath,
- getEntrypointOutputFile,
resolvePerBrowserOption,
} from '~/core/utils/entrypoints';
import {
@@ -197,15 +196,6 @@ function createFileReloader(options: {
.join(', ')}`,
);
- const rebuiltNames = changes.rebuildGroups
- .flat()
- .map((entry) => {
- return pc.cyan(
- relative(config.outDir, getEntrypointOutputFile(entry, '')),
- );
- })
- .join(pc.dim(', '));
-
// Rebuild entrypoints on change
const allEntrypoints = await findEntrypoints(config);
const { output: newOutput } = await rebuild(
@@ -221,15 +211,24 @@ function createFileReloader(options: {
switch (changes.type) {
case 'extension-reload':
server.reloadExtension();
+ consola.success(`Reloaded extension`);
break;
case 'html-reload':
- reloadHtmlPages(changes.rebuildGroups, server, config);
+ const { reloadedNames } = reloadHtmlPages(
+ changes.rebuildGroups,
+ server,
+ config,
+ );
+ consola.success(`Reloaded: ${getFilenameList(reloadedNames)}`);
break;
case 'content-script-reload':
reloadContentScripts(changes.changedSteps, config, server);
+ const rebuiltNames = changes.rebuildGroups
+ .flat()
+ .map((entry) => entry.name);
+ consola.success(`Reloaded: ${getFilenameList(rebuiltNames)}`);
break;
}
- consola.success(`Reloaded: ${rebuiltNames}`);
});
};
}
@@ -279,9 +278,26 @@ function reloadHtmlPages(
groups: EntrypointGroup[],
server: WxtDevServer,
config: InternalConfig,
-) {
- groups.flat().forEach((entry) => {
+): { reloadedNames: string[] } {
+ // groups might contain other files like background/content scripts, and we only care about the HTMl pages
+ const htmlEntries = groups
+ .flat()
+ .filter((entry) => entry.inputPath.endsWith('.html'));
+
+ htmlEntries.forEach((entry) => {
const path = getEntrypointBundlePath(entry, config.outDir, '.html');
server.reloadPage(path);
});
+
+ return {
+ reloadedNames: htmlEntries.map((entry) => entry.name),
+ };
+}
+
+function getFilenameList(names: string[]): string {
+ return names
+ .map((name) => {
+ return pc.cyan(name);
+ })
+ .join(pc.dim(', '));
}
diff --git a/src/core/utils/building/__tests__/find-entrypoints.test.ts b/src/core/utils/building/__tests__/find-entrypoints.test.ts
index 934fa0040..15ba49b91 100644
--- a/src/core/utils/building/__tests__/find-entrypoints.test.ts
+++ b/src/core/utils/building/__tests__/find-entrypoints.test.ts
@@ -27,6 +27,7 @@ const readFileMock = vi.mocked(
describe('findEntrypoints', () => {
const config = fakeInternalConfig({
+ manifestVersion: 3,
root: '/',
entrypointsDir: resolve('/src/entrypoints'),
outDir: resolve('.output'),
@@ -254,6 +255,32 @@ describe('findEntrypoints', () => {
},
);
+ it('should remove type=module from MV2 background scripts', async () => {
+ const config = fakeInternalConfig({ manifestVersion: 2 });
+ const options: BackgroundEntrypoint['options'] = {
+ type: 'module',
+ };
+ globMock.mockResolvedValueOnce(['background.ts']);
+ importEntrypointFileMock.mockResolvedValue(options);
+
+ const entrypoints = await findEntrypoints(config);
+
+ expect(entrypoints[0].options).toEqual({});
+ });
+
+ it('should allow type=module for MV3 background service workers', async () => {
+ const config = fakeInternalConfig({ manifestVersion: 3 });
+ const options: BackgroundEntrypoint['options'] = {
+ type: 'module',
+ };
+ globMock.mockResolvedValueOnce(['background.ts']);
+ importEntrypointFileMock.mockResolvedValue(options);
+
+ const entrypoints = await findEntrypoints(config);
+
+ expect(entrypoints[0].options).toEqual(options);
+ });
+
it("should include a virtual background script so dev reloading works when there isn't a background entrypoint defined by the user", async () => {
globMock.mockResolvedValueOnce(['popup.html']);
diff --git a/src/core/utils/building/__tests__/group-entrypoints.test.ts b/src/core/utils/building/__tests__/group-entrypoints.test.ts
index 41a8ec954..33c7caf4f 100644
--- a/src/core/utils/building/__tests__/group-entrypoints.test.ts
+++ b/src/core/utils/building/__tests__/group-entrypoints.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { Entrypoint } from '~/types';
import { groupEntrypoints } from '../group-entrypoints';
+import {
+ fakeBackgroundEntrypoint,
+ fakeGenericEntrypoint,
+ fakePopupEntrypoint,
+} from '../../testing/fake-objects';
const background: Entrypoint = {
type: 'background',
@@ -141,4 +146,29 @@ describe('groupEntrypoints', () => {
expect(actual).toEqual(expected);
});
+
+ it('should group ESM compatible scripts with extension pages', () => {
+ const background = fakeBackgroundEntrypoint({
+ options: {
+ type: 'module',
+ },
+ });
+ const popup = fakePopupEntrypoint();
+ const sandbox = fakeGenericEntrypoint({
+ inputPath: '/entrypoints/sandbox.html',
+ name: 'sandbox',
+ type: 'sandbox',
+ });
+
+ const actual = groupEntrypoints([background, popup, sandbox]);
+
+ expect(actual).toEqual([[background, popup], [sandbox]]);
+ });
+
+ it.todo(
+ 'should group ESM compatible sandbox scripts with sandbox pages',
+ () => {
+ // Main world content scripts
+ },
+ );
});
diff --git a/src/core/utils/building/find-entrypoints.ts b/src/core/utils/building/find-entrypoints.ts
index f97753e5e..e45af0d46 100644
--- a/src/core/utils/building/find-entrypoints.ts
+++ b/src/core/utils/building/find-entrypoints.ts
@@ -392,6 +392,11 @@ async function getBackgroundEntrypoint(
const { main: _, ...moduleOptions } = defaultExport;
options = moduleOptions;
}
+
+ if (config.manifestVersion !== 3) {
+ delete options.type;
+ }
+
return {
type: 'background',
name,
diff --git a/src/core/utils/building/group-entrypoints.ts b/src/core/utils/building/group-entrypoints.ts
index 495b24e75..28a2f59e7 100644
--- a/src/core/utils/building/group-entrypoints.ts
+++ b/src/core/utils/building/group-entrypoints.ts
@@ -11,8 +11,11 @@ export function groupEntrypoints(entrypoints: Entrypoint[]): EntrypointGroup[] {
const groups: EntrypointGroup[] = [];
for (const entry of entrypoints) {
- const group = ENTRY_TYPE_TO_GROUP_MAP[entry.type];
- if (group === 'no-group') {
+ let group = ENTRY_TYPE_TO_GROUP_MAP[entry.type];
+ if (entry.type === 'background' && entry.options.type === 'module') {
+ group = 'esm';
+ }
+ if (group === 'individual') {
groups.push(entry);
} else {
let groupIndex = groupIndexMap[group];
@@ -28,22 +31,22 @@ export function groupEntrypoints(entrypoints: Entrypoint[]): EntrypointGroup[] {
}
const ENTRY_TYPE_TO_GROUP_MAP: Record = {
- sandbox: 'sandbox-page',
+ sandbox: 'sandboxed-esm',
- popup: 'extension-page',
- newtab: 'extension-page',
- history: 'extension-page',
- options: 'extension-page',
- devtools: 'extension-page',
- bookmarks: 'extension-page',
- sidepanel: 'extension-page',
- 'unlisted-page': 'extension-page',
+ popup: 'esm',
+ newtab: 'esm',
+ history: 'esm',
+ options: 'esm',
+ devtools: 'esm',
+ bookmarks: 'esm',
+ sidepanel: 'esm',
+ 'unlisted-page': 'esm',
- background: 'no-group',
- 'content-script': 'no-group',
- 'unlisted-script': 'no-group',
- 'unlisted-style': 'no-group',
- 'content-script-style': 'no-group',
+ background: 'individual',
+ 'content-script': 'individual',
+ 'unlisted-script': 'individual',
+ 'unlisted-style': 'individual',
+ 'content-script-style': 'individual',
};
-type Group = 'extension-page' | 'sandbox-page' | 'no-group';
+type Group = 'esm' | 'sandboxed-esm' | 'individual';