feat: type /_favicon/ paths when favicon permission is declared (#2241)

Co-authored-by: oferitz <oferitz@Ofers-MacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ofer Itzhaki
2026-04-11 20:33:55 +03:00
committed by GitHub
parent ba96bbd8ff
commit fa698266b3
6 changed files with 138 additions and 1 deletions
+1
View File
@@ -41,6 +41,7 @@ words:
- esbuild
- esque
- extglob
- favicons
- fieldtrials
- firefoxdeveloperedition
- Florian
+1
View File
@@ -224,6 +224,7 @@ export default defineConfig({
menuItem('Storage', 'storage.md'),
menuItem('Messaging', 'messaging.md'),
menuItem('I18n', 'i18n.md'),
menuItem('Favicons', 'favicons.md'),
menuItem('Scripting', 'scripting.md'),
menuItem('WXT Modules', 'wxt-modules.md'),
menuItem('Frontend Frameworks', 'frontend-frameworks.md'),
+51
View File
@@ -0,0 +1,51 @@
# Favicons
[Chrome Docs](https://developer.chrome.com/docs/extensions/how-to/ui/favicons)
Chromium-based browsers expose cached favicons through the `_favicon/` URL served from your extension. To use it, declare the `favicon` permission in your manifest and call `browser.runtime.getURL` with a `_favicon/` path:
```ts
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
permissions: ['favicon'],
},
});
```
```ts
// Any entrypoint
function getFaviconUrl(pageUrl: string, size = 16) {
const url = new URL(browser.runtime.getURL('/_favicon/'));
url.searchParams.set('pageUrl', pageUrl);
url.searchParams.set('size', String(size));
return url.toString();
}
```
::: warning Chromium only
The favicon API is only available on Chromium-based browsers. Firefox has no equivalent, so `/_favicon/` URLs will not resolve at runtime there. If your extension supports both browsers, gate favicon usage behind `import.meta.env.CHROME` (or similar) and declare the permission per-browser.
:::
## Usage from a content script
WXT does **not** add a `web_accessible_resources` entry for `_favicon/*` — not every extension needs one, and adding it unconditionally would expose internals to sites that don't need them. If you want to load a favicon inside a content script (for example, as an `<img src>`), add your own entry in `wxt.config.ts`:
```ts
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
permissions: ['favicon'],
web_accessible_resources: [
{
resources: ['_favicon/*'],
matches: ['<all_urls>'],
},
],
},
});
```
@@ -420,6 +420,48 @@ describe('TypeScript Project', () => {
expect(output).toContain('./example.ts');
});
it('should type `/_favicon/` paths when the `favicon` permission is declared', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', '<html></html>');
await project.build({
manifest: { permissions: ['favicon'] },
});
const pathsOutput = await project.serializeFile('.wxt/types/paths.d.ts');
const manifest = await project.getOutputManifest();
// The generated type must be a template-literal (backticks), not a
// string-literal — otherwise query params (`?pageUrl=...&size=...`)
// would not type-check.
expect(pathsOutput).toMatchInlineSnapshot(`
".wxt/types/paths.d.ts
----------------------------------------
// Generated by wxt
import "wxt/browser";
declare module "wxt/browser" {
export type PublicPath =
| ""
| "/"
| "/popup.html"
| \`/_favicon/\${string}\`
type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
export interface WxtRuntime {
getURL(path: PublicPath): string;
getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
}
}
"
`);
// Per the review on #1570, WXT intentionally does NOT add a
// `web_accessible_resources` entry for `_favicon/*`: that's only
// needed for content-script usage, and users who need it can add
// it themselves.
expect(manifest.web_accessible_resources).toBeUndefined();
});
it('should set correct import.meta.env.BROWSER type based on targetBrowsers', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html', '<html></html>');
@@ -0,0 +1,41 @@
import { defineWxtModule } from '../modules';
import type { WxtDirFileEntry } from '../types';
/**
* Adds a template-literal type for the `_favicon/` paths served by Chrome's
* favicon API when the `favicon` permission is declared. With this module,
* `browser.runtime.getURL('/_favicon/?pageUrl=...')` type-checks without
* needing a `@ts-expect-error`.
*
* Extensions that load favicons from a content script must still add their own
* `web_accessible_resources` entry — this module intentionally does not touch
* the manifest. See the review thread on #1570 for context.
*
* @see https://developer.chrome.com/docs/extensions/how-to/ui/favicons
*/
export default defineWxtModule({
name: 'wxt:built-in:favicon-permission',
setup(wxt) {
wxt.hooks.hook('prepare:types', (_, entries) => {
if (!wxt.config.manifest.permissions?.includes('favicon')) return;
const pathsEntry = entries.find(
(entry): entry is WxtDirFileEntry =>
'path' in entry && entry.path === 'types/paths.d.ts',
);
if (!pathsEntry) return;
// The base generator wraps every public path in double quotes,
// producing string-literal union members. Favicon URLs need a
// template-literal type so arbitrary query strings type-check, so
// we splice a `\`/_favicon/\${string}\`` member into the union
// directly. The `HtmlPublicPath` line is a stable anchor emitted
// by generate-wxt-dir.ts:getPathsDeclarationEntry.
const anchor = ' type HtmlPublicPath';
pathsEntry.text = pathsEntry.text.replace(
anchor,
' | `/_favicon/${string}`\n' + anchor,
);
});
},
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { WxtModule } from '../types';
import faviconPermission from './favicon-permission';
import unimport from './unimport';
export const builtinModules: WxtModule<any>[] = [unimport];
export const builtinModules: WxtModule<any>[] = [unimport, faviconPermission];