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';