From f6acf6d5b5ff0b4e90e46fe1e8031d34a0585f87 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 30 Mar 2024 13:22:27 -0500 Subject: [PATCH 1/4] WIP --- demo/src/entrypoints/background.ts | 2 ++ demo/src/entrypoints/ui.content/index.ts | 2 ++ .../content-scripts/content-script-context.ts | 2 +- src/client/content-scripts/custom-events.ts | 18 +++++++------ .../content-scripts/location-watcher.ts | 2 +- src/client/content-scripts/ui/index.ts | 6 ++--- .../vite/plugins/resolveVirtualModules.ts | 26 ++++++++++++++++++- src/core/create-server.ts | 4 +-- src/core/utils/building/resolve-config.ts | 4 ++- src/core/utils/virtual-modules.ts | 1 + src/types/globals.d.ts | 12 ++++++++- src/types/index.ts | 25 +++++++++++++++++- ...ontent-script-isolated-world-entrypoint.ts | 4 +-- .../content-script-loader-entrypoint.ts | 10 +++++++ .../content-script-main-world-entrypoint.ts | 2 +- src/virtual/unlisted-script-entrypoint.ts | 2 +- 16 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 src/virtual/content-script-loader-entrypoint.ts diff --git a/demo/src/entrypoints/background.ts b/demo/src/entrypoints/background.ts index cfe9d9ddd..5cecb0647 100644 --- a/demo/src/entrypoints/background.ts +++ b/demo/src/entrypoints/background.ts @@ -1,7 +1,9 @@ import messages from '~/public/_locales/en/messages.json'; export default defineBackground({ + // TODO: Leave commented out // type: 'module', + type: 'module', main() { console.log(browser.runtime.id); diff --git a/demo/src/entrypoints/ui.content/index.ts b/demo/src/entrypoints/ui.content/index.ts index f619f1a1e..e5a47827a 100644 --- a/demo/src/entrypoints/ui.content/index.ts +++ b/demo/src/entrypoints/ui.content/index.ts @@ -4,8 +4,10 @@ import './style.css'; export default defineContentScript({ matches: ['https://*.duckduckgo.com/*'], cssInjectionMode: 'ui', + type: 'module', async main(ctx) { + logId(); const ui = await createShadowRootUi(ctx, { name: 'demo-ui', position: 'inline', diff --git a/src/client/content-scripts/content-script-context.ts b/src/client/content-scripts/content-script-context.ts index fca93cb48..d25a05149 100644 --- a/src/client/content-scripts/content-script-context.ts +++ b/src/client/content-scripts/content-script-context.ts @@ -42,7 +42,7 @@ export class ContentScriptContext implements AbortController { #locationWatcher = createLocationWatcher(this); constructor( - private readonly contentScriptName: string, + public readonly contentScriptName: string, public readonly options?: Omit, ) { this.#abortController = new AbortController(); diff --git a/src/client/content-scripts/custom-events.ts b/src/client/content-scripts/custom-events.ts index df3a97312..a275c712c 100644 --- a/src/client/content-scripts/custom-events.ts +++ b/src/client/content-scripts/custom-events.ts @@ -1,26 +1,28 @@ import { browser } from '~/browser'; +import { ContentScriptContext } from '.'; export class WxtLocationChangeEvent extends Event { - static EVENT_NAME = getUniqueEventName('wxt:locationchange'); + static getEventName = (ctx: ContentScriptContext) => + getUniqueEventName(ctx, 'wxt:locationchange'); constructor( + ctx: ContentScriptContext, readonly newUrl: URL, readonly oldUrl: URL, ) { - super(WxtLocationChangeEvent.EVENT_NAME, {}); + super(WxtLocationChangeEvent.getEventName(ctx), {}); } } /** * Returns an event name unique to the extension and content script that's running. */ -export function getUniqueEventName(eventName: string): string { +export function getUniqueEventName( + ctx: ContentScriptContext, + eventName: string, +): string { // During the build process, import.meta.env is not defined when importing // entrypoints to get their metadata. - const entrypointName = - typeof import.meta.env === 'undefined' - ? 'build' - : import.meta.env.ENTRYPOINT; - return `${browser.runtime.id}:${entrypointName}:${eventName}`; + return `${browser.runtime.id}:${ctx.contentScriptName}:${eventName}`; } diff --git a/src/client/content-scripts/location-watcher.ts b/src/client/content-scripts/location-watcher.ts index d0ae27695..5f573349b 100644 --- a/src/client/content-scripts/location-watcher.ts +++ b/src/client/content-scripts/location-watcher.ts @@ -21,7 +21,7 @@ export function createLocationWatcher(ctx: ContentScriptContext) { interval = ctx.setInterval(() => { let newUrl = new URL(location.href); if (newUrl.href !== oldUrl.href) { - window.dispatchEvent(new WxtLocationChangeEvent(newUrl, oldUrl)); + window.dispatchEvent(new WxtLocationChangeEvent(ctx, newUrl, oldUrl)); oldUrl = newUrl; } }, 1e3); diff --git a/src/client/content-scripts/ui/index.ts b/src/client/content-scripts/ui/index.ts index a2e49dc95..96eb60e40 100644 --- a/src/client/content-scripts/ui/index.ts +++ b/src/client/content-scripts/ui/index.ts @@ -97,7 +97,7 @@ export async function createShadowRootUi( ): Promise> { const css = [options.css ?? '']; if (ctx.options?.cssInjectionMode === 'ui') { - const entryCss = await loadCss(); + const entryCss = await loadCss(ctx); // Replace :root selectors with :host since we're in a shadow root css.push(entryCss.replaceAll(':root', ':host')); } @@ -230,9 +230,9 @@ function mountUi( /** * Load the CSS for the current entrypoint. */ -async function loadCss(): Promise { +async function loadCss(ctx: ContentScriptContext): Promise { const url = browser.runtime.getURL( - `/content-scripts/${import.meta.env.ENTRYPOINT}.css`, + `/content-scripts/${ctx.contentScriptName}.css`, ); try { const res = await fetch(url); diff --git a/src/core/builders/vite/plugins/resolveVirtualModules.ts b/src/core/builders/vite/plugins/resolveVirtualModules.ts index 600ba7d40..4168bfaa4 100644 --- a/src/core/builders/vite/plugins/resolveVirtualModules.ts +++ b/src/core/builders/vite/plugins/resolveVirtualModules.ts @@ -7,6 +7,7 @@ import { } from '~/core/utils/virtual-modules'; import fs from 'fs-extra'; import { resolve } from 'path'; +import { getEntrypointName } from '~/core/utils/entrypoints'; /** * Resolve all the virtual modules to the `node_modules/wxt/dist/virtual` directory. @@ -30,11 +31,34 @@ export function resolveVirtualModules(config: ResolvedConfig): Plugin[] { if (!id.startsWith(resolvedVirtualId)) return; const inputPath = id.replace(resolvedVirtualId, ''); + const entrypointName = getEntrypointName( + config.entrypointsDir, + inputPath, + ); + if (!entrypointName) + throw Error('Entrypoint name could not be detected: ' + inputPath); + const template = await fs.readFile( resolve(config.wxtModuleDir, `dist/virtual/${name}.js`), 'utf-8', ); - return template.replace(`virtual:user-${name}`, inputPath); + return template + .replace(`virtual:user-${name}`, inputPath) + .replaceAll('__ENTRYPOINT__', JSON.stringify(entrypointName)) + .replaceAll( + '__ESM_CONTENT_SCRIPT_URL__', + config.dev.server != null + ? // Point to virtual entrypoint in dev server + JSON.stringify( + `${ + config.dev.server.origin + }/virtual:wxt-content-script-isolated-world?${normalizePath( + inputPath, + )}`, + ) + : // Point to file in bundle + `chrome.runtime.getURL('/content-scripts/${entrypointName}.js')`, + ); }, }; }); diff --git a/src/core/create-server.ts b/src/core/create-server.ts index 8293769e9..4ea6aefae 100644 --- a/src/core/create-server.ts +++ b/src/core/create-server.ts @@ -44,11 +44,11 @@ export async function createServer( inlineConfig?: InlineConfig, ): Promise { await registerWxt('serve', inlineConfig, async (config) => { - const { port, hostname } = config.dev.server!; + const { port, hostname, origin } = config.dev.server!; const serverInfo: ServerInfo = { port, hostname, - origin: `http://${hostname}:${port}`, + origin, }; // Server instance must be created first so its reference can be added to the internal config used diff --git a/src/core/utils/building/resolve-config.ts b/src/core/utils/building/resolve-config.ts index e692ef687..884c70930 100644 --- a/src/core/utils/building/resolve-config.ts +++ b/src/core/utils/building/resolve-config.ts @@ -117,9 +117,11 @@ export async function resolveConfig( const { default: getPort, portNumbers } = await import('get-port'); port = await getPort({ port: portNumbers(3000, 3010) }); } + const hostname = 'localhost'; devServerConfig = { port, - hostname: 'localhost', + hostname, + origin: `http://${hostname}:${port}`, }; } diff --git a/src/core/utils/virtual-modules.ts b/src/core/utils/virtual-modules.ts index 35653edc3..1979e6508 100644 --- a/src/core/utils/virtual-modules.ts +++ b/src/core/utils/virtual-modules.ts @@ -1,6 +1,7 @@ export const virtualEntrypointTypes = [ 'content-script-main-world' as const, 'content-script-isolated-world' as const, + 'content-script-loader' as const, 'background' as const, 'unlisted-script' as const, ]; diff --git a/src/types/globals.d.ts b/src/types/globals.d.ts index 290d34474..32faeeb80 100644 --- a/src/types/globals.d.ts +++ b/src/types/globals.d.ts @@ -6,5 +6,15 @@ declare const __DEV_SERVER_PORT__: string; interface ImportMetaEnv { readonly COMMAND: WxtCommand; readonly MANIFEST_VERSION: 2 | 3; - readonly ENTRYPOINT: string; } + +/** + * Name of the entrypoint running. Not defined by Vite, since it needs to be different for different + * entrypoints. Instead, this is replaced only in virtual entrypoints when loading the template as + * text. + */ +declare const __ENTRYPOINT__: string; +/** + * URL to load ESM content script from. + */ +declare const __ESM_CONTENT_SCRIPT_URL__: string; diff --git a/src/types/index.ts b/src/types/index.ts index 5579dc265..61b064342 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -508,7 +508,29 @@ export interface BaseContentScriptEntrypointOptions extends BaseEntrypointOptions { matches: PerBrowserOption; /** - * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ + * When `undefined`, the content script is bundled individually and executed + * based on the `runAt` option. + * + * When `"module"`, the content script is code-split and bundled as ESM. A + * separate loader file is instead listed in the manifest and executed based + * the `runAt` option. This loader dynamically imports the ESM content + * script, with no guarantee when it will start executing. + * + * The trade-off between the two options is this: Leaving `type: undefined` + * will guarentee that your content script respsects `runAt` at the cost of a + * slower build and larger bundle, while `type: "module"` will reduce build + * time and overally bundle size at the cost of being ran asynchrouously. + */ + type?: 'module'; + /** + * Tells the browser when to load the content script. See + * + * + * When used alongside `type: "module"`, this tells the browser when to fetch + * the ES module, rather than when to actually execute the code, because ESM + * content scripts are imported using a + * [dynamic import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import). + * * @default "documentIdle" */ runAt?: PerBrowserOption; @@ -1087,6 +1109,7 @@ export interface ResolvedConfig { server?: { port: number; hostname: string; + origin: string; }; reloadCommand: string | false; }; diff --git a/src/virtual/content-script-isolated-world-entrypoint.ts b/src/virtual/content-script-isolated-world-entrypoint.ts index c2caa2007..8cc3ad3d9 100644 --- a/src/virtual/content-script-isolated-world-entrypoint.ts +++ b/src/virtual/content-script-isolated-world-entrypoint.ts @@ -5,12 +5,12 @@ import { ContentScriptContext } from 'wxt/client'; (async () => { try { const { main, ...options } = definition; - const ctx = new ContentScriptContext(import.meta.env.ENTRYPOINT, options); + const ctx = new ContentScriptContext(__ENTRYPOINT__, options); await main(ctx); } catch (err) { logger.error( - `The content script "${import.meta.env.ENTRYPOINT}" crashed on startup!`, + `The content script "${__ENTRYPOINT__}" crashed on startup!`, err, ); } diff --git a/src/virtual/content-script-loader-entrypoint.ts b/src/virtual/content-script-loader-entrypoint.ts new file mode 100644 index 000000000..11e8255d1 --- /dev/null +++ b/src/virtual/content-script-loader-entrypoint.ts @@ -0,0 +1,10 @@ +import { logger } from '../sandbox/utils/logger'; + +(async () => { + try { + /* vite-ignore */ + await import(__ESM_CONTENT_SCRIPT_URL__); + } catch (err) { + logger.error(`Failed to load ESM content script: "${__ENTRYPOINT__}"`, err); + } +})(); diff --git a/src/virtual/content-script-main-world-entrypoint.ts b/src/virtual/content-script-main-world-entrypoint.ts index 52616afbd..990ce019d 100644 --- a/src/virtual/content-script-main-world-entrypoint.ts +++ b/src/virtual/content-script-main-world-entrypoint.ts @@ -7,7 +7,7 @@ import { logger } from '../sandbox/utils/logger'; await main(); } catch (err) { logger.error( - `The content script "${import.meta.env.ENTRYPOINT}" crashed on startup!`, + `The content script "${__ENTRYPOINT__}" crashed on startup!`, err, ); } diff --git a/src/virtual/unlisted-script-entrypoint.ts b/src/virtual/unlisted-script-entrypoint.ts index 035b7e383..36d1d93e2 100644 --- a/src/virtual/unlisted-script-entrypoint.ts +++ b/src/virtual/unlisted-script-entrypoint.ts @@ -6,7 +6,7 @@ import { logger } from '../sandbox/utils/logger'; await definition.main(); } catch (err) { logger.error( - `The unlisted script "${import.meta.env.ENTRYPOINT}" crashed on startup!`, + `The unlisted script "${__ENTRYPOINT__}" crashed on startup!`, err, ); } From e5cb214ebfabdb9c2b7de29e5848b255e3c3fc25 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 30 Mar 2024 14:39:24 -0500 Subject: [PATCH 2/4] wip --- .../content-scripts/content-script-context.ts | 2 +- src/core/builders/vite/index.ts | 42 +++++++++++++++++-- .../vite/plugins/resolveVirtualModules.ts | 26 ++++++------ src/types/globals.d.ts | 9 ++++ 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/client/content-scripts/content-script-context.ts b/src/client/content-scripts/content-script-context.ts index d25a05149..73ea4911d 100644 --- a/src/client/content-scripts/content-script-context.ts +++ b/src/client/content-scripts/content-script-context.ts @@ -192,7 +192,7 @@ export class ContentScriptContext implements AbortController { } target.addEventListener?.( - type.startsWith('wxt:') ? getUniqueEventName(type) : type, + type.startsWith('wxt:') ? getUniqueEventName(this, type) : type, // @ts-expect-error: Event don't match, but that's OK, EventTarget doesn't allow custom types in the callback handler, { diff --git a/src/core/builders/vite/index.ts b/src/core/builders/vite/index.ts index 179f15173..1d33e1851 100644 --- a/src/core/builders/vite/index.ts +++ b/src/core/builders/vite/index.ts @@ -132,6 +132,9 @@ export async function createViteBuilder( const htmlEntrypoints = new Set( entrypoints.filter(isHtmlEntrypoint).map((e) => e.name), ); + const esmContentScripts = new Set( + entrypoints.filter((e) => e.type === 'content-script').map((e) => e.name), + ); return { mode: wxtConfig.mode, plugins: [ @@ -141,7 +144,19 @@ export async function createViteBuilder( build: { rollupOptions: { input: entrypoints.reduce>((input, entry) => { - input[entry.name] = getRollupEntry(entry); + if (entry.type !== 'content-script') { + input[entry.name] = getRollupEntry(entry); + return input; + } + + // All multi-page content scripts are ESM, don't need to check that + if (wxtConfig.command !== 'serve') { + // Don't build the content script during development - the script + // will be loaded directly from dev server via a URL import + input[entry.name] = getRollupEntry(entry); + } + input[`content-scripts/${entry.name}-loader`] = + getEsmContentScriptLoaderRollupEntry(entry); return input; }, {}), output: { @@ -150,11 +165,26 @@ export async function createViteBuilder( entryFileNames: ({ name }) => { // HTML main JS files go in the chunks folder if (htmlEntrypoints.has(name)) return 'chunks/[name]-[hash].js'; + + // ESM Content script entrypoints are placed along side content scripts + if (esmContentScripts.has(name)) + return 'content-scripts/[name].js'; + // Scripts are output in the root folder return '[name].js'; }, - // We can't control the "name", so we need a hash to prevent conflicts - assetFileNames: 'assets/[name]-[hash].[ext]', + assetFileNames: (asset) => { + if ( + asset.name && + esmContentScripts.has(asset.name.replace('.css', '')) + ) { + // Place ESM content script stylesheets along side the scripts + return 'content-scripts/[name].[ext]'; + } + + // We can't control the "name" for HTML CSS chunks, so we need a hash to prevent conflicts + return 'assets/[name]-[hash].[ext]'; + }, }, }, }, @@ -277,3 +307,9 @@ function getRollupEntry(entrypoint: Entrypoint): string { } return entrypoint.inputPath; } + +function getEsmContentScriptLoaderRollupEntry(entrypoint: Entrypoint): string { + const moduleId: VirtualModuleId = + 'virtual:wxt-content-script-loader-entrypoint'; + return `${moduleId}?${entrypoint.inputPath}`; +} diff --git a/src/core/builders/vite/plugins/resolveVirtualModules.ts b/src/core/builders/vite/plugins/resolveVirtualModules.ts index 4168bfaa4..172038a11 100644 --- a/src/core/builders/vite/plugins/resolveVirtualModules.ts +++ b/src/core/builders/vite/plugins/resolveVirtualModules.ts @@ -42,23 +42,21 @@ export function resolveVirtualModules(config: ResolvedConfig): Plugin[] { resolve(config.wxtModuleDir, `dist/virtual/${name}.js`), 'utf-8', ); + + let esmContentScriptUrl = `chrome.runtime.getURL('/content-scripts/${entrypointName}.js')`; + if (config.dev.server) { + // TODO: Support ESM main world content scripts; + const loaderId: VirtualModuleId = + 'virtual:wxt-content-script-isolated-world-entrypoint'; + const query = normalizePath(inputPath); + const url = `${config.dev.server.origin}/${loaderId}?${query}`; + esmContentScriptUrl = JSON.stringify(url); + } + return template .replace(`virtual:user-${name}`, inputPath) .replaceAll('__ENTRYPOINT__', JSON.stringify(entrypointName)) - .replaceAll( - '__ESM_CONTENT_SCRIPT_URL__', - config.dev.server != null - ? // Point to virtual entrypoint in dev server - JSON.stringify( - `${ - config.dev.server.origin - }/virtual:wxt-content-script-isolated-world?${normalizePath( - inputPath, - )}`, - ) - : // Point to file in bundle - `chrome.runtime.getURL('/content-scripts/${entrypointName}.js')`, - ); + .replaceAll('__ESM_CONTENT_SCRIPT_URL__', esmContentScriptUrl); }, }; }); diff --git a/src/types/globals.d.ts b/src/types/globals.d.ts index 32faeeb80..a89f512a5 100644 --- a/src/types/globals.d.ts +++ b/src/types/globals.d.ts @@ -8,6 +8,15 @@ interface ImportMetaEnv { readonly MANIFEST_VERSION: 2 | 3; } +// TODO: Remove PR context +// Moved from import.meta.env.ENTRYPOINT to __ENTRYPOINT__ because ESM content +// scripts are included in the multi-page build, and a simple defines couldn't +// give loaders the actual name of their entrypoint. Instead, when loading the +// virtual entrypoints, we replace this template variable with the actual +// content script's name, which works even when including the content scripts +// in the multi-page build. However, since it can only be used in virtual +// modules, I also edited the content script context to allow access to the +// content script's name. /** * Name of the entrypoint running. Not defined by Vite, since it needs to be different for different * entrypoints. Instead, this is replaced only in virtual entrypoints when loading the template as From a21036c318e094121b82faef67198d8241e314ca Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 30 Mar 2024 20:23:04 -0500 Subject: [PATCH 3/4] Bring over rest of the code --- src/core/create-server.ts | 26 ++++--- src/core/utils/building/find-entrypoints.ts | 8 +- src/core/utils/building/group-entrypoints.ts | 3 + src/core/utils/building/internal-build.ts | 15 +++- src/core/utils/content-scripts.ts | 9 +++ src/core/utils/manifest.ts | 81 ++++++++++++++++---- src/types/index.ts | 2 + 7 files changed, 114 insertions(+), 30 deletions(-) diff --git a/src/core/create-server.ts b/src/core/create-server.ts index 4ea6aefae..8db537d7a 100644 --- a/src/core/create-server.ts +++ b/src/core/create-server.ts @@ -30,6 +30,7 @@ import { getContentScriptJs, mapWxtOptionsToRegisteredContentScript, } from './utils/content-scripts'; +import { toArray } from './utils/arrays'; /** * Creates a dev server and pre-builds all the files that need to exist before loading the extension. @@ -232,20 +233,21 @@ function reloadContentScripts(steps: BuildStepOutput[], server: WxtDevServer) { steps.forEach((step) => { if (server.currentOutput == null) return; - const entry = step.entrypoints; - if (Array.isArray(entry) || entry.type !== 'content-script') return; + toArray(step.entrypoints).forEach((entry) => { + if (entry.type !== 'content-script') return; - const js = getContentScriptJs(wxt.config, entry); - const cssMap = getContentScriptsCssMap(server.currentOutput, [entry]); - const css = getContentScriptCssFiles([entry], cssMap); + const js = getContentScriptJs(wxt.config, entry); + const cssMap = getContentScriptsCssMap(server.currentOutput!, [entry]); + const css = getContentScriptCssFiles([entry], cssMap); - server.reloadContentScript({ - registration: entry.options.registration, - contentScript: mapWxtOptionsToRegisteredContentScript( - entry.options, - js, - css, - ), + server.reloadContentScript({ + registration: entry.options.registration, + contentScript: mapWxtOptionsToRegisteredContentScript( + entry.options, + js, + css, + ), + }); }); }); } else { diff --git a/src/core/utils/building/find-entrypoints.ts b/src/core/utils/building/find-entrypoints.ts index c2aa3ce65..3f7f298c2 100644 --- a/src/core/utils/building/find-entrypoints.ts +++ b/src/core/utils/building/find-entrypoints.ts @@ -29,6 +29,7 @@ import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants'; import { CSS_EXTENSIONS_PATTERN } from '~/core/utils/paths'; import pc from 'picocolors'; import { wxt } from '../../wxt'; +import { inspect } from 'node:util'; /** * Return entrypoints and their configuration by looking through the project's files. @@ -123,7 +124,12 @@ export async function findEntrypoints(): Promise { ); } - wxt.logger.debug('All entrypoints:', entrypoints); + if (wxt.config.debug) { + wxt.logger.debug( + 'All entrypoints:', + inspect(entrypoints, undefined, Infinity, true), + ); + } const skippedEntrypointNames = entrypointInfos .filter((item) => item.skipped) .map((item) => item.name); diff --git a/src/core/utils/building/group-entrypoints.ts b/src/core/utils/building/group-entrypoints.ts index 28a2f59e7..c8f8d7e88 100644 --- a/src/core/utils/building/group-entrypoints.ts +++ b/src/core/utils/building/group-entrypoints.ts @@ -15,6 +15,9 @@ export function groupEntrypoints(entrypoints: Entrypoint[]): EntrypointGroup[] { if (entry.type === 'background' && entry.options.type === 'module') { group = 'esm'; } + if (entry.type === 'content-script' && entry.options.type === 'module') { + group = entry.options.world === 'MAIN' ? 'sandboxed-esm' : 'esm'; + } if (group === 'individual') { groups.push(entry); } else { diff --git a/src/core/utils/building/internal-build.ts b/src/core/utils/building/internal-build.ts index 1aa0b391c..9873ad71a 100644 --- a/src/core/utils/building/internal-build.ts +++ b/src/core/utils/building/internal-build.ts @@ -19,6 +19,7 @@ import consola from 'consola'; import { wxt } from '../../wxt'; import { mergeJsonOutputs } from '@aklinker1/rollup-plugin-visualizer'; import { isCI } from 'ci-info'; +import { inspect } from 'node:util'; /** * Builds the extension based on an internal config. No more config discovery is performed, the @@ -47,7 +48,6 @@ export async function internalBuild(): Promise { await fs.ensureDir(wxt.config.outDir); const entrypoints = await findEntrypoints(); - wxt.logger.debug('Detected entrypoints:', entrypoints); const validationResults = validateEntrypoints(entrypoints); if (validationResults.errorCount + validationResults.warningCount > 0) { @@ -60,6 +60,19 @@ export async function internalBuild(): Promise { } const groups = groupEntrypoints(entrypoints); + if (wxt.config.debug) { + wxt.logger.debug( + 'Groups:', + inspect( + groups.map((group) => + Array.isArray(group) ? group.map((entry) => entry.name) : group.name, + ), + undefined, + Infinity, + true, + ), + ); + } await wxt.hooks.callHook('entrypoints:grouped', wxt, groups); const { output, warnings } = await rebuild(entrypoints, groups, undefined); diff --git a/src/core/utils/content-scripts.ts b/src/core/utils/content-scripts.ts index 2d483e45a..c715e2636 100644 --- a/src/core/utils/content-scripts.ts +++ b/src/core/utils/content-scripts.ts @@ -89,5 +89,14 @@ export function getContentScriptJs( config: ResolvedConfig, entrypoint: ContentScriptEntrypoint, ): string[] { + if (entrypoint.options.type === 'module') { + entrypoint = getEsmLoaderEntrypoint(entrypoint); + } return [getEntrypointBundlePath(entrypoint, config.outDir, '.js')]; } + +export function getEsmLoaderEntrypoint( + entrypoint: ContentScriptEntrypoint, +): ContentScriptEntrypoint { + return { ...entrypoint, name: `${entrypoint.name}-loader` }; +} diff --git a/src/core/utils/manifest.ts b/src/core/utils/manifest.ts index 3fbb48569..7af493305 100644 --- a/src/core/utils/manifest.ts +++ b/src/core/utils/manifest.ts @@ -10,9 +10,13 @@ import { } from '~/types'; import fs from 'fs-extra'; import { resolve } from 'path'; -import { getEntrypointBundlePath } from './entrypoints'; +import { + getEntrypointBundlePath, + resolvePerBrowserOption, +} from './entrypoints'; import { ContentSecurityPolicy } from './content-security-policy'; import { + getContentScriptJs, hashContentScriptOptions, mapWxtOptionsToContentScript, } from './content-scripts'; @@ -379,9 +383,7 @@ function addEntrypoints( ).map((scripts) => mapWxtOptionsToContentScript( scripts[0].options, - scripts.map((entry) => - getEntrypointBundlePath(entry, wxt.config.outDir, '.js'), - ), + scripts.flatMap((entry) => getContentScriptJs(wxt.config, entry)), getContentScriptCssFiles(scripts, cssMap), ), ); @@ -409,7 +411,7 @@ function addEntrypoints( }); } - const contentScriptCssResources = getContentScriptCssWebAccessibleResources( + const contentScriptCssResources = getContentScriptWebAccessibleResources( contentScripts, cssMap, ); @@ -523,29 +525,76 @@ export function getContentScriptCssFiles( * Content scripts configured with `cssInjectionMode: "ui"` need to add their CSS files to web * accessible resources so they can be fetched as text and added to shadow roots that the UI is * added to. + * + * ESM content scripts also need to load scripts that are web accessible. `chunks/*` and + * `content-scripts/.js` or else the dynamic import will fail. */ -export function getContentScriptCssWebAccessibleResources( +export function getContentScriptWebAccessibleResources( contentScripts: ContentScriptEntrypoint[], contentScriptCssMap: Record, ): any[] { - const resources: Manifest.WebExtensionManifestWebAccessibleResourcesC2ItemType[] = - []; + const resources: any[] = []; contentScripts.forEach((script) => { - if (script.options.cssInjectionMode !== 'ui') return; + addContentScriptUiWebAccessibleResource( + script, + resources, + contentScriptCssMap, + ); + addContentScriptEsmWebAccessibleResource(script, resources); + }); - const cssFile = contentScriptCssMap[script.name]; - if (cssFile == null) return; + return resources; +} +function addContentScriptUiWebAccessibleResource( + entrypoint: ContentScriptEntrypoint, + resources: any[], + contentScriptCssMap: Record, +): any | undefined { + if (entrypoint.options.cssInjectionMode !== 'ui') return; + + const cssFile = contentScriptCssMap[entrypoint.name]; + if (cssFile == null) return; + + if (wxt.config.manifestVersion === 2) { + resources.push(cssFile); + } else { resources.push({ resources: [cssFile], - matches: script.options.matches.map((matchPattern) => - stripPathFromMatchPattern(matchPattern), - ), + matches: getWebAccessibleMatches(entrypoint), }); - }); + } +} - return resources; +function addContentScriptEsmWebAccessibleResource( + entrypoint: ContentScriptEntrypoint, + resources: any[], +): any | undefined { + if (entrypoint.options.type !== 'module') return; + + const paths = [ + getEntrypointBundlePath(entrypoint, wxt.config.outDir, '.js'), + // Cheating here and adding all chunks instead of just the ones used by the content script + 'chunks/*', + ]; + if (wxt.config.manifestVersion === 2) { + resources.push(...paths); + } else { + resources.push({ + resources: paths, + matches: getWebAccessibleMatches(entrypoint), + }); + } +} + +function getWebAccessibleMatches( + entrypoint: ContentScriptEntrypoint, +): string[] { + return resolvePerBrowserOption( + entrypoint.options.matches, + wxt.config.browser, + ).map((matchPattern) => stripPathFromMatchPattern(matchPattern)); } /** diff --git a/src/types/index.ts b/src/types/index.ts index 61b064342..2c5840564 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -520,6 +520,8 @@ export interface BaseContentScriptEntrypointOptions * will guarentee that your content script respsects `runAt` at the cost of a * slower build and larger bundle, while `type: "module"` will reduce build * time and overally bundle size at the cost of being ran asynchrouously. + * + * @default undefined */ type?: 'module'; /** From 2661b9a61a1fd385b1f3a43d8464e6cb670d93d1 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sun, 31 Mar 2024 08:31:27 -0500 Subject: [PATCH 4/4] bring over tests --- .../output-structure.test.ts.snap | 569 ++++++++++++++++++ e2e/tests/output-structure.test.ts | 183 ++++++ 2 files changed, 752 insertions(+) create mode 100644 e2e/tests/__snapshots__/output-structure.test.ts.snap diff --git a/e2e/tests/__snapshots__/output-structure.test.ts.snap b/e2e/tests/__snapshots__/output-structure.test.ts.snap new file mode 100644 index 000000000..a09f381b9 --- /dev/null +++ b/e2e/tests/__snapshots__/output-structure.test.ts.snap @@ -0,0 +1,569 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Output Directory Structure > should generate ESM content script when type=module 1`] = ` +".output/chrome-mv3/content-scripts/content.js +---------------------------------------- +var __defProp = Object.defineProperty; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __publicField = (obj, key, value) => { + __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + return value; +}; +var __accessCheck = (obj, member, msg) => { + if (!member.has(obj)) + throw TypeError("Cannot " + msg); +}; +var __privateGet = (obj, member, getter) => { + __accessCheck(obj, member, "read from private field"); + return getter ? getter.call(obj) : member.get(obj); +}; +var __privateAdd = (obj, member, value) => { + if (member.has(obj)) + throw TypeError("Cannot add the same private member more than once"); + member instanceof WeakSet ? member.add(obj) : member.set(obj, value); +}; +var __privateSet = (obj, member, value, setter) => { + __accessCheck(obj, member, "write to private field"); + setter ? setter.call(obj, value) : member.set(obj, value); + return value; +}; +var __privateMethod = (obj, member, method) => { + __accessCheck(obj, member, "access private method"); + return method; +}; +var _a, _b, _isTopFrame, _abortController, _locationWatcher, _stopOldScripts, stopOldScripts_fn, _listenForNewerScripts, listenForNewerScripts_fn; +import { l as logHello } from "../chunks/log-bezs0tt4.js"; +function defineContentScript(definition2) { + return definition2; +} +const definition = defineContentScript({ + matches: [""], + type: "module", + main() { + logHello("background"); + } +}); +const originalBrowser = chrome; +var browser = originalBrowser; +function print$1(method, ...args) { + return; +} +var logger$1 = { + debug: (...args) => print$1(console.debug, ...args), + log: (...args) => print$1(console.log, ...args), + warn: (...args) => print$1(console.warn, ...args), + error: (...args) => print$1(console.error, ...args) +}; +var WxtLocationChangeEvent = (_a = class extends Event { + constructor(ctx, newUrl, oldUrl) { + super(_a.getEventName(ctx), {}); + this.newUrl = newUrl; + this.oldUrl = oldUrl; + } +}, __publicField(_a, "getEventName", (ctx) => getUniqueEventName(ctx, "wxt:locationchange")), _a); +function getUniqueEventName(ctx, eventName) { + return \`\${browser.runtime.id}:\${ctx.contentScriptName}:\${eventName}\`; +} +function createLocationWatcher(ctx) { + let interval; + let oldUrl; + return { + /** + * Ensure the location watcher is actively looking for URL changes. If it's already watching, + * this is a noop. + */ + run() { + if (interval != null) + return; + oldUrl = new URL(location.href); + interval = ctx.setInterval(() => { + let newUrl = new URL(location.href); + if (newUrl.href !== oldUrl.href) { + window.dispatchEvent(new WxtLocationChangeEvent(ctx, newUrl, oldUrl)); + oldUrl = newUrl; + } + }, 1e3); + } + }; +} +var ContentScriptContext = (_b = class { + constructor(contentScriptName, options) { + __privateAdd(this, _stopOldScripts); + __privateAdd(this, _listenForNewerScripts); + __privateAdd(this, _isTopFrame, window.self === window.top); + __privateAdd(this, _abortController, void 0); + __privateAdd(this, _locationWatcher, createLocationWatcher(this)); + this.contentScriptName = contentScriptName; + this.options = options; + __privateSet(this, _abortController, new AbortController()); + if (__privateGet(this, _isTopFrame)) { + __privateMethod(this, _stopOldScripts, stopOldScripts_fn).call(this); + } + this.setTimeout(() => { + __privateMethod(this, _listenForNewerScripts, listenForNewerScripts_fn).call(this); + }); + } + get signal() { + return __privateGet(this, _abortController).signal; + } + abort(reason) { + return __privateGet(this, _abortController).abort(reason); + } + get isInvalid() { + if (browser.runtime.id == null) { + this.notifyInvalidated(); + } + return this.signal.aborted; + } + get isValid() { + return !this.isInvalid; + } + /** + * Add a listener that is called when the content script's context is invalidated. + * + * @returns A function to remove the listener. + * + * @example + * browser.runtime.onMessage.addListener(cb); + * const removeInvalidatedListener = ctx.onInvalidated(() => { + * browser.runtime.onMessage.removeListener(cb); + * }) + * // ... + * removeInvalidatedListener(); + */ + onInvalidated(cb) { + this.signal.addEventListener("abort", cb); + return () => this.signal.removeEventListener("abort", cb); + } + /** + * Return a promise that never resolves. Useful if you have an async function that shouldn't run + * after the context is expired. + * + * @example + * const getValueFromStorage = async () => { + * if (ctx.isInvalid) return ctx.block(); + * + * // ... + * } + */ + block() { + return new Promise(() => { + }); + } + /** + * Wrapper around \`window.setInterval\` that automatically clears the interval when invalidated. + */ + setInterval(handler, timeout) { + const id = setInterval(() => { + if (this.isValid) + handler(); + }, timeout); + this.onInvalidated(() => clearInterval(id)); + return id; + } + /** + * Wrapper around \`window.setTimeout\` that automatically clears the interval when invalidated. + */ + setTimeout(handler, timeout) { + const id = setTimeout(() => { + if (this.isValid) + handler(); + }, timeout); + this.onInvalidated(() => clearTimeout(id)); + return id; + } + /** + * Wrapper around \`window.requestAnimationFrame\` that automatically cancels the request when + * invalidated. + */ + requestAnimationFrame(callback) { + const id = requestAnimationFrame((...args) => { + if (this.isValid) + callback(...args); + }); + this.onInvalidated(() => cancelAnimationFrame(id)); + return id; + } + /** + * Wrapper around \`window.requestIdleCallback\` that automatically cancels the request when + * invalidated. + */ + requestIdleCallback(callback, options) { + const id = requestIdleCallback((...args) => { + if (!this.signal.aborted) + callback(...args); + }, options); + this.onInvalidated(() => cancelIdleCallback(id)); + return id; + } + /** + * Call \`target.addEventListener\` and remove the event listener when the context is invalidated. + * + * Includes additional events useful for content scripts: + * + * - \`"wxt:locationchange"\` - Triggered when HTML5 history mode is used to change URL. Content + * scripts are not reloaded when navigating this way, so this can be used to reset the content + * script state on URL change, or run custom code. + * + * @example + * ctx.addEventListener(document, "visibilitychange", () => { + * // ... + * }); + * ctx.addEventListener(document, "wxt:locationchange", () => { + * // ... + * }); + */ + addEventListener(target, type, handler, options) { + var _a2; + if (type === "wxt:locationchange") { + if (this.isValid) + __privateGet(this, _locationWatcher).run(); + } + (_a2 = target.addEventListener) == null ? void 0 : _a2.call( + target, + type.startsWith("wxt:") ? getUniqueEventName(this, type) : type, + // @ts-expect-error: Event don't match, but that's OK, EventTarget doesn't allow custom types in the callback + handler, + { + ...options, + signal: this.signal + } + ); + } + /** + * @internal + * Abort the abort controller and execute all \`onInvalidated\` listeners. + */ + notifyInvalidated() { + this.abort("Content script context invalidated"); + logger$1.debug( + \`Content script "\${this.contentScriptName}" context invalidated\` + ); + } +}, _isTopFrame = new WeakMap(), _abortController = new WeakMap(), _locationWatcher = new WeakMap(), _stopOldScripts = new WeakSet(), stopOldScripts_fn = function() { + window.postMessage( + { + event: _b.SCRIPT_STARTED_MESSAGE_TYPE, + contentScriptName: this.contentScriptName + }, + "*" + ); +}, _listenForNewerScripts = new WeakSet(), listenForNewerScripts_fn = function() { + const cb = (event) => { + var _a2, _b2; + if (((_a2 = event.data) == null ? void 0 : _a2.type) === _b.SCRIPT_STARTED_MESSAGE_TYPE && ((_b2 = event.data) == null ? void 0 : _b2.contentScriptName) === this.contentScriptName) { + this.notifyInvalidated(); + } + }; + addEventListener("message", cb); + this.onInvalidated(() => removeEventListener("message", cb)); +}, __publicField(_b, "SCRIPT_STARTED_MESSAGE_TYPE", "wxt:content-script-started"), _b); +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) +}; +(async () => { + try { + const { main, ...options } = definition; + const ctx = new ContentScriptContext("content", options); + await main(ctx); + } catch (err) { + logger.error( + \`The content script "\${"content"}" crashed on startup!\`, + err + ); + } +})(); +" +`; + +exports[`Output Directory Structure > should generate IIFE content script when type=undefined 1`] = ` +".output/chrome-mv3/content-scripts/content.js +---------------------------------------- +var __defProp = Object.defineProperty; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __publicField = (obj, key, value) => { + __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + return value; +}; +var __accessCheck = (obj, member, msg) => { + if (!member.has(obj)) + throw TypeError("Cannot " + msg); +}; +var __privateGet = (obj, member, getter) => { + __accessCheck(obj, member, "read from private field"); + return getter ? getter.call(obj) : member.get(obj); +}; +var __privateAdd = (obj, member, value) => { + if (member.has(obj)) + throw TypeError("Cannot add the same private member more than once"); + member instanceof WeakSet ? member.add(obj) : member.set(obj, value); +}; +var __privateSet = (obj, member, value, setter) => { + __accessCheck(obj, member, "write to private field"); + setter ? setter.call(obj, value) : member.set(obj, value); + return value; +}; +var __privateMethod = (obj, member, method) => { + __accessCheck(obj, member, "access private method"); + return method; +}; +(function() { + "use strict"; + var _a, _b, _isTopFrame, _abortController, _locationWatcher, _stopOldScripts, stopOldScripts_fn, _listenForNewerScripts, listenForNewerScripts_fn; + function defineContentScript(definition2) { + return definition2; + } + function logHello(name) { + console.log(\`Hello \${name}!\`); + } + const definition = defineContentScript({ + matches: [""], + main() { + logHello("background"); + } + }); + const originalBrowser = chrome; + var browser = originalBrowser; + function print$1(method, ...args) { + return; + } + var logger$1 = { + debug: (...args) => print$1(console.debug, ...args), + log: (...args) => print$1(console.log, ...args), + warn: (...args) => print$1(console.warn, ...args), + error: (...args) => print$1(console.error, ...args) + }; + var WxtLocationChangeEvent = (_a = class extends Event { + constructor(ctx, newUrl, oldUrl) { + super(_a.getEventName(ctx), {}); + this.newUrl = newUrl; + this.oldUrl = oldUrl; + } + }, __publicField(_a, "getEventName", (ctx) => getUniqueEventName(ctx, "wxt:locationchange")), _a); + function getUniqueEventName(ctx, eventName) { + return \`\${browser.runtime.id}:\${ctx.contentScriptName}:\${eventName}\`; + } + function createLocationWatcher(ctx) { + let interval; + let oldUrl; + return { + /** + * Ensure the location watcher is actively looking for URL changes. If it's already watching, + * this is a noop. + */ + run() { + if (interval != null) + return; + oldUrl = new URL(location.href); + interval = ctx.setInterval(() => { + let newUrl = new URL(location.href); + if (newUrl.href !== oldUrl.href) { + window.dispatchEvent(new WxtLocationChangeEvent(ctx, newUrl, oldUrl)); + oldUrl = newUrl; + } + }, 1e3); + } + }; + } + var ContentScriptContext = (_b = class { + constructor(contentScriptName, options) { + __privateAdd(this, _stopOldScripts); + __privateAdd(this, _listenForNewerScripts); + __privateAdd(this, _isTopFrame, window.self === window.top); + __privateAdd(this, _abortController, void 0); + __privateAdd(this, _locationWatcher, createLocationWatcher(this)); + this.contentScriptName = contentScriptName; + this.options = options; + __privateSet(this, _abortController, new AbortController()); + if (__privateGet(this, _isTopFrame)) { + __privateMethod(this, _stopOldScripts, stopOldScripts_fn).call(this); + } + this.setTimeout(() => { + __privateMethod(this, _listenForNewerScripts, listenForNewerScripts_fn).call(this); + }); + } + get signal() { + return __privateGet(this, _abortController).signal; + } + abort(reason) { + return __privateGet(this, _abortController).abort(reason); + } + get isInvalid() { + if (browser.runtime.id == null) { + this.notifyInvalidated(); + } + return this.signal.aborted; + } + get isValid() { + return !this.isInvalid; + } + /** + * Add a listener that is called when the content script's context is invalidated. + * + * @returns A function to remove the listener. + * + * @example + * browser.runtime.onMessage.addListener(cb); + * const removeInvalidatedListener = ctx.onInvalidated(() => { + * browser.runtime.onMessage.removeListener(cb); + * }) + * // ... + * removeInvalidatedListener(); + */ + onInvalidated(cb) { + this.signal.addEventListener("abort", cb); + return () => this.signal.removeEventListener("abort", cb); + } + /** + * Return a promise that never resolves. Useful if you have an async function that shouldn't run + * after the context is expired. + * + * @example + * const getValueFromStorage = async () => { + * if (ctx.isInvalid) return ctx.block(); + * + * // ... + * } + */ + block() { + return new Promise(() => { + }); + } + /** + * Wrapper around \`window.setInterval\` that automatically clears the interval when invalidated. + */ + setInterval(handler, timeout) { + const id = setInterval(() => { + if (this.isValid) + handler(); + }, timeout); + this.onInvalidated(() => clearInterval(id)); + return id; + } + /** + * Wrapper around \`window.setTimeout\` that automatically clears the interval when invalidated. + */ + setTimeout(handler, timeout) { + const id = setTimeout(() => { + if (this.isValid) + handler(); + }, timeout); + this.onInvalidated(() => clearTimeout(id)); + return id; + } + /** + * Wrapper around \`window.requestAnimationFrame\` that automatically cancels the request when + * invalidated. + */ + requestAnimationFrame(callback) { + const id = requestAnimationFrame((...args) => { + if (this.isValid) + callback(...args); + }); + this.onInvalidated(() => cancelAnimationFrame(id)); + return id; + } + /** + * Wrapper around \`window.requestIdleCallback\` that automatically cancels the request when + * invalidated. + */ + requestIdleCallback(callback, options) { + const id = requestIdleCallback((...args) => { + if (!this.signal.aborted) + callback(...args); + }, options); + this.onInvalidated(() => cancelIdleCallback(id)); + return id; + } + /** + * Call \`target.addEventListener\` and remove the event listener when the context is invalidated. + * + * Includes additional events useful for content scripts: + * + * - \`"wxt:locationchange"\` - Triggered when HTML5 history mode is used to change URL. Content + * scripts are not reloaded when navigating this way, so this can be used to reset the content + * script state on URL change, or run custom code. + * + * @example + * ctx.addEventListener(document, "visibilitychange", () => { + * // ... + * }); + * ctx.addEventListener(document, "wxt:locationchange", () => { + * // ... + * }); + */ + addEventListener(target, type, handler, options) { + var _a2; + if (type === "wxt:locationchange") { + if (this.isValid) + __privateGet(this, _locationWatcher).run(); + } + (_a2 = target.addEventListener) == null ? void 0 : _a2.call( + target, + type.startsWith("wxt:") ? getUniqueEventName(this, type) : type, + // @ts-expect-error: Event don't match, but that's OK, EventTarget doesn't allow custom types in the callback + handler, + { + ...options, + signal: this.signal + } + ); + } + /** + * @internal + * Abort the abort controller and execute all \`onInvalidated\` listeners. + */ + notifyInvalidated() { + this.abort("Content script context invalidated"); + logger$1.debug( + \`Content script "\${this.contentScriptName}" context invalidated\` + ); + } + }, _isTopFrame = new WeakMap(), _abortController = new WeakMap(), _locationWatcher = new WeakMap(), _stopOldScripts = new WeakSet(), stopOldScripts_fn = function() { + window.postMessage( + { + event: _b.SCRIPT_STARTED_MESSAGE_TYPE, + contentScriptName: this.contentScriptName + }, + "*" + ); + }, _listenForNewerScripts = new WeakSet(), listenForNewerScripts_fn = function() { + const cb = (event) => { + var _a2, _b2; + if (((_a2 = event.data) == null ? void 0 : _a2.type) === _b.SCRIPT_STARTED_MESSAGE_TYPE && ((_b2 = event.data) == null ? void 0 : _b2.contentScriptName) === this.contentScriptName) { + this.notifyInvalidated(); + } + }; + addEventListener("message", cb); + this.onInvalidated(() => removeEventListener("message", cb)); + }, __publicField(_b, "SCRIPT_STARTED_MESSAGE_TYPE", "wxt:content-script-started"), _b); + 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) + }; + (async () => { + try { + const { main, ...options } = definition; + const ctx = new ContentScriptContext("content", options); + await main(ctx); + } catch (err) { + logger.error( + \`The content script "\${"content"}" crashed on startup!\`, + err + ); + } + })(); +})(); +" +`; diff --git a/e2e/tests/output-structure.test.ts b/e2e/tests/output-structure.test.ts index d0b371621..d17f5caa4 100644 --- a/e2e/tests/output-structure.test.ts +++ b/e2e/tests/output-structure.test.ts @@ -392,4 +392,187 @@ describe('Output Directory Structure', () => { " `); }); + + it('should generate ESM content 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/content.ts', + `export default defineContentScript({ + matches: [""], + 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/content-scripts/content.js', + ), + ).toMatchSnapshot(); + expect( + await project.serializeFile( + '.output/chrome-mv3/content-scripts/content-loader.js', + ), + ).toMatchInlineSnapshot(` + ".output/chrome-mv3/content-scripts/content-loader.js + ---------------------------------------- + const scriptRel = "modulepreload"; + const assetsURL = function(dep) { + return "/" + dep; + }; + const seen = {}; + const __vitePreload = function preload(baseModule, deps, importerUrl) { + let promise = Promise.resolve(); + if (deps && deps.length > 0) { + const links = document.getElementsByTagName("link"); + promise = Promise.all(deps.map((dep) => { + dep = assetsURL(dep); + if (dep in seen) + return; + seen[dep] = true; + const isCss = dep.endsWith(".css"); + const cssSelector = isCss ? '[rel="stylesheet"]' : ""; + const isBaseRelative = !!importerUrl; + if (isBaseRelative) { + for (let i = links.length - 1; i >= 0; i--) { + const link2 = links[i]; + if (link2.href === dep && (!isCss || link2.rel === "stylesheet")) { + return; + } + } + } else if (document.querySelector(\`link[href="\${dep}"]\${cssSelector}\`)) { + return; + } + const link = document.createElement("link"); + link.rel = isCss ? "stylesheet" : scriptRel; + if (!isCss) { + link.as = "script"; + link.crossOrigin = ""; + } + link.href = dep; + document.head.appendChild(link); + if (isCss) { + return new Promise((res, rej) => { + link.addEventListener("load", res); + link.addEventListener("error", () => rej(new Error(\`Unable to preload CSS for \${dep}\`))); + }); + } + })); + } + return promise.then(() => baseModule()).catch((err) => { + const e = new Event("vite:preloadError", { cancelable: true }); + e.payload = err; + window.dispatchEvent(e); + if (!e.defaultPrevented) { + throw err; + } + }); + }; + 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) + }; + (async () => { + try { + await __vitePreload(() => import(chrome.runtime.getURL("/content-scripts/content.js")), true ? __vite__mapDeps([]) : void 0); + } catch (err) { + logger.error(\`Failed to load ESM content script: "\${"content"}"\`, err); + } + })(); + function __vite__mapDeps(indexes) { + if (!__vite__mapDeps.viteFileDeps) { + __vite__mapDeps.viteFileDeps = [] + } + return indexes.map((i) => __vite__mapDeps.viteFileDeps[i]) + } + " + `); + }); + + it('should generate IIFE content 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/content.ts', + `export default defineContentScript({ + matches: [""], + 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/content-scripts/content.js', + ), + ).toMatchSnapshot(); + expect( + await project.fileExists( + '.output/chrome-mv3/content-scripts/content-loader.js', + ), + ).toBe(false); + }); });