From 3459b5b5f7438651a9cedf319a8dec6fb4879f9d Mon Sep 17 00:00:00 2001 From: eupthere Date: Tue, 21 Apr 2026 22:03:50 +0900 Subject: [PATCH 1/5] feat: Add optional registration for content scripts --- docs/guide/essentials/entrypoints.md | 2 +- docs/guide/essentials/scripting.md | 6 ++ .../src/core/utils/__tests__/manifest.test.ts | 98 +++++++++++++++++++ .../core/utils/__tests__/validation.test.ts | 29 +++++- packages/wxt/src/core/utils/manifest.ts | 54 +++++++++- packages/wxt/src/core/utils/validation.ts | 3 +- packages/wxt/src/types.ts | 6 +- .../utils/internal/dev-server-websocket.ts | 2 +- .../virtual/utils/reload-content-scripts.ts | 2 +- 9 files changed, 194 insertions(+), 8 deletions(-) diff --git a/docs/guide/essentials/entrypoints.md b/docs/guide/essentials/entrypoints.md index 4981b26f1..95783b81d 100644 --- a/docs/guide/essentials/entrypoints.md +++ b/docs/guide/essentials/entrypoints.md @@ -288,7 +288,7 @@ export default defineContentScript({ cssInjectionMode: undefined | "manifest" | "manual" | "ui", // Configure how/when content script will be registered - registration: undefined | "manifest" | "runtime", + registration: undefined | "manifest" | "runtime" | "optional", main(ctx: ContentScriptContext) { // Executed when content script is loaded, can be async diff --git a/docs/guide/essentials/scripting.md b/docs/guide/essentials/scripting.md index 468e2c770..7a81babcc 100644 --- a/docs/guide/essentials/scripting.md +++ b/docs/guide/essentials/scripting.md @@ -27,3 +27,9 @@ export default defineContentScript({ }, }); ``` + +## Optional Host Registration + +When using `registration: 'optional'`, WXT adds the script's `matches` to +`optional_host_permissions` instead of `host_permissions`. You must request host +access before registering/executing the script at runtime. diff --git a/packages/wxt/src/core/utils/__tests__/manifest.test.ts b/packages/wxt/src/core/utils/__tests__/manifest.test.ts index 5e0ffbfa3..18788cf0e 100644 --- a/packages/wxt/src/core/utils/__tests__/manifest.test.ts +++ b/packages/wxt/src/core/utils/__tests__/manifest.test.ts @@ -1270,6 +1270,47 @@ describe('Manifest Utils', () => { expect(actual.content_scripts).toEqual([]); expect(actual.host_permissions).toEqual(['*://google.com/*']); }); + + it('should add optional_host_permissions instead of content_scripts when registration=optional', async () => { + const cs: ContentScriptEntrypoint = { + type: 'content-script', + name: 'one', + inputPath: 'entrypoints/one.content.ts', + outputDir: contentScriptOutDir, + options: { + matches: ['*://google.com/*'], + registration: 'optional', + }, + skipped: false, + }; + const styles: OutputAsset = { + type: 'asset', + fileName: 'content-scripts/one.css', + }; + + const entrypoints = [cs]; + const buildOutput: Omit = { + publicAssets: [], + steps: [{ entrypoints: cs, chunks: [styles] }], + }; + setFakeWxt({ + config: { + manifestVersion: 3, + outDir, + command: 'build', + }, + }); + + const { manifest: actual } = await generateManifest( + entrypoints, + buildOutput, + ); + + expect(actual.content_scripts).toEqual([]); + expect(actual.optional_host_permissions).toEqual([ + '*://google.com/*', + ]); + }); }); }); @@ -1859,6 +1900,63 @@ describe('Manifest Utils', () => { }); }); + describe('optional_host_permissions', () => { + it('should keep optional_host_permissions as-is for MV3', async () => { + const expectedOptionalHostPermissions = ['https://google.com/*']; + const expectedOptionalPermissions = ['cookies' as const]; + setFakeWxt({ + config: { + manifest: { + optional_host_permissions: expectedOptionalHostPermissions, + optional_permissions: expectedOptionalPermissions, + }, + manifestVersion: 3, + command: 'build', + }, + }); + const output = fakeBuildOutput(); + + const { manifest: actual } = await generateManifest([], output); + + expect(actual.optional_permissions).toEqual( + expectedOptionalPermissions, + ); + expect(actual.optional_host_permissions).toEqual( + expectedOptionalHostPermissions, + ); + }); + + it('should move optional_host_permissions to optional_permissions for MV2, ignoring duplicates', async () => { + const expectedOptionalPermissions = [ + 'cookies', + 'https://google.com/*', + '*://*.youtube.com/*', + ]; + setFakeWxt({ + config: { + manifest: { + optional_host_permissions: [ + 'https://google.com/*', + 'https://google.com/*', + '*://*.youtube.com/*', + ], + optional_permissions: ['cookies'], + }, + manifestVersion: 2, + command: 'build', + }, + }); + const output = fakeBuildOutput(); + + const { manifest: actual } = await generateManifest([], output); + + expect(actual.optional_permissions).toEqual( + expectedOptionalPermissions, + ); + expect(actual.optional_host_permissions).toBeUndefined(); + }); + }); + describe('Dev mode', () => { it('should not add any code for production builds', async () => { setFakeWxt({ diff --git a/packages/wxt/src/core/utils/__tests__/validation.test.ts b/packages/wxt/src/core/utils/__tests__/validation.test.ts index 24f6fed71..bf9e725b8 100644 --- a/packages/wxt/src/core/utils/__tests__/validation.test.ts +++ b/packages/wxt/src/core/utils/__tests__/validation.test.ts @@ -85,7 +85,7 @@ describe('Validation Utils', () => { { type: 'error', message: - '`matches` is required for manifest registered content scripts', + '`matches` is required for content scripts not registered at runtime', value: null, entrypoint, }, @@ -117,5 +117,32 @@ describe('Validation Utils', () => { expect(actual).toEqual(expected); }); + + it('should return an error when "registration: optional" content scripts don\'t have matches', () => { + const entrypoint = fakeContentScriptEntrypoint({ + options: { + registration: 'optional', + // @ts-expect-error + matches: null, + }, + }); + const expected = { + errors: [ + { + type: 'error', + message: + '`matches` is required for content scripts not registered at runtime', + value: null, + entrypoint, + }, + ], + errorCount: 1, + warningCount: 0, + }; + + const actual = validateEntrypoints([entrypoint]); + + expect(actual).toEqual(expected); + }); }); }); diff --git a/packages/wxt/src/core/utils/manifest.ts b/packages/wxt/src/core/utils/manifest.ts index 24ee00874..b14ee1bff 100644 --- a/packages/wxt/src/core/utils/manifest.ts +++ b/packages/wxt/src/core/utils/manifest.ts @@ -145,6 +145,7 @@ export async function generateManifest( convertActionToMv2(manifest); convertCspToMv2(manifest); moveHostPermissionsToPermissions(manifest); + moveOptionalHostPermissionsToOptionalPermissions(manifest); } if (wxt.config.manifestVersion === 3) { @@ -394,13 +395,21 @@ function addEntrypoints( if (wxt.config.command === 'serve' && wxt.config.manifestVersion === 3) { contentScripts.forEach((script) => { script.options.matches?.forEach((matchPattern) => { - addHostPermission(manifest, matchPattern); + if (script.options.registration === 'optional') { + addOptionalHostPermission(manifest, matchPattern); + } else { + addHostPermission(manifest, matchPattern); + } }); }); } else { // Manifest scripts const hashToEntrypointsMap = contentScripts - .filter((cs) => cs.options.registration !== 'runtime') + .filter( + (cs) => + cs.options.registration !== 'runtime' && + cs.options.registration !== 'optional', + ) .reduce((map, script) => { const hash = hashContentScriptOptions(script.options); if (map.has(hash)) map.get(hash)?.push(script); @@ -432,6 +441,16 @@ function addEntrypoints( addHostPermission(manifest, matchPattern); }); }); + + // Optional runtime content scripts + const optionalContentScripts = contentScripts.filter( + (cs) => cs.options.registration === 'optional', + ); + optionalContentScripts.forEach((script) => { + script.options.matches?.forEach((matchPattern) => { + addOptionalHostPermission(manifest, matchPattern); + }); + }); } const contentScriptCssResources = getContentScriptCssWebAccessibleResources( @@ -615,6 +634,17 @@ function addPermission( manifest.permissions.push(permission); } +function addOptionalPermission( + manifest: Browser.runtime.Manifest, + permission: string, +): void { + manifest.optional_permissions ??= []; + // @ts-expect-error: Allow using strings for permissions for MV2 support + if (manifest.optional_permissions.includes(permission)) return; + // @ts-expect-error: Allow using strings for permissions for MV2 support + manifest.optional_permissions.push(permission); +} + function addHostPermission( manifest: Browser.runtime.Manifest, hostPermission: string, @@ -624,6 +654,15 @@ function addHostPermission( manifest.host_permissions.push(hostPermission); } +function addOptionalHostPermission( + manifest: Browser.runtime.Manifest, + hostPermission: string, +): void { + manifest.optional_host_permissions ??= []; + if (manifest.optional_host_permissions.includes(hostPermission)) return; + manifest.optional_host_permissions.push(hostPermission); +} + /** * - "" → "" * - "_://play.google.com/books/_" → "_://play.google.com/_" @@ -668,6 +707,17 @@ function moveHostPermissionsToPermissions( delete manifest.host_permissions; } +function moveOptionalHostPermissionsToOptionalPermissions( + manifest: Browser.runtime.Manifest, +): void { + if (!manifest.optional_host_permissions?.length) return; + + manifest.optional_host_permissions.forEach((permission: string) => + addOptionalPermission(manifest, permission), + ); + delete manifest.optional_host_permissions; +} + function convertActionToMv2(manifest: Browser.runtime.Manifest): void { if ( manifest.action == null || diff --git a/packages/wxt/src/core/utils/validation.ts b/packages/wxt/src/core/utils/validation.ts index 331fe80a6..5c51d0d91 100644 --- a/packages/wxt/src/core/utils/validation.ts +++ b/packages/wxt/src/core/utils/validation.ts @@ -36,7 +36,8 @@ function validateContentScriptEntrypoint( ) { errors.push({ type: 'error', - message: '`matches` is required for manifest registered content scripts', + message: + '`matches` is required for content scripts not registered at runtime', value: definition.options.matches, entrypoint: definition, }); diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 46561536a..4dfc74f57 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -709,10 +709,14 @@ export interface BaseContentScriptEntrypointOptions extends BaseScriptEntrypoint * - `"runtime"`: The content script's `matches` is added to `host_permissions` * and you are responsible for using the scripting API to register/execute * the content script dynamically at runtime. + * - `"optional"`: The content script's `matches` is added to + * `optional_host_permissions` and you are responsible for requesting access + * and using the scripting API to register/execute the content script at + * runtime. * * @default 'manifest' */ - registration?: PerBrowserOption<'manifest' | 'runtime'>; + registration?: PerBrowserOption<'manifest' | 'runtime' | 'optional'>; } export interface MainWorldContentScriptEntrypointOptions extends BaseContentScriptEntrypointOptions { diff --git a/packages/wxt/src/utils/internal/dev-server-websocket.ts b/packages/wxt/src/utils/internal/dev-server-websocket.ts index 913f6b7e2..313b6a9e7 100644 --- a/packages/wxt/src/utils/internal/dev-server-websocket.ts +++ b/packages/wxt/src/utils/internal/dev-server-websocket.ts @@ -71,7 +71,7 @@ export function getDevServerWebSocket(): WxtWebSocket { } export interface ReloadContentScriptPayload { - registration?: 'manifest' | 'runtime'; + registration?: 'manifest' | 'runtime' | 'optional'; contentScript: { matches: string[]; js?: string[]; diff --git a/packages/wxt/src/virtual/utils/reload-content-scripts.ts b/packages/wxt/src/virtual/utils/reload-content-scripts.ts index 5d3b4363e..17025ca61 100644 --- a/packages/wxt/src/virtual/utils/reload-content-scripts.ts +++ b/packages/wxt/src/virtual/utils/reload-content-scripts.ts @@ -16,7 +16,7 @@ export async function reloadContentScriptMv3({ registration, contentScript, }: ReloadContentScriptPayload) { - if (registration === 'runtime') { + if (registration === 'runtime' || registration === 'optional') { await reloadRuntimeContentScriptMv3(contentScript); } else { await reloadManifestContentScriptMv3(contentScript); From b72dfe63437652dd1b2c36de37f18f80090658d6 Mon Sep 17 00:00:00 2001 From: eupthere Date: Fri, 24 Apr 2026 19:40:35 +0900 Subject: [PATCH 2/5] fix: Update error message for content script validation - Clarified error message for content scripts not registered at runtime. --- packages/wxt/src/core/utils/__tests__/validation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wxt/src/core/utils/__tests__/validation.test.ts b/packages/wxt/src/core/utils/__tests__/validation.test.ts index bf9e725b8..059b3c991 100644 --- a/packages/wxt/src/core/utils/__tests__/validation.test.ts +++ b/packages/wxt/src/core/utils/__tests__/validation.test.ts @@ -85,7 +85,7 @@ describe('Validation Utils', () => { { type: 'error', message: - '`matches` is required for content scripts not registered at runtime', + '`matches` is required for content scripts that are not registered at runtime', value: null, entrypoint, }, @@ -131,7 +131,7 @@ describe('Validation Utils', () => { { type: 'error', message: - '`matches` is required for content scripts not registered at runtime', + '`matches` is required for content scripts that are not registered at runtime', value: null, entrypoint, }, From 884f596f7afc39d14806d7e1802866f9b2626509 Mon Sep 17 00:00:00 2001 From: eupthere Date: Fri, 24 Apr 2026 19:44:51 +0900 Subject: [PATCH 3/5] fix: Update error message for content script validation --- packages/wxt/src/core/utils/validation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wxt/src/core/utils/validation.ts b/packages/wxt/src/core/utils/validation.ts index 5c51d0d91..b51327e03 100644 --- a/packages/wxt/src/core/utils/validation.ts +++ b/packages/wxt/src/core/utils/validation.ts @@ -37,7 +37,7 @@ function validateContentScriptEntrypoint( errors.push({ type: 'error', message: - '`matches` is required for content scripts not registered at runtime', + '`matches` is required for content scripts that are not registered at runtime', value: definition.options.matches, entrypoint: definition, }); From 836004278fcb37e5e8886c33e77a27f17c08e11a Mon Sep 17 00:00:00 2001 From: eupthere Date: Fri, 24 Apr 2026 19:49:07 +0900 Subject: [PATCH 4/5] fix: Update error message for optional content script validation - Clarify the error message for invalid `optional` content scripts without `matches`. --- packages/wxt/src/core/utils/__tests__/validation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wxt/src/core/utils/__tests__/validation.test.ts b/packages/wxt/src/core/utils/__tests__/validation.test.ts index 059b3c991..2c51f5b61 100644 --- a/packages/wxt/src/core/utils/__tests__/validation.test.ts +++ b/packages/wxt/src/core/utils/__tests__/validation.test.ts @@ -122,7 +122,7 @@ describe('Validation Utils', () => { const entrypoint = fakeContentScriptEntrypoint({ options: { registration: 'optional', - // @ts-expect-error + // @ts-expect-error: Testing validation of invalid `optional` content script without `matches` matches: null, }, }); From 8115eacbf78d1e7959309b1650073482353f292e Mon Sep 17 00:00:00 2001 From: eupthere Date: Fri, 24 Apr 2026 20:16:14 +0900 Subject: [PATCH 5/5] fix: Update expected optional permissions type in tests - Use proper type instead of const --- packages/wxt/src/core/utils/__tests__/manifest.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/wxt/src/core/utils/__tests__/manifest.test.ts b/packages/wxt/src/core/utils/__tests__/manifest.test.ts index 158e4fa9b..c4550924f 100644 --- a/packages/wxt/src/core/utils/__tests__/manifest.test.ts +++ b/packages/wxt/src/core/utils/__tests__/manifest.test.ts @@ -1914,7 +1914,8 @@ describe('Manifest Utils', () => { describe('optional_host_permissions', () => { it('should keep optional_host_permissions as-is for MV3', async () => { const expectedOptionalHostPermissions = ['https://google.com/*']; - const expectedOptionalPermissions = ['cookies' as const]; + const expectedOptionalPermissions: Browser.runtime.ManifestOptionalPermission[] = + ['cookies']; setFakeWxt({ config: { manifest: {