Compare commits

...

95 Commits

Author SHA1 Message Date
Aaron b99da14799 feat(runner): Create new @wxt-dev/runner package (#1566)
📼 VHS / Create VHS (push) Waiting to run
2025-06-02 15:01:59 -05:00
aklinker1 f02400a1fb fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-06-01 02:20:17 +00:00
aklinker1 bbcb7f7cdd fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-05-31 01:53:46 +00:00
aklinker1 30cf8e6d54 fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-05-30 01:54:08 +00:00
aklinker1 4f772fc0f7 fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-05-17 01:52:17 +00:00
Ahmed Rangel 2cf2582138 docs: Added "NetSuite Record Scripts", "VueTracker" to the homepage (#1665)
Co-authored-by: Florian Metz <me@timeraa.dev>
2025-05-14 12:30:19 +02:00
Stepan Rabotkin ad4de4b92c docs: Added CanCopy to homepage (#1667)
Co-authored-by: Florian Metz <me@timeraa.dev>
2025-05-14 12:23:48 +02:00
MengXi 27ed73a6ae docs: Added 'Read Frog' extension to homepage (#1666) 2025-05-14 12:17:18 +02:00
Qiwei Yang a1570c8917 docs: Added "Bilibili Feed History Extension" to the homepage (#1669)
Co-authored-by: Florian Metz <me@timeraa.dev>
2025-05-14 11:59:01 +02:00
ТΞNSΛI 7e419ddd43 docs: Added "NZBDonkey" to the homepage (#1670)
Co-authored-by: Florian Metz <me@timeraa.dev>
2025-05-14 11:54:59 +02:00
Honwhy Wang b045d3616d docs: Added "WeChat Markdown Editor" to the homepage (#1671)
Co-authored-by: Florian Metz <me@timeraa.dev>
2025-05-14 11:53:48 +02:00
𝖆𝖉𝖎𝖙𝖍𝐲𝖆 f6b50e2e77 docs: Added Tab Grab to the homepage (#1672) 2025-05-14 11:45:16 +02:00
aklinker1 9d1381cbb3 fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-05-13 01:55:49 +00:00
aklinker1 99b0242d86 fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-05-10 01:49:30 +00:00
aklinker1 db27628211 fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-05-06 01:53:55 +00:00
aklinker1 9b9d86229a fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-05-05 01:57:09 +00:00
Boris Zabolotskikh 46f830dacf docs: Added "Web to PDF", "Online CSV Viewer", "YouTube Video Transcript" to the homepage (#1655)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-05-04 19:33:35 -05:00
github-actions[bot] 3dd9bc7772 chore(release): @wxt-dev/i18n v0.2.4
📼 VHS / Create VHS (push) Waiting to run
2025-05-04 12:27:20 +00:00
Aaron c7335add19 chore: Change browser workspace dependency to ^ 2025-05-04 07:21:55 -05:00
Aaron c133498208 chore(deps): Update all dependencies (#1648) 2025-05-02 08:53:38 -05:00
Aaron 6a52bb22a3 fix: Use @wxt-dev/browser instead of @types/chrome (#1645) 2025-05-02 08:41:02 -05:00
Aaron 6f970efda5 chore: Upgrade @aklinker1/check to v2 (#1647) 2025-05-02 08:36:46 -05:00
Aaron c69350a0eb fix: Fix bad links to developer.chrome.com 2025-05-02 07:10:25 -05:00
Aaron 4add8820eb chore: Stop using PNPM catalog (#1644) 2025-05-02 06:26:12 -05:00
Aaron 033dd4811e chore(deps): Upgrade dev dependencies (#1642) 2025-05-02 05:52:06 -05:00
Aaron da5cd32502 fix: Improve CSS reset inside shadow roots 2025-05-02 05:14:42 -05:00
aklinker1 edf33fdec9 fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-05-02 01:53:07 +00:00
seaders 448cbf16c9 feat: Add @font-face to be processed by splitShadowRootCss (#1635) 2025-05-01 12:49:54 -05:00
Aaron e45b77b528 chore(deps): Upgrade vite and related dependencies 2025-05-01 12:25:56 -05:00
github-actions[bot] b61fd1716e chore(release): wxt v0.20.6
📼 VHS / Create VHS (push) Waiting to run
2025-04-30 16:22:57 +00:00
Aaron d584b5cc7e chore(deps): Upgrade web-ext-run to v0.2.3 (#1634) 2025-04-30 11:18:30 -05:00
Bang·_· faf9cedfbd docs: Added "[DesignPicker]" to the homepage (#1624) 2025-04-27 07:24:00 -05:00
Aaron 3a38ac9339 Fix changelog contributors 2025-04-26 18:07:17 -05:00
github-actions[bot] 7ea36a3cfc chore(release): wxt v0.20.5
📼 VHS / Create VHS (push) Waiting to run
2025-04-26 13:53:46 +00:00
Aaron 3577c0b47a fix: Don't use crypto.randUUID for shadow root UIs 2025-04-26 08:48:42 -05:00
Aaron d0bef42186 fix: Standardize locale codes and warn about unsupported ones (#1617) 2025-04-26 08:03:22 -05:00
github-actions[bot] d9f10c62ad chore(release): wxt v0.20.4
📼 VHS / Create VHS (push) Waiting to run
2025-04-25 03:03:08 +00:00
aklinker1 35d3c03e4b fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-04-25 01:52:10 +00:00
Yunsup Sim fdd38a1580 fix: Fix CORS error in Firefox (#1607) 2025-04-24 19:01:30 -05:00
ТΞNSΛI 3018801f06 feat: add {{packageVersion}} as template variable (#1604) 2025-04-24 12:06:50 -05:00
Anh71me 8e96bfef06 fix: fix typescript error on defineItem fallback (#1601) 2025-04-24 07:31:48 -05:00
ТΞNSΛI 31071bd11e feat: ignore elements with a vite-ignore or wxt-ignore attribute (#1603)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-04-24 00:34:49 -05:00
Yuan f5619f6be1 chore: Move the public folder outside of src for the default Svelte template (#1602) 2025-04-24 00:26:34 -05:00
Aaron adad1b5a2c chore: Fix formatting 2025-04-24 00:24:49 -05:00
Nishu a6c4e19a5d fix: adding missing "" to PublicPath and browser.runtime.getUrl (#1597) 2025-04-24 00:18:06 -05:00
Jack af6f74c344 docs: Added "[Always Light Mode]" to the homepage (#1608) 2025-04-24 00:05:37 -05:00
Aaron ce45cb8a89 docs: Add warning about --load-extension deprecation 2025-04-23 23:35:28 -05:00
aklinker1 14d4aa202d fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-04-23 01:51:01 +00:00
Nishu 4c35798dba feat: Ignore popup/index.ts instead of erroring (#1520)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-04-20 21:17:05 -05:00
Aaron 656a9b365f docs(Content Script UI): Add additional details about when onRemove is called 2025-04-20 21:15:08 -05:00
Namu 67fa3db921 docs: Fix import in unit-testing.md (#1598) 2025-04-20 20:36:06 -05:00
github-actions[bot] f2d3061e97 chore(release): wxt v0.20.3
📼 VHS / Create VHS (push) Waiting to run
2025-04-19 17:17:39 +00:00
Aaron 760c34e416 feat: Automatically place document-level CSS outside shadow root (#1594) 2025-04-19 12:12:04 -05:00
Bang·_· 48398b315c docs: Added "[Blens - Time Tracker and AI Insight]" to the homepage (#1587) 2025-04-18 21:53:19 -05:00
techlism 45d0d9d7f1 docs: Fix entrypoints.md examples (#1586) 2025-04-18 21:11:04 -05:00
Aaron b0f4ac8221 fix: Fix double hashing of inline script keys
Additionally, update the vite plugin name to better align with purpose
2025-04-18 20:50:02 -05:00
github-actions[bot] 719192b1aa chore(release): wxt v0.20.2
📼 VHS / Create VHS (push) Waiting to run
2025-04-19 00:40:32 +00:00
Yunsup Sim 7eb32bdac8 fix: Fix hashing issue with inline scripts (#1591) 2025-04-18 19:35:18 -05:00
nostro 862756fd5b chore: update docs to point to webExt instead of runner; update config in demo (#1582) 2025-04-16 09:42:55 -05:00
nostro 5ba16e72e0 docs: add radiofrance extension to usingWXT (#1581) 2025-04-16 09:42:20 -05:00
Aaron e43ae0504d docs: Add filenames to startup examples 2025-04-16 08:09:46 -05:00
Aaron acb6cd180c docs: Fix typo in changelog 2025-04-14 17:24:10 -05:00
github-actions[bot] a3301d0413 chore(release): wxt v0.20.1
📼 VHS / Create VHS (push) Waiting to run
2025-04-14 13:29:52 +00:00
7sDream b9e72358ac feat: type-safe import.meta.env.BROWSER with new targetBrowers config (#1574)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-04-14 08:25:04 -05:00
Aaron 61b42ef326 chore: Update comment 2025-04-13 08:55:14 -05:00
aklinker1 da9e27c28d fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-04-12 01:46:11 +00:00
aklinker1 6f80fbabaa fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-04-10 01:48:29 +00:00
Aaron d2308bd45c chore(deps): Update all dependencies (#1568) 2025-04-09 09:46:29 -05:00
nostro d35972d24f fix: add JSDoc type annotation to auto-imports for ESlint (#1558) 2025-04-08 18:10:58 -05:00
Aaron 298a264934 docs: Add not about viewing available options for wxt submit 2025-04-08 17:58:53 -05:00
Khalil Yao ad63b595b4 fix: Don't remove top-level destructured variable definitions when importing entrypoints (#1561) 2025-04-05 11:25:15 -05:00
aklinker1 986a9ceebf fix: Upgrade @wxt-dev/browser to latest @types/chrome version 2025-04-02 01:49:03 +00:00
dependabot[bot] 682b65e723 chore(deps): bump @types/node from 20.17.6 to 20.17.30 (#1552)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 16:06:04 -05:00
dependabot[bot] cb661506c0 chore(deps): bump fast-glob from 3.3.2 to 3.3.3 (#1556)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 16:04:57 -05:00
dependabot[bot] 505aa06443 chore(deps): bump @commitlint/config-conventional from 19.7.1 to 19.8.0 (#1555)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 16:04:32 -05:00
Aaron f534a978f1 chore: Upgrade templates to wxt 0.20 (#1551) 2025-03-31 18:22:53 -05:00
Aaron 1293815934 docs: Update unocss import to match latest requirements 2025-03-31 16:17:22 -05:00
Aaron 0c0fd18bf4 docs: Split major and minor version update steps apart 2025-03-31 15:59:03 -05:00
Aaron c49c31813f docs: Add description of modules directory to project structure summaries 2025-03-31 14:24:02 -05:00
Aaron 877ab11bb4 docs: Fix knowledge file generation (#1550) 2025-03-31 13:54:04 -05:00
Alec WM 0d18d8d7e3 feat: enable wxt usage inside of devcontainers (#1406)
Co-authored-by: Aaron <aaronklinker1@gmail.com>
2025-03-31 09:21:56 -05:00
Aaron 5f977857a1 chore: Fix formatting 2025-03-29 09:50:26 -05:00
Aaron a63b21d417 docs: Fix diff highlights after auto-formatting 2025-03-29 09:45:01 -05:00
Aaron e1b7d6090e docs: Refresh entrypoints page with more in-depth examples and documentation 2025-03-28 20:08:53 -05:00
Aaron 1bcc5cdd81 docs: Add link to comments on blog post 2025-03-28 11:44:54 -05:00
Aaron 873fdbc4b3 Update changelogs 2025-03-28 11:10:14 -05:00
Aaron 79128effea ci: Update browser package commit message 2025-03-28 11:06:06 -05:00
Aaron 14ef94fc09 ci: Fix browser sync workflow 2025-03-28 11:05:16 -05:00
aklinker1 759e3e3a4d fix: Upgrade \@wxt-dev/browser\ to latest \@types/chrome\ version 2025-03-28 16:01:49 +00:00
Aaron 5b7b16efba fix(browser): Install latest version of @types/chrome 2025-03-28 11:01:05 -05:00
aklinker1 7fd171acb5 fix: Upgrade \@wxt-dev/browser\ to latest \@types/chrome\ version 2025-03-28 15:59:43 +00:00
Aaron 02ed3f1d44 chore: Fix type check script 2025-03-28 10:58:21 -05:00
Aaron 4f7f3f36a3 chore: Fix type check script 2025-03-28 10:58:09 -05:00
Aaron 73a8cf12a9 docs: Use nicer project structure for v0.20 directory changes 2025-03-28 10:47:36 -05:00
Aaron 5f5eb16705 docs: Add redirect for old upgrade guide 2025-03-28 10:41:24 -05:00
122 changed files with 5721 additions and 3748 deletions
+1
View File
@@ -14,6 +14,7 @@ on:
- module-solid
- module-svelte
- module-vue
- runner
- storage
- unocss
- webextension-polyfill
+1
View File
@@ -14,6 +14,7 @@ on:
- module-solid
- module-svelte
- module-vue
- runner
- storage
- webextension-polyfill
- wxt
+1 -2
View File
@@ -36,8 +36,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
commit_message: 'fix: Upgrade \`@wxt-dev/browser\` to latest \`@types/chrome\` version'
file_pattern: 'packages/browser/**'
commit_message: 'fix: Upgrade `@wxt-dev/browser` to latest `@types/chrome` version'
- name: Publish Package
if: steps.commit.outputs.changes_detected == 'true'
+2
View File
@@ -5,8 +5,10 @@
.output
.webextrc
.wxt
.wxt-runner
*.log
/docs/.vitepress/cache
docs/.vitepress/.temp
coverage
dist
node_modules
@@ -70,6 +70,21 @@ const chromeExtensionIds = [
'hafcajcllbjnoolpfngclfmmgpikdhlm', // Monochromate
'bmoggiinmnodjphdjnmpcnlleamkfedj', // AliasVault - Open-Source Password & (Email) Alias Manager
'hlnhhamckimoaiekbglafiebkfimhapb', // SnapThePrice: AI-Powered Real-time Lowest Price Finder
'gdjampjdgjmbifnhldgcnccdjkcoicmg', // radiofrance - news & broadcasts (French), music (international)
'jlnhphlghikichhgbnkepenehbmloenb', // Blens - Time Tracker and AI Insight
'njnammmpdodmfkodnfpammnpdcbhnlcm', // Always Light Mode - Setting website always in light mode
'lblmfclcfniabobmamfkdogcgdagbhhb', // DesignPicker - Color Picker & Font Detector
'pamnlaoeobcmhkliljfaofekeddpmfoh', // Web to PDF
'jmbcbeepjfenihlocplnbmbhimcoooka', // Online CSV Viewer
'nkjcoophmpcmmgadnljnlpbpfdfacgbo', // YouTube Video Transcript
'lcaieahkjgeggeiihblhcjbbjlppgieh', // NetSuite Record Scripts
'gmocfknjllodfiomnljmaehcplnekhlo', // VueTracker
'ggcfemmoabhhelfkhknhbnkmeahloiod', // CanCopy - A web extension that allow you to copy any content from website
'modkelfkcfjpgbfmnbnllalkiogfofhb', // Language Learning with AI
'npfopljnjbamegincfjelhjhnonnjloo', // Bilibili Feed History Helper
'edkhpdceeinkcacjdgebjehipmnbomce', // NZBDonkey - The ultimate NZB file download tool
'cckggnbnimdbbpmdinkkgbbncopbloob', // WeChat Markdown Editor(微信 Markdown 编辑器)
'jcblcjolcojmfopefcighfmkkefbaofg', // Tab Grab
];
const { data, err, isLoading } = useListExtensionDetails(chromeExtensionIds);
+29 -14
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vitepress';
import { DefaultTheme, defineConfig } from 'vitepress';
import typedocSidebar from '../api/reference/typedoc-sidebar.json';
import {
menuGroup,
@@ -15,7 +15,8 @@ import { version as autoIconsVersion } from '../../packages/auto-icons/package.j
import { version as unocssVersion } from '../../packages/unocss/package.json';
import { version as storageVersion } from '../../packages/storage/package.json';
import { version as analyticsVersion } from '../../packages/analytics/package.json';
import knowledge from 'vitepress-knowledge';
import { version as runnerVersion } from '../../packages/runner/package.json';
import addKnowledge from 'vitepress-knowledge';
import {
groupIconMdPlugin,
groupIconVitePlugin,
@@ -41,20 +42,28 @@ const otherPackages = {
i18n: i18nVersion,
storage: storageVersion,
unocss: unocssVersion,
runner: runnerVersion,
};
const knowledge = addKnowledge<DefaultTheme.Config>({
serverUrl: 'https://knowledge.wxt.dev',
paths: {
'/': 'docs',
'/api/': 'api-reference',
'/blog/': 'blog',
},
layoutSelectors: {
blog: '.container-content',
},
pageSelectors: {
'examples.md': '#VPContent > .VPPage',
'blog.md': '#VPContent > .VPPage',
},
});
// https://vitepress.dev/reference/site-config
export default defineConfig({
extends: knowledge({
serverUrl: 'https://knowledge.wxt.dev',
paths: {
'/': 'docs',
'/api/': 'api-reference',
},
pageSelectors: {
'examples.md': '#VPContent > .VPPage',
},
}),
extends: knowledge,
titleTemplate: `:title${titleSuffix}`,
title: 'WXT',
@@ -78,6 +87,9 @@ export default defineConfig({
},
async buildEnd(site) {
// @ts-expect-error: knowledge.buildEnd is not typed, but it exists.
await knowledge.buildEnd(site);
// Only construct the RSS document for production builds
const { default: blogDataLoader } = await import('./loaders/blog.data');
const posts = await blogDataLoader.load();
@@ -95,8 +107,8 @@ export default defineConfig({
description: post.frontmatter.description,
});
});
console.log('rss.xml:');
console.log(feed.rss2());
// console.log('rss.xml:');
// console.log(feed.rss2());
await writeFile(join(site.outDir, 'rss.xml'), feed.rss2(), 'utf8');
},
@@ -118,6 +130,9 @@ export default defineConfig({
md.use(footnote);
md.use(groupIconMdPlugin);
},
languageAlias: {
mjs: 'js',
},
},
themeConfig: {
@@ -70,3 +70,7 @@ You don't have to use `#imports` if you don't like - you can continue importing
Happy Coding 😄
> P.S. Yes, this is exactly how [Nuxt's `#imports`](https://nuxt.com/docs/guide/concepts/auto-imports#explicit-imports) works! We use the exact same library, [`unimport`](https://github.com/unjs/unimport).
---
[Discuss this blog post on Github](https://github.com/wxt-dev/wxt/discussions/1543).
@@ -8,13 +8,23 @@ outline: deep
During development, WXT uses [`web-ext` by Mozilla](https://www.npmjs.com/package/web-ext) to automatically open a browser window with your extension installed.
:::danger
Chrome 137 removed support for the `--load-extension` CLI flag, which WXT relied on to open the browser with an extension installed. So this feature will not work for Chrome.
You have two options:
1. Install [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) (which still supports the `--load-extension` flag) and [point the `chrome` binary to it](#set-browser-binaries), or
2. [Disable this feature](#disable-opening-browser) and manually load your extension
:::
## Config Files
You can configure browser startup in 3 places:
1. `<rootDir>/web-ext.config.ts`: Ignored from version control, this file lets you configure your own options for a specific project without affecting other developers
```ts
```ts [web-ext.config.ts]
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
@@ -22,7 +32,7 @@ You can configure browser startup in 3 places:
});
```
2. `<rootDir>/wxt.config.ts`: Via the [`runner` config](/api/reference/wxt/interfaces/InlineConfig#runner), included in version control
2. `<rootDir>/wxt.config.ts`: Via the [`webExt` config](/api/reference/wxt/interfaces/InlineConfig#webext), included in version control
3. `$HOME/web-ext.config.ts`: Provide default values for all WXT projects on your computer
## Recipes
@@ -31,7 +41,7 @@ You can configure browser startup in 3 places:
To set or customize the browser opened during development:
```ts
```ts [web-ext.config.ts]
export default defineWebExtConfig({
binaries: {
chrome: '/path/to/chrome-beta', // Use Chrome Beta instead of regular Chrome
@@ -81,7 +91,7 @@ You can use any directory you'd like for `--user-data-dir`, the examples above c
If you prefer to load the extension into your browser manually, you can disable the auto-open behavior:
```ts
```ts [web-ext.config.ts]
export default defineWebExtConfig({
disabled: true,
});
@@ -42,6 +42,8 @@ WXT provides some custom environment variables based on the current command:
| `import.meta.env.EDGE` | `boolean` | Equivalent to `import.meta.env.BROWSER === "edge"` |
| `import.meta.env.OPERA` | `boolean` | Equivalent to `import.meta.env.BROWSER === "opera"` |
You can set the [`targetBrowsers`](/api/reference/wxt/interfaces/InlineConfig#targetbrowsers) option to make the `BROWSER` variable a more specific type, like `"chrome" | "firefox"`.
You can also access all of [Vite's environment variables](https://vite.dev/guide/env-and-mode.html#env-variables):
| Usage | Type | Description |
+193 -69
View File
@@ -4,50 +4,30 @@ outline: deep
# Entrypoints
WXT uses the files inside the `entrypoints/` directory as inputs when bundling your extension. They can be HTML, JS, CSS, or any variant of those file types supported by Vite (Pug, TS, JSX, SCSS, etc).
WXT uses the files inside the `entrypoints/` directory as inputs when bundling your extension. They can be HTML, JS, CSS, or any variant of those file types supported by Vite (TS, JSX, SCSS, etc).
Here's an example set of entrypoints:
## Folder Structure
Inside the `entrypoints/` directory, an entrypoint is defined as a single file or directory (with an `index` file) inside it.
:::code-group
<!-- prettier-ignore -->
```html
```html [Single File]
📂 entrypoints/
📂 popup/
📄 index.html
📄 main.ts
📄 style.css
📄 background.ts
📄 content.ts
📄 {name}.{ext}
```
[[toc]]
<!-- prettier-ignore -->
```html [Directory]
📂 entrypoints/
📂 {name}/
📄 index.{ext}
```
## Listed vs Unlisted
For web extensions, there are two types of entrypoints:
- **Listed**: Referenced in the `manifest.json`
- **Unlisted**: Not referenced in the `manifest.json`
Throughout the rest of WXT's documentation, listed entrypoints are referred to by name. For example:
- Popup
- Options
- Background
- Content Scripts
- Etc.
Some examples of "unlisted" entrypoints:
- A welcome page shown when the extension is installed
- JS files injected by content scripts into the page's main world
:::tip
Regardless of whether an entrypoint is listed or unlisted, it will still be bundled into your extension and be available at runtime.
:::
## Adding Entrypoints
An entrypoint can be defined as a single file or directory with an `index` file inside it.
The entrypoint's `name` dictates the type of entrypoint. For example, to add a ["Background" entrypoint](#background), either of these files would work:
:::code-group
@@ -66,10 +46,96 @@ An entrypoint can be defined as a single file or directory with an `index` file
:::
The entrypoint's name dictates the type of entrypoint, listed vs unlisted. In this example, "background" is the name of the ["Background" entrypoint](#background).
Refer to the [Entrypoint Types](#entrypoint-types) section for the full list of listed entrypoints and their filename patterns.
### Including Other Files
When using an entrypoint directory, `entrypoints/{name}/index.{ext}`, you can add related files next to the `index` file.
<!-- prettier-ignore -->
```html
📂 entrypoints/
📂 popup/
📄 index.html ← This file is the entrypoint
📄 main.ts
📄 style.css
📂 background/
📄 index.ts ← This file is the entrypoint
📄 alarms.ts
📄 messaging.ts
📂 youtube.content/
📄 index.ts ← This file is the entrypoint
📄 style.css
```
:::danger
**DO NOT** put files related to an entrypoint directly inside the `entrypoints/` directory. WXT will treat them as entrypoints and try to build them, usually resulting in an error.
Instead, use a directory for that entrypoint:
<!-- prettier-ignore -->
```html
📂 entrypoints/
📄 popup.html <!-- [!code --] -->
📄 popup.ts <!-- [!code --] -->
📄 popup.css <!-- [!code --] -->
📂 popup/ <!-- [!code ++] -->
📄 index.html <!-- [!code ++] -->
📄 main.ts <!-- [!code ++] -->
📄 style.css <!-- [!code ++] -->
```
:::
### Deeply Nested Entrypoints
While the `entrypoints/` directory might resemble the `pages/` directory of other web frameworks, like Nuxt or Next.js, **it does not support deeply nesting entrypoints** in the same way.
Entrypoints must be zero or one levels deep for WXT to discover and build them:
<!-- prettier-ignore -->
```html
📂 entrypoints/
📂 youtube/ <!-- [!code --] -->
📂 content/ <!-- [!code --] -->
📄 index.ts <!-- [!code --] -->
📄 ... <!-- [!code --] -->
📂 injected/ <!-- [!code --] -->
📄 index.ts <!-- [!code --] -->
📄 ... <!-- [!code --] -->
📂 youtube.content/ <!-- [!code ++] -->
📄 index.ts <!-- [!code ++] -->
📄 ... <!-- [!code ++] -->
📂 youtube-injected/ <!-- [!code ++] -->
📄 index.ts <!-- [!code ++] -->
📄 ... <!-- [!code ++] -->
```
## Unlisted Entrypoints
In web extensions, there are two types of entrypoints:
1. **Listed**: Referenced in the `manifest.json`
2. **Unlisted**: Not referenced in the `manifest.json`
Throughout the rest of WXT's documentation, listed entrypoints are referred to by name. For example:
- Popup
- Options
- Background
- Content Script
However, not all entrypoints in web extensions are listed in the manifest. Some are not listed in the manifest, but are still used by extensions. For example:
- A welcome page shown in a new tab when the extension is installed
- JS files injected by content scripts into the main world
For more details on how to add unlisted entrypoints, see:
- [Unlisted Pages](#unlisted-pages)
- [Unlisted Scripts](#unlisted-scripts)
- [Unlisted CSS](#unlisted-css)
## Defining Manifest Options
Most listed entrypoints have options that need to be added to the `manifest.json`. However with WXT, instead of defining the options in a separate file, _you define these options inside the entrypoint file itself_.
@@ -106,8 +172,6 @@ When building your extension, WXT will look at the options defined in your entry
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/manifest/background/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/background)
For MV2, the background is added as a script to the background page. For MV3, the background becomes a service worker.
<EntrypointPatterns
:patterns="[
['background.[jt]s', 'background.js'],
@@ -141,6 +205,25 @@ export default defineBackground({
:::
For MV2, the background is added as a script to the background page. For MV3, the background becomes a service worker.
When defining your background entrypoint, keep in mind that WXT will import this file in a NodeJS environment during the build process. That means you cannot place any runtime code outside the `main` function.
<!-- prettier-ignore -->
```ts
browser.action.onClicked.addListener(() => { // [!code --]
// ... // [!code --]
}); // [!code --]
export default defineBackground(() => {
browser.action.onClicked.addListener(() => { // [!code ++]
// ... // [!code ++]
}); // [!code ++]
});
```
Refer to the [Entrypoint Loaders](/guide/essentials/config/entrypoint-loaders) documentation for more details.
### Bookmarks
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/override/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/chrome_url_overrides)
@@ -169,18 +252,18 @@ export default defineBackground({
</html>
```
When you define a Bookmarks entrypoint, WXT will automatically update the manifest to override the browser's bookmarks page with your own HTML page.
### Content Scripts
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/content_scripts/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts)
See [Content Script UI](/guide/essentials/content-scripts) for more info on creating UIs and including CSS in content scripts.
<EntrypointPatterns
:patterns="[
['content.[jt]sx?', 'content-scripts/content.js'],
['content/index.[jt]sx?', 'content-scripts/content.js'],
['<name>.content.[jt]sx?', 'content-scripts/<name>.js'],
['<name>.content/index.[jt]sx?', 'content-scripts/<name>.js'],
['{name}.content.[jt]sx?', 'content-scripts/{name}.js'],
['{name}.content/index.[jt]sx?', 'content-scripts/{name}.js'],
]"
/>
@@ -213,12 +296,29 @@ export default defineContentScript({
});
```
When defining content script entrypoints, keep in mind that WXT will import this file in a NodeJS environment during the build process. That means you cannot place any runtime code outside the `main` function.
<!-- prettier-ignore -->
```ts
browser.runtime.onMessage.addListener((message) => { // [!code --]
// ... // [!code --]
}); // [!code --]
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message) => { // [!code ++]
// ... // [!code ++]
}); // [!code ++]
});
```
Refer to the [Entrypoint Loaders](/guide/essentials/config/entrypoint-loaders) documentation for more details.
See [Content Script UI](/guide/essentials/content-scripts) for more info on creating UIs and including CSS in content scripts.
### Devtools
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/devtools/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/devtools_page)
Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/examples/devtools-extension#readme) to add different panels and panes.
<EntrypointPatterns
:patterns="[
['devtools.html', 'devtools.html'],
@@ -242,6 +342,8 @@ Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/exam
</html>
```
Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/examples/devtools-extension#readme) to add different panels and panes.
### History
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/override/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/chrome_url_overrides)
@@ -270,6 +372,8 @@ Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/exam
</html>
```
When you define a History entrypoint, WXT will automatically update the manifest to override the browser's history page with your own HTML page.
### Newtab
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/override/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/chrome_url_overrides)
@@ -298,6 +402,8 @@ Follow the [Devtools Example](https://github.com/wxt-dev/examples/tree/main/exam
</html>
```
When you define a Newtab entrypoint, WXT will automatically update the manifest to override the browser's new tab page with your own HTML page.
### Options
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/options/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui)
@@ -387,8 +493,8 @@ Firefox does not support sandboxed pages.
:patterns="[
['sandbox.html', 'sandbox.html'],
['sandbox/index.html', 'sandbox.html'],
['<name>.sandbox.html', '<name>.html'],
['<name>.sandbox/index.html', '<name>.html'],
['{name}.sandbox.html', '{name}.html'],
['{name}.sandbox/index.html', '{name}.html'],
]"
/>
@@ -414,14 +520,12 @@ Firefox does not support sandboxed pages.
[Chrome Docs](https://developer.chrome.com/docs/extensions/reference/sidePanel/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/user_interface/Sidebars)
In Chrome, side panels use the `side_panel` API, while Firefox uses the `sidebar_action` API.
<EntrypointPatterns
:patterns="[
['sidepanel.html', 'sidepanel.html'],
['sidepanel/index.html', 'sidepanel.html'],
['<name>.sidepanel.html', '<name>.html` '],
['<name>.sidepanel/index.html', '<name>.html` '],
['{name}.sidepanel.html', '{name}.html` '],
['{name}.sidepanel/index.html', '{name}.html` '],
]"
/>
@@ -455,20 +559,18 @@ In Chrome, side panels use the `side_panel` API, while Firefox uses the `sidebar
</html>
```
In Chrome, side panels use the `side_panel` API, while Firefox uses the `sidebar_action` API.
### Unlisted CSS
Follow Vite's guide to setup your preprocessor of choice: https://vitejs.dev/guide/features.html#css-pre-processors
CSS entrypoints are always unlisted. To add CSS to a content script, see the [Content Script](/guide/essentials/content-scripts#css) docs.
<EntrypointPatterns
:patterns="[
['<name>.(css|scss|sass|less|styl|stylus)', '<name>.css'],
['<name>/index.(css|scss|sass|less|styl|stylus)', '<name>.css'],
['{name}.(css|scss|sass|less|styl|stylus)', '{name}.css'],
['{name}/index.(css|scss|sass|less|styl|stylus)', '{name}.css'],
['content.(css|scss|sass|less|styl|stylus)', 'content-scripts/content.css'],
['content/index.(css|scss|sass|less|styl|stylus)', 'content-scripts/content.css'],
['<name>.content.(css|scss|sass|less|styl|stylus)', 'content-scripts/<name>.css'],
['<name>.content/index.(css|scss|sass|less|styl|stylus)', 'content-scripts/<name>.css'],
['{name}.content.(css|scss|sass|less|styl|stylus)', 'content-scripts/{name}.css'],
['{name}.content/index.(css|scss|sass|less|styl|stylus)', 'content-scripts/{name}.css'],
]"
/>
@@ -478,12 +580,16 @@ body {
}
```
Follow Vite's guide to setup your preprocessor of choice: https://vitejs.dev/guide/features.html#css-pre-processors
CSS entrypoints are always unlisted. To add CSS to a content script, see the [Content Script](/guide/essentials/content-scripts#css) docs.
### Unlisted Pages
<EntrypointPatterns
:patterns="[
['<name>.html', '<name>.html'],
['<name>/index.html', '<name>.html'],
['{name}.html', '{name}.html'],
['{name}/index.html', '{name}.html'],
]"
/>
@@ -505,20 +611,21 @@ body {
</html>
```
Pages are accessible at `/<name>.html`:
At runtime, unlisted pages are accessible at `/{name}.html`:
```ts
const url = browser.runtime.getURL('/<name>.html');
const url = browser.runtime.getURL('/{name}.html');
console.log(url); // "chrome-extension://<id>/<name>.html"
console.log(url); // "chrome-extension://{id}/{name}.html"
window.open(url); // Open the page in a new tab
```
### Unlisted Scripts
<EntrypointPatterns
:patterns="[
['<name>.[jt]sx?', '<name>.js'],
['<name>/index.[jt]sx?', '<name>.js'],
['{name}.[jt]sx?', '{name}.js'],
['{name}/index.[jt]sx?', '{name}.js'],
]"
/>
@@ -544,12 +651,29 @@ export default defineUnlistedScript({
:::
Scripts are accessible from `/<name>.js`:
At runtime, unlisted scripts are accessible from `/{name}.js`:
```ts
const url = browser.runtime.getURL('/<name>.js');
const url = browser.runtime.getURL('/{name}.js');
console.log(url); // "chrome-extension://<id>/<name>.js"
console.log(url); // "chrome-extension://{id}/{name}.js"
```
You are responsible for loading/running these scripts where needed. If necessary, don't forget to add the script and/or any related assets to [`web_accessible_resources`](https://developer.chrome.com/docs/extensions/reference/manifest/web-accessible-resources).
When defining an unlisted script, keep in mind that WXT will import this file in a NodeJS environment during the build process. That means you cannot place any runtime code outside the `main` function.
<!-- prettier-ignore -->
```ts
document.querySelectorAll('a').forEach((anchor) => { // [!code --]
// ... // [!code --]
}); // [!code --]
export default defineUnlistedScript(() => {
document.querySelectorAll('a').forEach((anchor) => { // [!code ++]
// ... // [!code ++]
}); // [!code ++]
});
```
Refer to the [Entrypoint Loaders](/guide/essentials/config/entrypoint-loaders) documentation for more details.
+1 -1
View File
@@ -29,7 +29,7 @@ This means you can use the promise-style API for both MV2 and MV3, and it will w
All types can be accessed via WXT's `Browser` namespace:
```ts
import { Browser } from 'wxt/browser';
import { type Browser } from 'wxt/browser';
function handleMessage(message: any, sender: Browser.runtime.MessageSender) {
// ...
+3 -2
View File
@@ -30,9 +30,10 @@ Here's a brief summary of each of these files and directories:
- `.wxt/`: Generated by WXT, it contains TS config
- `assets/`: Contains all CSS, images, and other assets that should be processed by WXT
- `components/`: Auto-imported by default, contains UI components
- `composables/`: Auto-imported by default, contains composable functions for Vue
- `composables/`: Auto-imported by default, contains source code for your project's composable functions for Vue
- `entrypoints/`: Contains all the entrypoints that get bundled into your extension
- `hooks/`: Auto-imported by default, contains hooks for React and Solid
- `hooks/`: Auto-imported by default, contains source code for your project's hooks for React and Solid
- `modules/`: Contains [local WXT Modules](/guide/essentials/wxt-modules) for your project
- `public/`: Contains any files you want to copy into the output folder as-is, without being processed by WXT
- `utils/`: Auto-imported by default, contains generic utilities used throughout your project
- `.env`: Contains [Environment Variables](/guide/essentials/config/environment-variables)
+1 -1
View File
@@ -23,7 +23,7 @@ WXT provides two commands to help automate submitting a new version for review a
- `wxt submit init`: Setup all the required secrets and options for the `wxt submit` command
- `wxt submit`: Submit new versions of your extension for review (and publish them automatically once approved)
To get started, run `wxt submit init` and follow the prompts. Once finished, you should have a `.env.submit` file! WXT will use this file to submit your updates.
To get started, run `wxt submit init` and follow the prompts, or run `wxt submit --help` to view all available options. Once finished, you should have a `.env.submit` file! WXT will use this file to submit your updates.
> In CI, make sure you add all the environment variables to the submit step.
+1 -1
View File
@@ -85,7 +85,7 @@ import { injectScript, createShadowRootUi } from '#imports';
But Vitest sees this:
```ts
import { injectScript } from 'wxt/browser';
import { injectScript } from 'wxt/utils/inject-script';
import { createShadowRootUi } from 'wxt/utils/content-script-ui/shadow-root';
```
+13
View File
@@ -174,3 +174,16 @@ Additionally, if you want to train your own model or provide context to your edi
https://wxt.dev/knowledge/index.json
You don't need to crawl the entire website, these files already contain all the relevant docs for training a LLM on WXT. But feel free to crawl it and generate your own files if you want!
## How do I run my WXT project with docker / [devcontainers](https://containers.dev)?
To run the WXT dev server in a devcontainer, but load the dev build of your extension in your browser:
1. **Bind-mount your project directory to your host**
If you're using VS Code, you can open your project folder with the `Dev Containers: Open Folder in Container...` command. This keeps the folder synchronized between your host and the devcontainer, ensuring that the extension `dist` directory remains accessible from the host.
2. **Disable auto-opening the browser**
WXT automatically opens your browser during development, but since you're running inside a container, it won't be able to access it. Follow the instructions [here](https://wxt.dev/guide/essentials/config/browser-startup.html#disable-opening-browser) to disable browser auto-opening in your `wxt.config.ts`.
3. **Tell WXT to listen on all network interfaces**
To enable hot-reloading, your extension has to connect to the WXT dev server running inside your container. WXT will only listen on `localhost` by default, which prevents connections from outside the devcontainer. To fix this you can instruct WXT to listen on all interfaces with `wxt --host 0.0.0.0`.
+30 -13
View File
@@ -6,12 +6,27 @@ outline: deep
## Overview
To upgrade WXT to the latest version... just install it!
To upgrade WXT to the latest major version:
1. Install it, skipping scripts so `wxt prepare` doesn't run - it will probably throw an error after a major version change (we'll run it later).
```sh
pnpm i wxt@latest --ignore-scripts
```
2. Follow the upgrade steps below to fix any breaking changes.
3. Run `wxt prepare`. It should succeed and type errors will go away afterwords.
```sh
pnpm wxt prepare
```
4. Manually test to make sure both dev mode and production builds work.
For minor or patch version updates, there are no special steps. Just update it with your package manager:
```sh
pnpm i wxt@latest
```
---
Listed below are all the breaking changes you should address when upgrading to a new version of WXT.
Currently, WXT is in pre-release. This means changes to the second digit, `v0.X`, are considered major and have breaking changes. Once v1 is released, only major version bumps will have breaking changes.
@@ -21,7 +36,7 @@ Currently, WXT is in pre-release. This means changes to the second digit, `v0.X`
v0.20 is a big release! There are lots of breaking changes because this version is intended to be a release candidate for v1.0. If all goes well, v1.0 will be released with no additional breaking changes.
:::tip
Read through all the changes once before making any code changes.
Read through all the changes once before updating your code.
:::
### `webextension-polyfill` Removed
@@ -92,17 +107,19 @@ The default location for the `public/` and `modules/` directories have changed t
- If you follow the default folder structure, you don't need to make any changes.
- If you set a custom `srcDir`, you have two options:
1. Move the your `public/` and `modules/` directories to the project root:
```diff
<root>/
+ modules/
+ public/
src/
components/
entrypoints/
- modules/
- public/
utils/
wxt.config.ts
<!-- prettier-ignore -->
```html
📂 {rootDir}/
📁 modules/ <!-- [!code ++] -->
📁 public/ <!-- [!code ++] -->
📂 src/
📁 components/
📁 entrypoints/
📁 modules/ <!-- [!code --] -->
📁 public/ <!-- [!code --] -->
📁 utils/
📄 app.config.ts
📄 wxt.config.ts
```
2. Keep the folders in the same place and update your project config:
```ts [wxt.config.ts]
+2
View File
@@ -32,6 +32,8 @@
/guide/build-targets.html /guide/multiple-browsers.html
/guide/installation.html /get-started/installation.html
/guide/introduction.html /get-started/introduction.html
/guide/upgrade-guide/wxt /guide/resources/upgrading.html
/guide/upgrade-guide/wxt.html /guide/resources/upgrading.html
# 0.19.0
/guide/go-further/entrypoint-side-effects.html /guide/go-further/entrypoint-loaders.html
+1
View File
@@ -0,0 +1 @@
<!--@include: ../packages/runner/README.md-->
+28 -28
View File
@@ -4,7 +4,7 @@
"engines": {
"node": ">=18.20.3"
},
"packageManager": "pnpm@10.5.2",
"packageManager": "pnpm@10.10.0",
"scripts": {
"check": "check && pnpm -r --sequential run check",
"test": "pnpm -r --sequential run test run",
@@ -17,33 +17,33 @@
"docs:preview": "pnpm -s docs:gen && vitepress preview docs"
},
"devDependencies": {
"@aklinker1/buildc": "catalog:",
"@aklinker1/check": "catalog:",
"@commitlint/config-conventional": "catalog:",
"@commitlint/types": "catalog:",
"@types/fs-extra": "catalog:",
"@vitest/coverage-v8": "catalog:",
"changelogen": "catalog:",
"consola": "catalog:",
"fast-glob": "catalog:",
"feed": "catalog:",
"fs-extra": "catalog:",
"lint-staged": "catalog:",
"markdown-it-footnote": "catalog:",
"nano-spawn": "catalog:",
"prettier": "catalog:",
"simple-git-hooks": "catalog:",
"tsx": "catalog:",
"typedoc": "catalog:",
"typedoc-plugin-frontmatter": "catalog:",
"typedoc-plugin-markdown": "catalog:",
"typedoc-vitepress-theme": "catalog:",
"typescript": "catalog:",
"vitepress": "catalog:",
"vitepress-knowledge": "catalog:",
"vitepress-plugin-group-icons": "catalog:",
"vitest-mock-extended": "catalog:",
"vue": "catalog:",
"@aklinker1/buildc": "^1.1.4",
"@aklinker1/check": "2.0.0",
"@commitlint/config-conventional": "^19.8.0",
"@commitlint/types": "^19.8.0",
"@types/fs-extra": "^11.0.4",
"@vitest/coverage-v8": "^3.1.2",
"changelogen": "^0.6.1",
"consola": "^3.4.2",
"fast-glob": "^3.3.3",
"feed": "^4.2.2",
"fs-extra": "^11.3.0",
"lint-staged": "^15.5.1",
"markdown-it-footnote": "^4.0.0",
"nano-spawn": "^0.2.0",
"prettier": "^3.5.3",
"simple-git-hooks": "^2.13.0",
"tsx": "4.19.4",
"typedoc": "^0.25.4",
"typedoc-plugin-frontmatter": "^1.3.0",
"typedoc-plugin-markdown": "4.0.0-next.23",
"typedoc-vitepress-theme": "1.0.0-next.3",
"typescript": "^5.8.3",
"vitepress": "^1.6.3",
"vitepress-knowledge": "^0.4.1",
"vitepress-plugin-group-icons": "^1.5.2",
"vitest-mock-extended": "^3.1.0",
"vue": "^3.5.13",
"wxt": "workspace:*"
},
"simple-git-hooks": {
+2 -17
View File
@@ -1,27 +1,12 @@
# Changelog
## v0.5.0
[⚠️ breaking changes](https://wxt.dev/guide/upgrade-guide/wxt) &bull; [compare changes](https://github.com/wxt-dev/wxt/compare/analytics-v0.4.1...analytics-v0.5.0)
### 🚀 Enhancements
- Analytics module ([5217279](https://github.com/wxt-dev/wxt/commit/5217279))
- New `@wxt-dev/analytics` package ([#790](https://github.com/wxt-dev/wxt/pull/790))
[⚠️ breaking changes](https://wxt.dev/guide/resources/upgrading.html) &bull; [compare changes](https://github.com/wxt-dev/wxt/compare/analytics-v0.4.1...analytics-v0.5.0)
### 🩹 Fixes
- ⚠️ Update min WXT version to 0.20 ([2e8baf0](https://github.com/wxt-dev/wxt/commit/2e8baf0))
### 🏡 Chore
- **release:** Analytics-v0.4.0 ([2faba91](https://github.com/wxt-dev/wxt/commit/2faba91))
- **release:** Analytics-v0.4.1 ([1509809](https://github.com/wxt-dev/wxt/commit/1509809))
#### ⚠️ Breaking Changes
- ⚠️ Update min WXT version to 0.20 ([2e8baf0](https://github.com/wxt-dev/wxt/commit/2e8baf0))
### ❤️ Contributors
- Aaron ([@aklinker1](https://github.com/aklinker1))
- Aaron ([@aklinker1](https://github.com/aklinker1))
@@ -9,11 +9,12 @@ import type {
AnalyticsEventMetadata,
AnalyticsProvider,
} from './types';
import { browser } from '@wxt-dev/browser';
const ANALYTICS_PORT = '@wxt-dev/analytics';
export function createAnalytics(config?: AnalyticsConfig): Analytics {
if (typeof chrome === 'undefined' || !chrome?.runtime?.id)
if (!browser?.runtime?.id)
throw Error(
'Cannot use WXT analytics in contexts without access to the browser.runtime APIs',
);
@@ -51,13 +52,13 @@ function createBackgroundAnalytics(
defineStorageItem<boolean>('local:wxt-analytics:enabled', false);
// Cached values
const platformInfo = chrome.runtime.getPlatformInfo();
const platformInfo = browser.runtime.getPlatformInfo();
const userAgent = UAParser();
let userId = Promise.resolve(userIdStorage.getValue()).then(
(id) => id ?? globalThis.crypto.randomUUID(),
);
let userProperties = userPropertiesStorage.getValue();
const manifest = chrome.runtime.getManifest();
const manifest = browser.runtime.getManifest();
const getBackgroundMeta = () => ({
timestamp: Date.now(),
@@ -178,7 +179,7 @@ function createBackgroundAnalytics(
config?.providers?.map((provider) => provider(analytics, config)) ?? [];
// Listen for messages from the rest of the extension
chrome.runtime.onConnect.addListener((port) => {
browser.runtime.onConnect.addListener((port) => {
if (port.name === ANALYTICS_PORT) {
port.onMessage.addListener(({ fn, args }) => {
// @ts-expect-error: Untyped fn key
@@ -194,7 +195,7 @@ function createBackgroundAnalytics(
* Creates an analytics client for non-background contexts.
*/
function createFrontendAnalytics(): Analytics {
const port = chrome.runtime.connect({ name: ANALYTICS_PORT });
const port = browser.runtime.connect({ name: ANALYTICS_PORT });
const sessionId = Date.now();
const getFrontendMetadata = (): AnalyticsEventMetadata => ({
sessionId,
@@ -252,8 +253,8 @@ function defineStorageItem<T>(
): AnalyticsStorageItem<T> {
return {
getValue: async () =>
(await chrome.storage.local.get(key))[key] ?? defaultValue,
setValue: (newValue) => chrome.storage.local.set({ [key]: newValue }),
(await browser.storage.local.get(key))[key] ?? defaultValue,
setValue: (newValue) => browser.storage.local.set({ [key]: newValue }),
};
}
+7 -7
View File
@@ -51,15 +51,15 @@
"wxt": ">=0.20.0"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"@types/chrome": "catalog:",
"@types/ua-parser-js": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"@aklinker1/check": "2.0.0",
"@types/ua-parser-js": "^0.7.39",
"publint": "^0.3.12",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"wxt": "workspace:*"
},
"dependencies": {
"ua-parser-js": "catalog:"
"@wxt-dev/browser": "workspace:^",
"ua-parser-js": "^1.0.40"
}
}
+1 -2
View File
@@ -3,8 +3,7 @@
"compilerOptions": {
"paths": {
"#analytics": ["./.wxt/analytics/index.ts"]
},
"types": ["chrome"]
}
},
"exclude": ["node_modules", "dist"]
}
+8 -8
View File
@@ -45,16 +45,16 @@
"wxt": ">=0.19.0"
},
"dependencies": {
"defu": "catalog:",
"fs-extra": "catalog:",
"sharp": "catalog:"
"defu": "^6.1.4",
"fs-extra": "^11.3.0",
"sharp": "^0.34.1"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"oxlint": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"@aklinker1/check": "2.0.0",
"oxlint": "^0.16.8",
"publint": "^0.3.12",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"wxt": "workspace:*"
}
}
+8 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@wxt-dev/browser",
"description": "Provides a cross-browser API for using extension APIs and types based on @types/chrome",
"version": "0.0.310",
"version": "0.0.326",
"type": "module",
"main": "src/index.mjs",
"types": "src/index.d.ts",
@@ -11,6 +11,7 @@
"directory": "packages/browser"
},
"scripts": {
"check": "check",
"gen": "tsx scripts/generate.ts"
},
"author": {
@@ -22,11 +23,12 @@
"src"
],
"devDependencies": {
"@types/chrome": "0.0.310",
"fs-extra": "catalog:",
"nano-spawn": "catalog:",
"tsx": "catalog:",
"vitest": "catalog:"
"@types/chrome": "0.0.326",
"fs-extra": "^11.3.0",
"nano-spawn": "^0.2.0",
"tsx": "4.19.4",
"typescript": "^5.8.3",
"vitest": "^3.1.2"
},
"dependencies": {
"@types/filesystem": "*",
+3 -1
View File
@@ -7,7 +7,7 @@ import { sep as posixSep } from 'node:path/posix';
// Fetch latest version
console.log('Getting latest version of \x1b[36m@types/chrome\x1b[0m');
await spawn('pnpm', ['i', '--ignore-scripts', '-D', '@types/chrome']);
await spawn('pnpm', ['i', '--ignore-scripts', '-D', '@types/chrome@latest']);
// Generate new package.json
@@ -76,5 +76,7 @@ function transformFile(file: string, content: string): string {
.replaceAll('declare namespace chrome', 'export namespace Browser')
// Update references to `chrome` namespace to `Browser`
.replaceAll('chrome.', 'Browser.')
// Fix links to developer.chrome.com
.replaceAll('developer.Browser.com', 'developer.chrome.com')
);
}
+26 -25
View File
@@ -1,5 +1,6 @@
/* DO NOT EDIT - generated by scripts/generate.ts */
/* eslint-disable @typescript-eslint/no-wrapper-object-types */
export namespace Browser {
////////////////////
// Cast
@@ -112,7 +113,7 @@ export namespace Browser {
*/
export function initialize(
apiConfig: Browser.cast.ApiConfig,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -160,7 +161,7 @@ export namespace Browser {
*/
export function setCustomReceivers(
receivers: Browser.cast.Receiver[],
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -171,7 +172,7 @@ export namespace Browser {
*/
export function setReceiverDisplayStatus(
receiver: Browser.cast.Receiver,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -294,7 +295,7 @@ export namespace Browser {
*/
setReceiverVolumeLevel(
newLevel: number,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -305,7 +306,7 @@ export namespace Browser {
*/
setReceiverMuted(
muted: boolean,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -313,13 +314,13 @@ export namespace Browser {
* @param successCallback
* @param errorCallback
*/
leave(successCallback: Function, errorCallback: (error: Browser.cast.Error) => void): void;
leave(successCallback: () => void, errorCallback: (error: Browser.cast.Error) => void): void;
/**
* @param successCallback
* @param errorCallback
*/
stop(successCallback: Function, errorCallback: (error: Browser.cast.Error) => void): void;
stop(successCallback: () => void, errorCallback: (error: Browser.cast.Error) => void): void;
/**
* @param namespace
@@ -330,7 +331,7 @@ export namespace Browser {
sendMessage(
namespace: string,
message: string | object,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -887,7 +888,7 @@ export namespace Browser {
*/
getStatus(
getStatusRequest: Browser.cast.media.GetStatusRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -898,7 +899,7 @@ export namespace Browser {
*/
play(
playRequest: Browser.cast.media.PlayRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -909,7 +910,7 @@ export namespace Browser {
*/
pause(
pauseRequest: Browser.cast.media.PauseRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -920,7 +921,7 @@ export namespace Browser {
*/
seek(
seekRequest: Browser.cast.media.SeekRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -931,7 +932,7 @@ export namespace Browser {
*/
stop(
stopRequest: Browser.cast.media.StopRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -942,7 +943,7 @@ export namespace Browser {
*/
setVolume(
volumeRequest: Browser.cast.media.VolumeRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -953,7 +954,7 @@ export namespace Browser {
*/
editTracksInfo(
editTracksInfoRequest: Browser.cast.media.EditTracksInfoRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -986,7 +987,7 @@ export namespace Browser {
*/
queueAppendItem(
item: Browser.cast.media.QueueItem,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -997,7 +998,7 @@ export namespace Browser {
*/
queueInsertItems(
queueInsertItemsRequest: Browser.cast.media.QueueInsertItemsRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -1008,7 +1009,7 @@ export namespace Browser {
*/
queueJumpToItem(
itemId: number,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -1021,7 +1022,7 @@ export namespace Browser {
queueMoveItemToNewIndex(
itemId: number,
newIndex: number,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -1029,13 +1030,13 @@ export namespace Browser {
* @param successCallback
* @param errorCallback
*/
queueNext(successCallback: Function, errorCallback: (error: Browser.cast.Error) => void): void;
queueNext(successCallback: () => void, errorCallback: (error: Browser.cast.Error) => void): void;
/**
* @param successCallback
* @param errorCallback
*/
queuePrev(successCallback: Function, errorCallback: (error: Browser.cast.Error) => void): void;
queuePrev(successCallback: () => void, errorCallback: (error: Browser.cast.Error) => void): void;
/**
* @param itemId
@@ -1044,7 +1045,7 @@ export namespace Browser {
*/
queueRemoveItem(
itemId: number,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -1055,7 +1056,7 @@ export namespace Browser {
*/
queueReorderItems(
queueReorderItemsRequest: Browser.cast.media.QueueReorderItemsRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -1066,7 +1067,7 @@ export namespace Browser {
*/
queueSetRepeatMode(
repeatMode: Browser.cast.media.RepeatMode,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
@@ -1077,7 +1078,7 @@ export namespace Browser {
*/
queueUpdateItems(
queueUpdateItemsRequest: Browser.cast.media.QueueUpdateItemsRequest,
successCallback: Function,
successCallback: () => void,
errorCallback: (error: Browser.cast.Error) => void,
): void;
}
+1476 -1108
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -11,6 +11,7 @@
"directory": "packages/browser"
},
"scripts": {
"check": "check",
"gen": "tsx scripts/generate.ts"
},
"author": {
@@ -23,9 +24,10 @@
],
"devDependencies": {
"@types/chrome": "{{chromeTypesVersion}}",
"fs-extra": "catalog:",
"nano-spawn": "catalog:",
"tsx": "catalog:",
"vitest": "catalog:"
"fs-extra": "^11.3.0",
"nano-spawn": "^0.2.0",
"tsx": "4.19.4",
"typescript": "^5.8.3",
"vitest": "^3.1.2"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {}
}
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## v0.2.4
[compare changes](https://github.com/wxt-dev/wxt/compare/i18n-v0.2.3...i18n-v0.2.4)
### 🩹 Fixes
- Standardize locale codes and warn about unsupported ones ([#1617](https://github.com/wxt-dev/wxt/pull/1617))
- Use `@wxt-dev/browser` instead of `@types/chrome` ([#1645](https://github.com/wxt-dev/wxt/pull/1645))
### 📖 Documentation
- Add react language ID to README ([#1347](https://github.com/wxt-dev/wxt/pull/1347))
- Fix public path reference ([bcb20874](https://github.com/wxt-dev/wxt/commit/bcb20874))
### 🏡 Chore
- **deps:** Upgrade all non-major dependencies ([#1164](https://github.com/wxt-dev/wxt/pull/1164))
- **deps:** Bump dev and non-breaking major dependencies ([#1167](https://github.com/wxt-dev/wxt/pull/1167))
- Add funding links to `package.json` files ([#1446](https://github.com/wxt-dev/wxt/pull/1446))
- Use PNPM 10's new catelog feature ([#1493](https://github.com/wxt-dev/wxt/pull/1493))
- Move production dependencies to PNPM 10 catelog ([#1494](https://github.com/wxt-dev/wxt/pull/1494))
- Stop using PNPM catalog ([#1644](https://github.com/wxt-dev/wxt/pull/1644))
- Upgrade `@aklinker1/check` to v2 ([#1647](https://github.com/wxt-dev/wxt/pull/1647))
- Change browser workspace dependency to `^` ([c7335add](https://github.com/wxt-dev/wxt/commit/c7335add))
### ❤️ Contributors
- Aaron ([@aklinker1](https://github.com/aklinker1))
- Okinea Dev ([@okineadev](https://github.com/okineadev))
- Redwoodlid ([@redwoodlid](https://github.com/redwoodlid))
## v0.2.3
[compare changes](https://github.com/wxt-dev/wxt/compare/i18n-v0.2.2...i18n-v0.2.3)
+13 -13
View File
@@ -1,7 +1,7 @@
{
"name": "@wxt-dev/i18n",
"description": "Type-safe wrapper around browser.i18n.getMessage with additional features",
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"repository": {
"type": "git",
@@ -26,9 +26,10 @@
"test": "buildc --deps-only -- vitest"
},
"dependencies": {
"chokidar": "catalog:",
"confbox": "catalog:",
"fast-glob": "catalog:"
"@wxt-dev/browser": "workspace:^",
"chokidar": "^4.0.3",
"confbox": "^0.1.8 || ^0.2.2",
"fast-glob": "^3.3.3"
},
"peerDependencies": {
"wxt": ">=0.19.7"
@@ -39,15 +40,14 @@
}
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"@types/chrome": "catalog:",
"@types/node": "catalog:",
"oxlint": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"vitest": "catalog:",
"vitest-plugin-random-seed": "catalog:",
"@aklinker1/check": "2.0.0",
"@types/node": "^20.17.6",
"oxlint": "^0.16.8",
"publint": "^0.3.12",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"vitest": "^3.1.2",
"vitest-plugin-random-seed": "^1.1.1",
"wxt": "workspace:*"
},
"main": "./dist/index.cjs",
+11 -6
View File
@@ -1,13 +1,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createI18n } from '../index';
import { browser } from '@wxt-dev/browser';
const getMessageMock = vi.fn();
vi.stubGlobal('chrome', {
i18n: {
getMessage: getMessageMock,
},
vi.mock('@wxt-dev/browser', async () => {
const { vi } = await import('vitest');
return {
browser: {
i18n: {
getMessage: vi.fn(),
},
},
};
});
const getMessageMock = vi.mocked(browser.i18n.getMessage);
describe('createI18n', () => {
beforeEach(() => {
+11 -6
View File
@@ -1,13 +1,18 @@
import { beforeEach, describe, it, vi } from 'vitest';
import { createI18n } from '..';
import { browser } from '@wxt-dev/browser';
const getMessageMock = vi.fn();
vi.stubGlobal('chrome', {
i18n: {
getMessage: getMessageMock,
},
vi.mock('@wxt-dev/browser', async () => {
const { vi } = await import('vitest');
return {
browser: {
i18n: {
getMessage: vi.fn(),
},
},
};
});
const getMessageMock = vi.mocked(browser.i18n.getMessage);
const n: number = 1;
+32 -2
View File
@@ -1,10 +1,14 @@
import { describe, it, expect } from 'vitest';
import { ChromeMessage } from '../build';
import { applyChromeMessagePlaceholders, getSubstitutionCount } from '../utils';
import {
applyChromeMessagePlaceholders,
getSubstitutionCount,
standardizeLocale,
} from '../utils';
describe('Utils', () => {
describe('applyChromeMessagePlaceholders', () => {
it('should return the combined stirng', () => {
it('should return the combined string', () => {
const input = {
message: 'Hello $username$, welcome to $appName$',
placeholders: {
@@ -60,4 +64,30 @@ describe('Utils', () => {
expect(getSubstitutionCount('Hello $10')).toBe(1);
});
});
describe('standardizeLocale', () => {
it('should convert two-letter locale codes to lowercase', () => {
expect(standardizeLocale('en')).toEqual('en');
expect(standardizeLocale('EN')).toEqual('en');
});
it('should convert locale code extensions to uppercase', () => {
expect(standardizeLocale('en_US')).toEqual('en_US');
expect(standardizeLocale('en_us')).toEqual('en_US');
expect(standardizeLocale('es_419')).toEqual('es_419');
});
it('should convert dashes to underscores', () => {
expect(standardizeLocale('en_US')).toEqual('en_US');
expect(standardizeLocale('en-US')).toEqual('en_US');
});
it('should return the input string as-is for unknown formats', () => {
expect(standardizeLocale('en_USSS')).toEqual('en_USSS');
expect(standardizeLocale('en-')).toEqual('en-');
expect(standardizeLocale('------')).toEqual('------');
expect(standardizeLocale('test')).toEqual('test');
expect(standardizeLocale('hello-world')).toEqual('hello-world');
});
});
});
+2
View File
@@ -10,6 +10,8 @@ import { parseYAML, parseJSON5, parseTOML } from 'confbox';
import { dirname, extname } from 'node:path';
import { applyChromeMessagePlaceholders, getSubstitutionCount } from './utils';
export { SUPPORTED_LOCALES } from './supported-locales';
//
// TYPES
//
+3 -2
View File
@@ -7,6 +7,7 @@ import {
I18n,
Substitution,
} from './types';
import { browser } from '@wxt-dev/browser';
export function createI18n<
T extends I18nStructure = DefaultI18nStructure,
@@ -39,9 +40,9 @@ export function createI18n<
if (sub?.length) {
// Convert all substitutions to strings
const stringSubs = sub?.map((sub) => String(sub));
message = chrome.i18n.getMessage(key.replaceAll('.', '_'), stringSubs);
message = browser.i18n.getMessage(key.replaceAll('.', '_'), stringSubs);
} else {
message = chrome.i18n.getMessage(key.replaceAll('.', '_'));
message = browser.i18n.getMessage(key.replaceAll('.', '_'));
}
if (!message) {
console.warn(`[i18n] Message not found: "${key}"`);
+18 -4
View File
@@ -16,12 +16,14 @@ import {
generateChromeMessagesText,
parseMessagesFile,
generateTypeText,
SUPPORTED_LOCALES,
} from './build';
import glob from 'fast-glob';
import { basename, extname, join, resolve } from 'node:path';
import { watch } from 'chokidar';
import { GeneratedPublicFile, WxtDirFileEntry } from 'wxt';
import { writeFile } from 'node:fs/promises';
import { standardizeLocale } from './utils';
export default defineWxtModule<I18nOptions>({
name: '@wxt-dev/i18n',
@@ -46,10 +48,22 @@ export default defineWxtModule<I18nOptions>({
cwd: localesDir,
absolute: true,
});
return files.map((file) => ({
file,
locale: basename(file).replace(extname(file), ''),
}));
const unsupportedLocales: string[] = [];
const res = files.map((file) => {
const rawLocale = basename(file).replace(extname(file), '');
const locale = standardizeLocale(rawLocale);
if (!SUPPORTED_LOCALES.has(locale)) unsupportedLocales.push(locale);
return { file, locale };
});
if (unsupportedLocales.length > 0)
wxt.logger.warn(
`Unsupported locales: [${unsupportedLocales.join(', ')}].\n\nWeb extensions only support a limited set of locales as described here: https://developer.chrome.com/docs/extensions/reference/api/i18n#locales`,
);
return res;
};
const generateOutputJsonFiles = async (): Promise<
+58
View File
@@ -0,0 +1,58 @@
/** From https://developer.chrome.com/docs/extensions/reference/api/i18n#locales */
export const SUPPORTED_LOCALES = new Set([
'ar',
'am',
'bg',
'bn',
'ca',
'cs',
'da',
'de',
'el',
'en',
'en_AU',
'en_GB',
'en_US',
'es',
'es_419',
'et',
'fa',
'fi',
'fil',
'fr',
'gu',
'he',
'hi',
'hr',
'hu',
'id',
'it',
'ja',
'kn',
'ko',
'lt',
'lv',
'ml',
'mr',
'ms',
'nl',
'no',
'pl',
'pt_BR',
'pt_PT',
'ro',
'ru',
'sk',
'sl',
'sr',
'sv',
'sw',
'ta',
'te',
'th',
'tr',
'uk',
'vi',
'zh_CN',
'zh_TW',
]);
+13
View File
@@ -21,3 +21,16 @@ export function getSubstitutionCount(message: string): number {
}
const MAX_SUBSTITUTIONS = 9;
/** Given a string, standardize it to the format `xx_YY`. */
export function standardizeLocale(locale: string): string {
if (locale.length === 2) return locale.toLowerCase();
const [is_match, prefix, suffix] =
locale.match(/^([a-z]{2})[-_]([a-z]{2,3})$/i) ?? [];
if (is_match) {
return `${prefix.toLowerCase()}_${suffix.toUpperCase()}`;
}
return locale;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["chrome", "node"]
"types": ["node"]
},
"exclude": ["node_modules/**", "dist/**"]
}
+9 -9
View File
@@ -48,17 +48,17 @@
"wxt": ">=0.19.16"
},
"dependencies": {
"@vitejs/plugin-react": "catalog:"
"@vitejs/plugin-react": "^4.4.1"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"publint": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"@aklinker1/check": "2.0.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"publint": "^0.3.12",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"wxt": "workspace:*"
}
}
+6 -6
View File
@@ -48,14 +48,14 @@
"wxt": ">=0.19.16"
},
"dependencies": {
"vite-plugin-solid": "catalog:"
"vite-plugin-solid": "^2.11.6"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"publint": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"@aklinker1/check": "2.0.0",
"publint": "^0.3.12",
"solid-js": "^1.9.6",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"wxt": "workspace:*"
}
}
+5 -5
View File
@@ -47,13 +47,13 @@
"svelte": ">=5"
},
"dependencies": {
"@sveltejs/vite-plugin-svelte": "catalog:"
"@sveltejs/vite-plugin-svelte": "^4.0.0 || ^5.0.0"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"@aklinker1/check": "2.0.0",
"publint": "^0.3.12",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"wxt": "workspace:*"
}
}
+5 -5
View File
@@ -46,13 +46,13 @@
"wxt": ">=0.19.16"
},
"dependencies": {
"@vitejs/plugin-vue": "catalog:"
"@vitejs/plugin-vue": "^5.2.3"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"@aklinker1/check": "2.0.0",
"publint": "^0.3.12",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"wxt": "workspace:*"
}
}
+225
View File
@@ -0,0 +1,225 @@
# `@wxt-dev/runner`
Programmatically open a browser and install a web extension from a local directory.
###### With WXT
> [!WARNING]
> This package is intended to replace [`web-ext`](https://github.com/mozilla/web-ext) in the future, but it is not ready at the moment. Once it's ready for testing in WXT, more details will be added here.
```ts
// ~/wxt.runner.config.ts OR <project>/wxt.runner.config.ts
import { defineRunnerConfig } from 'wxt';
export default defineRunnerConfig({
// Options go here
});
```
###### JS API
```ts
import { run } from '@wxt-dev/runner';
await run({
extensionDir: '/path/to/extension',
// Other options...
});
```
## Features
- Supports all Chromium and Firefox based browsers
- Zero dependencies
- One-line config for persisting data between launches
## Requirements
`@wxt-dev/runner` requires a JS runtime that implements the `WebSocket` standard:
| JS Runtime | Version |
| ---------- | ----------- |
| NodeJS | &ge; 22.4.0 |
| Bun | &ge; 1.2.0 |
You also need to have a specific version of the browser installed that supports the latest features so extensions can be loaded:
| Browser | Version |
| -------- | -------- |
| Chromium | Unknown |
| Firefox | &ge; 139 |
## TODO
- [x] Provide install functions to allow hooking into already running instances of Chrome/Firefox
- [ ] Try to setup E2E tests on Firefox with Puppeteer using this approach
- [ ] Try to setup E2E tests on Chrome with Puppeteer using this approach
## Options
### Target
To open a specific browser, use the `target` option:
```ts
import { run } from '@wxt-dev/runner';
await run({
extensionDir: 'path/to/extension',
target: 'firefox',
});
```
Defaults to opening `chrome`. You may see type-hints for a list of popular browsers, but you can enter any string you want here.
### Data Persistence
Browsers block you from using your normal browser profiles when using the [BiDi and CDP protocols](#implementation-details) for security reasons.
To change how the new profile's data is saved between sessions, use the `dataPersistence` option:
```ts
import { run } from '@wxt-dev/runner';
await run({
dataPersistence: 'user',
});
```
- `"none"` (default): Use a brand new browser profile every time the browser is opened (stored in the system's tmp directory)
- `"project"`: Create a new profile that is re-used for your current directory (by default stored in `.wxt-runner` or `.wxt/runner` for WXT projects)
- `"user"`: Create a new profile that is re-used for all projects using `@wxt-dev/runner` (by default stored in `$HOME/.wxt-runner`)
These presets configure different flags for different operating systems when spawning the browser process.
If you want to customize your data persistence beyond what these presets define, [you can override the browser flags yourself](#Arguments) to configure persistence.
### Browser Binaries
`@wxt-dev/runner` will look for browser binaries/executables in [a hard-coded list of paths](https://github.com/wxt-dev/wxt/blob/main/packages/runner/src/browser-paths.ts). It does not and will not explore your filesystem/`$PATH` to find where the browser is installed. That means there are times you will need to specify the path to a browser's binary on your system:
- Your browser's path is non-standard or missing from the hard-coded list.
- You want to use a specific version/release of the browser.
- You're using a less popular browser and `@wxt-dev/runner` doesn't have hard-coded paths for it.
To do this, use the `browserBinaries` option and set the path to the browser's binary:
```ts
import { run } from '@wxt-dev/runner';
await run({
extensionDir: 'path/to/extension',
browserBinaries: {
chrome: '/path/to/chrome',
firefox: '/path/to/firefox',
},
});
```
### Arguments
To pass custom arguments to the browser on startup, use the `chromiumArgs` or `firefoxArgs` options:
```ts
import { run } from '@wxt-dev/runner';
await run({
extensionDir: 'path/to/extension',
chromiumArgs: ['--window-size=1920,1080'],
firefoxArgs: ['--window-size', '1920,1080'],
});
```
### Start URLs
To open specific URLs in tabs by default, you also use the `chromiumArgs` or `firefoxArgs` options.
Any URLs passed as a CLI argument will be opened in the browser when it starts.
```ts
import { run } from '@wxt-dev/runner';
await run({
extensionDir: 'path/to/extension',
chromiumArgs: ['https://example.com'],
firefoxArgs: ['https://example.com'],
});
```
### Debugging
To see debug logs, set the `DEBUG` env var to `"@wxt-dev/runner"`. This will print the resolved config, commands used to spawn the browser, any messages sent on the browser's communication protocol, and more for you to debug.
<details>
<summary>Example debug output</summary>
```
@wxt-dev/runner:options User options: { extensionDir: 'demo-extension', target: undefined }
@wxt-dev/runner:options Resolved options: {
browserBinary: '/usr/bin/chromium',
chromiumArgs: [
'--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider,CalculateNativeWinOcclusion,InterestFeedContentSuggestions,CertificateTransparencyComponentUpdater,AutofillServerCommunication,PrivacySandboxSettings4',
'--disable-component-extensions-with-background-pages',
'--disable-background-networking',
'--disable-component-update',
'--disable-client-side-phishing-detection',
'--disable-sync',
'--metrics-recording-only',
'--disable-default-apps',
'--no-default-browser-check',
'--no-first-run',
'--disable-background-timer-throttling',
'--disable-ipc-flooding-protection',
'--password-store=basic',
'--use-mock-keychain',
'--force-fieldtrials=*BackgroundTracing/default/',
'--disable-hang-monitor',
'--disable-prompt-on-repost',
'--disable-domain-reliability',
'--propagate-iph-for-testing',
'--remote-debugging-port=0',
'--remote-debugging-pipe',
'--user-data-dir=/tmp/wxt-runner-pWXLO1',
'--enable-unsafe-extension-debugging'
],
dataDir: '/tmp/wxt-runner-pWXLO1',
dataPersistence: 'none',
chromiumRemoteDebuggingPort: 0,
extensionDir: '/home/aklinker1/Development/github.com/wxt-dev/wxt/packages/runner/demo-extension',
firefoxArgs: [
'--new-instance',
'--no-remote',
'--profile',
'/tmp/wxt-runner-pWXLO1',
'--remote-debugging-port=0',
'about:debugging#/runtime/this-firefox'
],
firefoxRemoteDebuggingPort: 0,
target: 'chrome'
}
@wxt-dev/runner:chrome:stderr DevTools listening on ws://127.0.0.1:38397/devtools/browser/93dc4de5-64cb-4e0b-a9d3-7549527015f0
@wxt-dev/runner:cdp Sending command: {
id: 1,
method: 'Extensions.loadUnpacked',
params: {
path: '/home/aklinker1/Development/github.com/wxt-dev/wxt/packages/runner/demo-extension'
}
}
@wxt-dev/runner:cdp Received response: { id: 1, result: { id: 'hckhakegfgenefhikdcfkaaonnclljmf' } }
```
</details>
## Implementation Details
All this package does is spawn a child process to open the browser with some default flags before using remote protocols to install the extension.
### Firefox
We use the new [WebDriver BiDi protocol](https://www.w3.org/TR/webdriver-bidi) to install the extension. This just involves connecting to a web socket and sending a few messages.
### Chrome
We use the [CDP](https://chromedevtools.github.io/devtools-protocol/) with `--remote-debugging-pipe` and `--enable-unsafe-extension-debugging` to install the extension by sending a message via IO pipes 3 and 4.
We don't use Webdriver Bidi because it's not built into Chrome yet. It requires us instantiating a separate child process for `chromedriver`. This is slower and more difficult than just using the CDP built into Chrome.
@@ -0,0 +1 @@
console.log('Hello background!');
@@ -0,0 +1,9 @@
{
"name": "Test",
"version": "1.0.0",
"manifest_version": 3,
"background": {
"service_worker": "background.js",
"scripts": ["background.js"]
}
}
+16
View File
@@ -0,0 +1,16 @@
//
// USAGE:
// pnpm dev
// pnpm dev firefox-nightly
// pnpm dev <target>
//
import { run } from './src';
// Uncomment to enable debug logs
process.env.DEBUG = '@wxt-dev/runner';
await run({
extensionDir: 'demo-extension',
target: process.argv[2],
});
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@wxt-dev/runner",
"description": "Launch Chrome and Firefox with a web extension installed",
"version": "0.1.0",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/wxt-dev/wxt.git",
"directory": "packages/runner"
},
"homepage": "https://github.com/wxt-dev/wxt/tree/main/packages/runner#readme",
"keywords": [
"web-extension",
"chrome-extension",
"wxt"
],
"author": {
"name": "Aaron Klinker",
"email": "aaronklinker1+wxt@gmail.com"
},
"license": "MIT",
"funding": "https://github.com/sponsors/wxt-dev",
"scripts": {
"check": "pnpm build && check",
"test": "buildc --deps-only -- vitest",
"dev": "tsx --trace-warnings dev.ts",
"build": "buildc -- unbuild",
"prepublishOnly": "pnpm build"
},
"dependencies": {},
"devDependencies": {
"@aklinker1/check": "2.0.0",
"oxlint": "^0.16.8",
"publint": "^0.3.12",
"tsx": "4.19.4",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"vitest": "^3.1.2"
},
"main": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"files": [
"dist"
]
}
@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { createCdpConnection, type CDPConnection } from '../cdp';
import { createBidiConnection, type BidiConnection } from '../bidi';
import type { ChildProcess } from 'node:child_process';
import { installChromium, installFirefox } from '../install';
vi.mock('../cdp');
const createCdpConnectionMock = vi.mocked(createCdpConnection);
vi.mock('../bidi');
const createBidiConnectionMock = vi.mocked(createBidiConnection);
describe('Install', () => {
describe('Chromium', () => {
it('Should send the install command to the process', async () => {
const browserProcess = mock<ChildProcess>();
const connection = mock<CDPConnection>({
[Symbol.dispose]: vi.fn(),
});
const extensionDir = '/path/to/extension';
const expectedExtensionId = 'chromium-extension-id';
createCdpConnectionMock.mockReturnValue(connection);
connection.send.mockImplementation(async (method) => {
if (method === 'Extensions.loadUnpacked')
return { id: expectedExtensionId };
throw Error('Unknown method');
});
const res = await installChromium(browserProcess, extensionDir);
expect(createCdpConnectionMock).toBeCalledTimes(1);
expect(createCdpConnectionMock).toBeCalledWith(browserProcess);
expect(connection.send).toBeCalledTimes(1);
expect(connection.send).toBeCalledWith('Extensions.loadUnpacked', {
path: extensionDir,
});
expect(res).toEqual({ id: expectedExtensionId });
});
});
describe('Firefox', () => {
it('Should connect to the server, start a session, then install the extension', async () => {
const debuggerUrl = 'http://127.0.0.1:9222';
const extensionDir = '/path/to/extension';
const expectedExtensionId = 'firefox-extension-id';
const connection = mock<BidiConnection>({
[Symbol.dispose]: vi.fn(),
});
createBidiConnectionMock.mockResolvedValue(connection);
connection.send.mockImplementation(async (method) => {
if (method === 'session.new') return { sessionId: 'session-id' };
if (method === 'webExtension.install')
return { extension: expectedExtensionId };
});
const res = await installFirefox(debuggerUrl, extensionDir);
expect(createBidiConnectionMock).toBeCalledTimes(1);
expect(createBidiConnectionMock).toBeCalledWith(debuggerUrl);
expect(connection.send).toBeCalledTimes(2);
expect(connection.send).toBeCalledWith('session.new', {
capabilities: {},
});
expect(connection.send).toBeCalledWith('webExtension.install', {
extensionData: {
type: 'path',
path: extensionDir,
},
});
expect(res).toEqual({ extension: expectedExtensionId });
});
});
});
@@ -0,0 +1,306 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { ResolvedRunOptions, resolveRunOptions } from '../options';
import { resolve, join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { mkdir } from 'node:fs/promises';
vi.mock('node:os', async () => {
const { vi } = await import('vitest');
const os: any = await vi.importActual('node:os');
const { join } = await import('node:path');
return {
...os,
tmpdir: () => join(os.tmpdir(), 'tmpdir-mock'),
homedir: () => join(os.tmpdir(), 'homedir-mock'),
};
});
describe('Options', () => {
beforeAll(async () => {
// Make sure mock test directories exist
await mkdir(tmpdir(), { recursive: true });
await mkdir(homedir(), { recursive: true });
});
describe('extensionDir', () => {
it('should default to the current working directory', async () => {
const actual = await resolveRunOptions({});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
extensionDir: process.cwd(),
});
});
it('should resolve relative to the current working directory', async () => {
const actual = await resolveRunOptions({
extensionDir: './path/to/extension',
});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
extensionDir: resolve(process.cwd(), './path/to/extension'),
});
});
});
describe('target', () => {
it('should be "chrome" by default', async () => {
const actual = await resolveRunOptions({
extensionDir: 'path/to/extension',
});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
target: 'chrome',
});
});
it('should be what is passed in', async () => {
const actual = await resolveRunOptions({
extensionDir: 'path/to/extension',
target: 'custom',
browserBinaries: {
custom: '/path/to/custom/browser',
},
});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
target: 'custom',
});
});
it('should throw an error if the target binary could not be found', async () => {
const actual = resolveRunOptions({
extensionDir: 'path/to/extension',
target: 'custom',
});
await expect(actual).rejects.toThrow('Could not find "custom" binary.');
});
});
describe('browserBinary', () => {
it('should denormalize the browserBinary', async () => {
const path =
process.platform === 'win32'
? 'C:/path/to/custom/browser.exe'
: '/path/to/custom/browser';
const expectedPath =
process.platform === 'win32'
? 'C:\\path\\to\\custom\\browser.exe'
: path;
const actual = await resolveRunOptions({
extensionDir: 'path/to/extension',
target: 'custom',
browserBinaries: {
custom: path,
},
});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
browserBinary: expectedPath,
});
});
});
describe('chromiumArgs', () => {
it('should log a warning when --user-data-dir is passed in', async () => {
const warnSpy = vi.spyOn(console, 'warn');
await resolveRunOptions({
chromiumArgs: ['--user-data-dir=some/custom/path'],
});
expect(warnSpy).toBeCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Custom Chromium --user-data-dir argument ignored',
),
);
});
it('should log a warning when --remote-debugging-port is passed in', async () => {
const warnSpy = vi.spyOn(console, 'warn');
await resolveRunOptions({
chromiumArgs: ['--remote-debugging-port=9222'],
});
expect(warnSpy).toBeCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Custom Chromium --remote-debugging-port argument ignored',
),
);
});
it('should combine default args with user provided ones', async () => {
const actual = await resolveRunOptions({
chromiumArgs: ['--window-size=1920,1080'],
});
expect(actual.chromiumArgs).toEqual([
// Defaults
'--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider,CalculateNativeWinOcclusion,InterestFeedContentSuggestions,CertificateTransparencyComponentUpdater,AutofillServerCommunication,PrivacySandboxSettings4',
'--disable-component-extensions-with-background-pages',
'--disable-background-networking',
'--disable-component-update',
'--disable-client-side-phishing-detection',
'--disable-sync',
'--metrics-recording-only',
'--disable-default-apps',
'--no-default-browser-check',
'--no-first-run',
'--disable-background-timer-throttling',
'--disable-ipc-flooding-protection',
'--password-store=basic',
'--use-mock-keychain',
'--force-fieldtrials=*BackgroundTracing/default/',
'--disable-hang-monitor',
'--disable-prompt-on-repost',
'--disable-domain-reliability',
'--propagate-iph-for-testing',
// Debugging
expect.stringContaining('--remote-debugging-port='),
'--remote-debugging-pipe',
expect.stringContaining('--user-data-dir='), // See dataPersistence tests
'--enable-unsafe-extension-debugging',
// User provided
'--window-size=1920,1080',
]);
});
});
describe('firefoxArgs', () => {
it('should log a warning when --profile is passed in', async () => {
const warnSpy = vi.spyOn(console, 'warn');
await resolveRunOptions({
firefoxArgs: ['--profile=some/custom/path'],
});
expect(warnSpy).toBeCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Custom Firefox --profile argument ignored'),
);
});
it('should log a warning when --remote-debugging-port is passed in', async () => {
const warnSpy = vi.spyOn(console, 'warn');
await resolveRunOptions({
firefoxArgs: ['--remote-debugging-port=9222'],
});
expect(warnSpy).toBeCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Custom Firefox --remote-debugging-port argument ignored',
),
);
});
it('should combine default args with user provided ones', async () => {
const actual = await resolveRunOptions({
firefoxArgs: ['--window-size=1920,1080'],
});
expect(actual.firefoxArgs).toEqual([
// Defaults
'--new-instance',
'--no-remote',
'--profile',
expect.any(String), // See dataPersistence tests
expect.stringContaining('--remote-debugging-port='),
'about:debugging#/runtime/this-firefox',
// User provided
'--window-size=1920,1080',
]);
});
});
describe('chromiumRemoteDebuggingPort', () => {
it('should default to 0', async () => {
const actual = await resolveRunOptions({});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
chromiumRemoteDebuggingPort: 0,
chromiumArgs: expect.arrayContaining([`--remote-debugging-port=0`]),
});
});
it('should respect user provided port', async () => {
const actual = await resolveRunOptions({
chromiumRemoteDebuggingPort: 9222,
});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
chromiumRemoteDebuggingPort: 9222,
chromiumArgs: expect.arrayContaining([`--remote-debugging-port=9222`]),
});
});
});
describe('firefoxRemoteDebuggingPort', () => {
it('should default to 0', async () => {
const actual = await resolveRunOptions({});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
firefoxRemoteDebuggingPort: 0,
firefoxArgs: expect.arrayContaining([`--remote-debugging-port=0`]),
});
});
it('should respect user provided port', async () => {
const actual = await resolveRunOptions({
firefoxRemoteDebuggingPort: 9222,
});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
firefoxRemoteDebuggingPort: 9222,
firefoxArgs: expect.arrayContaining([`--remote-debugging-port=9222`]),
});
});
});
describe('dataPersistence', () => {
it('should default to "none"', async () => {
const actual = await resolveRunOptions({});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
dataPersistence: 'none',
});
});
it('should use a temporary directory when set to "none"', async () => {
const actual = await resolveRunOptions({
dataPersistence: 'none',
});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
dataPersistence: 'none',
dataDir: expect.stringContaining(join(tmpdir(), 'wxt-runner-')),
chromiumArgs: expect.arrayContaining([
expect.stringContaining(
`--user-data-dir=${join(tmpdir(), 'wxt-runner-')}`,
),
]),
firefoxArgs: expect.arrayContaining([
expect.stringContaining(join(tmpdir(), 'wxt-runner-')),
]),
});
});
it('should use a directory in the current working directory when set to "project"', async () => {
const actual = await resolveRunOptions({
dataPersistence: 'project',
});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
dataPersistence: 'project',
dataDir: expect.stringContaining(join(process.cwd(), '.wxt-runner')),
chromiumArgs: expect.arrayContaining([
expect.stringContaining(
`--user-data-dir=${join(process.cwd(), '.wxt-runner')}`,
),
]),
firefoxArgs: expect.arrayContaining([
expect.stringContaining(join(process.cwd(), '.wxt-runner')),
]),
});
});
it('should use a directory in the user\'s home directory when set to "user"', async () => {
const actual = await resolveRunOptions({
dataPersistence: 'user',
});
expect(actual).toMatchObject<Partial<ResolvedRunOptions>>({
dataPersistence: 'user',
dataDir: expect.stringContaining(join(homedir(), '.wxt-runner')),
chromiumArgs: expect.arrayContaining([
expect.stringContaining(
`--user-data-dir=${join(homedir(), '.wxt-runner')}`,
),
]),
firefoxArgs: expect.arrayContaining([
expect.stringContaining(join(homedir(), '.wxt-runner')),
]),
});
});
});
});
+75
View File
@@ -0,0 +1,75 @@
import { openWebSocket } from './web-socket';
import { debug } from './debug';
const debugBidi = debug.scoped('bidi');
export interface BidiConnection extends Disposable {
send<T>(method: string, params: any, timeout?: number): Promise<T>;
close(): void;
}
export async function createBidiConnection(
baseUrl: string,
): Promise<BidiConnection> {
const url = new URL('/session', baseUrl);
debugBidi('Connecting to BiDi server @', url.href);
const webSocket = await openWebSocket(url.href);
debugBidi('Connected');
let requestId = 0;
return {
send(method, params, timeout = 10e3) {
const id = ++requestId;
const command = { id, method, params };
debugBidi('Sending command:', command);
return new Promise((resolve, reject) => {
const cleanup = () => {
webSocket.removeEventListener('message', onMessage);
webSocket.removeEventListener('error', onError);
};
setTimeout(() => {
cleanup();
reject(
new Error(
`Timed out after ${timeout}ms waiting for ${method} response`,
),
);
}, timeout);
const onMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
if (data.id === id) {
debugBidi('Received response:', data);
cleanup();
if (data.type === 'success') resolve(data.result);
else reject(Error(data.message, { cause: data }));
}
};
const onError = (error: any) => {
cleanup();
reject(new Error('Error sending request', { cause: error }));
};
webSocket.addEventListener('message', onMessage);
webSocket.addEventListener('error', onError);
webSocket.send(JSON.stringify(command));
});
},
close() {
debugBidi('Closing connection...');
webSocket.close();
debugBidi('Closed connection');
},
[Symbol.dispose]() {
debugBidi('Disposing connection...');
webSocket.close();
debugBidi('Disposed connection');
},
};
}
+120
View File
@@ -0,0 +1,120 @@
export type BrowserPlatform = 'windows' | 'mac' | 'linux';
export type KnownTarget =
| 'chromium'
| 'chrome'
| 'chrome-beta'
| 'chrome-dev'
| 'chrome-canary'
| 'edge'
| 'edge-beta'
| 'edge-dev'
| 'edge-canary'
| 'firefox'
| 'firefox-nightly'
| 'firefox-developer-edition'
| 'zen';
export const KNOWN_BROWSER_PATHS: Record<
KnownTarget,
Record<BrowserPlatform, string[]>
> = {
// Chromium based targets
chromium: {
mac: [],
linux: [
// Arch
'/usr/bin/chromium',
],
windows: [],
},
chrome: {
mac: ['/Applications/Chrome.app/Contents/MacOS/Google Chrome'],
linux: [],
windows: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'],
},
'chrome-beta': {
mac: [],
linux: [],
windows: [],
},
'chrome-canary': {
mac: [
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
],
linux: [],
windows: [],
},
'chrome-dev': {
mac: [],
linux: [],
windows: [],
},
edge: {
mac: [],
linux: [],
windows: [],
},
'edge-beta': {
mac: [],
linux: [],
windows: [],
},
'edge-canary': {
mac: [],
linux: [],
windows: [],
},
'edge-dev': {
mac: [],
linux: [],
windows: [],
},
// Firefox based targets
firefox: {
mac: ['/Applications/Firefox.app/Contents/MacOS/firefox'],
linux: [
// Arch
'/usr/bin/firefox',
],
windows: [],
},
'firefox-nightly': {
mac: ['/Applications/Firefox Nightly.app/Contents/MacOS/firefox'],
linux: [],
windows: ['C:\\Program Files\\Firefox Nightly\\firefox.exe'],
},
'firefox-developer-edition': {
mac: ['/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox'],
linux: [
// Arch
'/usr/bin/firefox-developer-edition',
],
windows: [],
},
zen: {
mac: ['/Applications/Zen Browser.app/Contents/MacOS/zen'],
linux: [],
windows: [],
},
};
/**
* When targeting a browser, this map contains the other targets to fall back on when a binary could not be found for the primary target.
*/
export const FALLBACK_TARGETS: Partial<Record<KnownTarget, KnownTarget[]>> = {
chrome: [
'chromium',
'chrome-canary',
'chrome-beta',
'chrome-dev',
'edge',
'edge-canary',
'edge-beta',
'edge-dev',
],
firefox: ['firefox-developer-edition', 'firefox-nightly', 'zen'],
};
+57
View File
@@ -0,0 +1,57 @@
import type { ChildProcess } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import { debug } from './debug';
const debugCdp = debug.scoped('cdp');
export interface CDPConnection extends Disposable {
send<T>(method: string, params: any, timeout?: number): Promise<T>;
close(): void;
}
export function createCdpConnection(
browserProcess: ChildProcess,
): CDPConnection {
const inputStream = browserProcess.stdio[3] as Writable;
const outputStream = browserProcess.stdio[4] as Readable;
let requestId = 0;
return {
send(method, params, timeout = 10e3) {
const id = ++requestId;
const command = { id, method, params };
debugCdp('Sending command:', command);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`CDP command timed out: ${method}`));
}, timeout);
const onData = (data: Buffer) => {
// Trim the trailing null character
const text = data.toString().slice(0, -1);
const res = JSON.parse(text);
if (res.id !== id) return;
debugCdp('Received response:', res);
clearTimeout(timer);
outputStream.removeListener('data', onData);
if ('error' in res) {
reject(new Error(res.error.message, { cause: res.error }));
} else {
resolve(res.result);
}
};
outputStream.addListener('data', onData);
inputStream.write(JSON.stringify(command) + '\0');
});
},
close() {},
[Symbol.dispose]() {},
};
}
+24
View File
@@ -0,0 +1,24 @@
export interface Debug {
(...args: any[]): void;
scoped: (scope: string) => Debug;
}
function createDebug(scopes: string[]): Debug {
const debug = (...args: any[]) => {
const scope = scopes.join(':');
if (
process.env.DEBUG === '1' ||
process.env.DEBUG === 'true' ||
scope.startsWith(process.env.DEBUG ?? '@NOT')
) {
const params = scope ? [`\x1b[31m${scope}\x1b[0m`, ...args] : args;
console.log(...params);
}
};
debug.scoped = (scope: string) => createDebug([...scopes, scope]);
return debug;
}
export const debug = createDebug(['@wxt-dev/runner']);
+3
View File
@@ -0,0 +1,3 @@
export * from './run';
export * from './options';
export * from './install';
+59
View File
@@ -0,0 +1,59 @@
import type { ChildProcess } from 'node:child_process';
import { createBidiConnection } from './bidi';
import { createCdpConnection } from './cdp';
/**
* Install an extension to an already running instance of Firefox.
* @param debuggerUrl The URL of the Firefox BiDi server (ex: `ws://127.0.0.1:45912`).
* @param extensionDir Absolute path to the directory containing the extension to be installed.
*/
export async function installFirefox(
debuggerUrl: string,
extensionDir: string,
): Promise<BidiWebExtensionInstallResponse> {
using bidi = await createBidiConnection(debuggerUrl);
// Start a session
await bidi.send<unknown>('session.new', { capabilities: {} });
// Install the extension
return await bidi.send<BidiWebExtensionInstallResponse>(
'webExtension.install',
{
extensionData: {
type: 'path',
path: extensionDir,
},
},
);
}
export type BidiWebExtensionInstallResponse = {
extension: string;
};
/**
* Given a child process of Chrome, install an extension. The process must be started with the following flags:
*
* - `--remote-debugging-pipe`
* - `--user-data-dir=...`
* - `--enable-unsafe-extension-debugging`
*
* Otherwise it the CDP doesn't have permission to install extensions.
*/
export async function installChromium(
browserProcess: ChildProcess,
extensionDir: string,
): Promise<CdpExtensionsLoadUnpackedResponse> {
using cdp = createCdpConnection(browserProcess);
return await cdp.send<CdpExtensionsLoadUnpackedResponse>(
'Extensions.loadUnpacked',
{
path: extensionDir,
},
);
}
export type CdpExtensionsLoadUnpackedResponse = {
id: string;
};
+286
View File
@@ -0,0 +1,286 @@
import {
FALLBACK_TARGETS,
KNOWN_BROWSER_PATHS,
KnownTarget,
type BrowserPlatform,
} from './browser-paths';
import { resolve, join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { debug } from './debug';
import { mkdtemp, open } from 'node:fs/promises';
const debugOptions = debug.scoped('options');
export type UnknownTarget = string & {};
export type Target = KnownTarget | UnknownTarget;
export type RunOptions = {
/** Paths to binaries to use for each target. */
browserBinaries?: Record<string, string>;
/** Customize the arguments passed to the chromium binary. Conflicting arguments with required ones to install extensions are ignored. */
chromiumArgs?: string[];
/** Control how data is persisted between launches. Either save data at a user level, project level, or don't persist data at all. Defaults to `project`. */
dataPersistence?: 'user' | 'project' | 'none';
/** Customize where your profile's data is stored when using `dataPersistence: 'project'`. Can be absolute or relative to the current working directory. */
projectDataDir?: string;
/** Customize the port Chrome's debugger is listening on. Defaults to a random open port. */
chromiumRemoteDebuggingPort?: number;
/** Directory where the extension will be installed from. Should contain a `manifest.json` file. Can be relative to the current working directory. Defaults to the current working directory. */
extensionDir?: string;
/** Customize the arguments passed to the firefox binary. Conflicting arguments with required ones to install extensions are ignored. */
firefoxArgs?: string[];
/** Customize the port Firefox's debugger is listening on. Defaults to a random open port. */
firefoxRemoteDebuggingPort?: number;
/** Specify the browser to open. Defaults to `"chrome"`, but you can pass any string. */
target?: Target;
};
export type ResolvedRunOptions = {
/** Absolute path to the browser binary. */
browserBinary: string;
chromiumArgs: string[];
chromiumRemoteDebuggingPort: number;
/** Absolute path to the directory where browser data will be stored. */
dataDir: string;
dataPersistence: 'user' | 'project' | 'none';
/** Absolute path to the extension directory. */
extensionDir: string;
firefoxArgs: string[];
firefoxRemoteDebuggingPort: number;
target: string;
};
export async function resolveRunOptions(
options: RunOptions | undefined,
): Promise<ResolvedRunOptions> {
debugOptions('User options:', options);
const target = options?.target || 'chrome';
const _browserBinary =
options?.browserBinaries?.[target] ?? (await findBrowserBinary(target));
if (!_browserBinary)
throw Error(
`Could not find "${target}" binary.\n\nIf it is installed in a custom location, you can specify the path with the browserPaths option.`,
);
// Denormalize the path so it uses the correct path separator for the OS
const browserBinary = resolve(_browserBinary);
const chromiumRemoteDebuggingPort = options?.chromiumRemoteDebuggingPort ?? 0;
const firefoxRemoteDebuggingPort = options?.firefoxRemoteDebuggingPort ?? 0;
const dataPersistence = options?.dataPersistence ?? 'none';
const dataDir =
dataPersistence === 'user'
? join(homedir(), '.wxt-runner', target)
: dataPersistence === 'project'
? options?.projectDataDir
? resolve(options.projectDataDir)
: resolve('.wxt-runner', target)
: dataPersistence === 'none'
? await mkdtemp(join(tmpdir(), 'wxt-runner-'))
: resolve(dataPersistence);
const resolved: ResolvedRunOptions = {
browserBinary,
chromiumArgs: resolveChromiumArgs(
options?.chromiumArgs,
chromiumRemoteDebuggingPort,
dataDir,
),
dataDir,
dataPersistence,
chromiumRemoteDebuggingPort,
extensionDir: resolve(options?.extensionDir ?? '.'),
firefoxArgs: resolveFirefoxArgs(
options?.firefoxArgs,
firefoxRemoteDebuggingPort,
dataDir,
),
firefoxRemoteDebuggingPort,
target,
};
debugOptions('Resolved options:', resolved);
return resolved;
}
async function findBrowserBinary(target: string): Promise<string | undefined> {
const targets = new Set<KnownTarget>([target as KnownTarget]);
FALLBACK_TARGETS[target as KnownTarget]?.forEach((fallback) =>
targets.add(fallback),
);
const platform = getPlatform();
for (const target of targets) {
const potentialPaths = KNOWN_BROWSER_PATHS[target]?.[platform] ?? [];
for (const path of potentialPaths) {
if (await exists(path)) return path;
}
}
}
function getPlatform(): BrowserPlatform {
switch (process.platform) {
case 'win32':
return 'windows';
case 'darwin':
return 'mac';
default:
return 'linux';
}
}
function resolveChromiumArgs(
userArgs: string[] | undefined,
chromiumRemoteDebuggingPort: ResolvedRunOptions['chromiumRemoteDebuggingPort'],
dataDir: string,
): string[] {
return deduplicateArgs(
[
// Limit features to improve performance
...CHROME_LAUNCHER_DEFAULT_FLAGS,
// Enable debugging
`--remote-debugging-port=${chromiumRemoteDebuggingPort}`,
// Required for installing extensions
`--remote-debugging-pipe`,
`--user-data-dir=${dataDir}`,
`--enable-unsafe-extension-debugging`,
],
userArgs,
{
'--remote-debugging-port':
'\x1b[1m\x1b[33mCustom Chromium --remote-debugging-port argument ignored.\x1b[0m Use \x1b[36mchromiumRemoteDebuggingPort\x1b[0m option instead.',
'--user-data-dir':
'\x1b[1m\x1b[33mCustom Chromium --user-data-dir argument ignored.\x1b[0m Use \x1b[36mdataPersistence\x1b[0m option instead.',
},
);
}
function resolveFirefoxArgs(
userArgs: string[] | undefined,
firefoxRemoteDebuggingPort: ResolvedRunOptions['firefoxRemoteDebuggingPort'],
dataDir: string,
): string[] {
return deduplicateArgs(
[
// Allows opening multiple instances of Firefox at the same time
`--new-instance`,
`--no-remote`,
`--profile`,
dataDir,
// Required for installing extensions
`--remote-debugging-port=${firefoxRemoteDebuggingPort}`,
// Default URL to start with
`about:debugging#/runtime/this-firefox`,
],
userArgs,
{
'--remote-debugging-port':
'\x1b[1m\x1b[33mCustom Firefox --remote-debugging-port argument ignored.\x1b[0m Use \x1b[36mfirefoxDebuggerPort\x1b[0m option instead.',
'--profile':
'\x1b[1m\x1b[33mCustom Firefox --profile argument ignored.\x1b[0m Use \x1b[36mdataPersistence\x1b[0m option instead.',
},
);
}
function deduplicateArgs(
requiredArgs: string[],
userArgs: string[] | undefined,
warnings: Record<string, string>,
): string[] {
const getKey = (arg: string) => {
return arg.startsWith('--') ? arg.split('=')[0] : arg;
};
const alreadyAdded = new Set<string>(requiredArgs.map(getKey));
const args = [...requiredArgs];
userArgs?.forEach((arg) => {
const key = getKey(arg);
if (alreadyAdded.has(key)) {
if (warnings[key]) console.warn(`[@wxt-dev/runner] ${warnings[key]}`);
} else {
alreadyAdded.add(key);
args.push(arg);
}
});
return args;
}
async function exists(path: string): Promise<boolean> {
try {
await open(path, 'r');
return true;
} catch (err) {
// @ts-expect-error: Unknown error type
if (err?.code === 'ENOENT') return false;
throw err;
}
}
/**
* Copied from https://github.com/GoogleChrome/chrome-launcher/blob/main/src/flags.ts
* with some flags commented out. Run tests after updating to compare.
*/
const CHROME_LAUNCHER_DEFAULT_FLAGS = [
'--disable-features=' +
[
// Disable built-in Google Translate service
'Translate',
// Disable the Chrome Optimization Guide background networking
'OptimizationHints',
// Disable the Chrome Media Router (cast target discovery) background networking
'MediaRouter',
/// Avoid the startup dialog for _Do you want the application “Chromium.app” to accept incoming network connections?_. This is a sub-component of the MediaRouter.
'DialMediaRouteProvider',
// Disable the feature of: Calculate window occlusion on Windows will be used in the future to throttle and potentially unload foreground tabs in occluded windows.
'CalculateNativeWinOcclusion',
// Disables the Discover feed on NTP
'InterestFeedContentSuggestions',
// Don't update the CT lists
'CertificateTransparencyComponentUpdater',
// Disables autofill server communication. This feature isn't disabled via other 'parent' flags.
'AutofillServerCommunication',
// Disables "Enhanced ad privacy in Chrome" dialog (though as of 2024-03-20 it shouldn't show up if the profile has no stored country).
'PrivacySandboxSettings4',
].join(','),
// Disable some extensions that aren't affected by --disable-extensions
'--disable-component-extensions-with-background-pages',
// Disable various background network services, including extension updating,
// safe browsing service, upgrade detector, translate, UMA
'--disable-background-networking',
// Don't update the browser 'components' listed at chrome://components/
'--disable-component-update',
// Disables client-side phishing detection.
'--disable-client-side-phishing-detection',
// Disable syncing to a Google account
'--disable-sync',
// Disable reporting to UMA, but allows for collection
'--metrics-recording-only',
// Disable installation of default apps on first run
'--disable-default-apps',
// Disable the default browser check, do not prompt to set it as such
'--no-default-browser-check',
// Skip first run wizards
'--no-first-run',
// Disable task throttling of timer tasks from background pages.
'--disable-background-timer-throttling',
// Disable the default throttling of IPC between renderer & browser processes.
'--disable-ipc-flooding-protection',
// Avoid potential instability of using Gnome Keyring or KDE wallet. crbug.com/571003 crbug.com/991424
'--password-store=basic',
// Use mock keychain on Mac to prevent blocking permissions dialogs
'--use-mock-keychain',
// Disable background tracing (aka slow reports & deep reports) to avoid 'Tracing already started'
'--force-fieldtrials=*BackgroundTracing/default/',
// Suppresses hang monitor dialogs in renderer processes. This flag may allow slow unload handlers on a page to prevent the tab from closing.
'--disable-hang-monitor',
// Reloading a page that came from a POST normally prompts the user.
'--disable-prompt-on-repost',
// Disables Domain Reliability Monitoring, which tracks whether the browser has difficulty contacting Google-owned sites and uploads reports to Google.
'--disable-domain-reliability',
// Disable the in-product Help (IPH) system.
'--propagate-iph-for-testing',
];
+19
View File
@@ -0,0 +1,19 @@
export function promiseWithResolvers<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve: (value: T) => void;
let reject: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: resolve!,
reject: reject!,
};
}
+125
View File
@@ -0,0 +1,125 @@
import { debug } from './debug';
import {
resolveRunOptions,
type ResolvedRunOptions,
type RunOptions,
} from './options';
import { spawn } from 'node:child_process';
import { installChromium, installFirefox } from './install';
import { promiseWithResolvers } from './promises';
const debugFirefox = debug.scoped('firefox');
const debugChrome = debug.scoped('chrome');
export interface Runner {
stop(): void;
}
export async function run(options: RunOptions): Promise<Runner> {
const resolvedOptions = await resolveRunOptions(options);
if (
resolvedOptions.target.includes('firefox') ||
resolvedOptions.target.includes('zen')
) {
return runFirefox(resolvedOptions);
} else {
return runChromium(resolvedOptions);
}
}
async function runFirefox(options: ResolvedRunOptions): Promise<Runner> {
const urlRes = promiseWithResolvers<string>();
const urlTimeout = setTimeout(() => {
urlRes.reject(Error('Timed out after 10s waiting for the browser to open'));
}, 10e3);
// Firefox notifies the user if an instance is already running, so we don't add any logs for it.
const browserProcess = spawn(
`"${options.browserBinary}"`,
options.firefoxArgs,
{
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
},
);
const debugFirefoxStderr = debugFirefox.scoped('stderr');
browserProcess.stderr.on('data', (data: string) => {
const message = data.toString().trim();
debugFirefoxStderr(message);
if (message.startsWith('WebDriver BiDi listening on ws://')) {
clearTimeout(urlTimeout);
urlRes.resolve(message.slice(28));
}
});
const debugFirefoxStdout = debugFirefox.scoped('stdout');
browserProcess.stdout.on('data', (data: string) => {
const message = data.toString().trim();
debugFirefoxStdout(message);
});
const baseUrl = await urlRes.promise;
await installFirefox(baseUrl, options.extensionDir);
return {
stop() {
browserProcess.kill('SIGINT');
},
};
}
async function runChromium(options: ResolvedRunOptions): Promise<Runner> {
const browserProcess = spawn(
`"${options.browserBinary}"`,
options.chromiumArgs,
{
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
shell: true,
},
);
const opened = promiseWithResolvers<void>();
const openedTimeout = setTimeout(() => {
opened.reject(Error('Timed out after 10s waiting for browser to open.'));
}, 10e3);
const debugChromeStderr = debugChrome.scoped('stderr');
browserProcess.stderr!.on('data', (data: string) => {
const message = data.toString().trim();
debugChromeStderr(message);
// This message signifies Chrome started up correctly.
if (message.startsWith('DevTools listening on')) {
clearTimeout(openedTimeout);
opened.resolve();
}
});
const debugChromeStdout = debugChrome.scoped('stdout');
browserProcess.stdout!.on('data', (data: string) => {
const message = data.toString().trim();
debugChromeStdout(message);
// This message signifies Chrome was already open, and thus we couldn't open the required new instance.
if (message === 'Opening in existing browser session.') {
clearTimeout(openedTimeout);
opened.reject(
Error(
'An instance of the browser is already running. Close it and try again.',
),
);
}
});
// Wait for the browser to open before proceeding.
await opened.promise;
await installChromium(browserProcess, options.extensionDir);
return {
stop() {
browserProcess.kill('SIGINT');
},
};
}
+37
View File
@@ -0,0 +1,37 @@
export function openWebSocket(url: string): Promise<WebSocket> {
if (typeof WebSocket === 'undefined') {
throw new Error(
'To open Firefox, your JS runtime must support the standard WebSocket API (NodeJS >=22.4.0, Bun, etc).',
);
}
return new Promise<WebSocket>((resolve, reject) => {
const webSocket = new WebSocket(url);
const cleanup = () => {
webSocket.removeEventListener('open', onOpen);
webSocket.removeEventListener('error', onError);
webSocket.removeEventListener('close', onClose);
};
const onOpen = async () => {
cleanup();
resolve(webSocket);
};
const onClose = (event: CloseEvent) => {
cleanup();
reject(
new Error(
`Connection closed: code=${event.code}, reason=${event.reason}`,
),
);
};
const onError = (error: any) => {
cleanup();
reject(new Error('Error connecting to WebSocket', { cause: error }));
};
webSocket.addEventListener('open', onOpen);
webSocket.addEventListener('error', onError);
webSocket.addEventListener('close', onClose);
});
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
mockReset: true,
restoreMocks: true,
},
});
+10 -10
View File
@@ -30,18 +30,18 @@
"test": "buildc --deps-only -- vitest"
},
"dependencies": {
"async-mutex": "catalog:",
"dequal": "catalog:"
"@wxt-dev/browser": "workspace:^",
"async-mutex": "^0.5.0",
"dequal": "^2.0.3"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"@types/chrome": "catalog:",
"@webext-core/fake-browser": "catalog:",
"oxlint": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"vitest": "catalog:"
"@aklinker1/check": "2.0.0",
"@webext-core/fake-browser": "^1.3.2",
"oxlint": "^0.16.8",
"publint": "^0.3.12",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"vitest": "^3.1.2"
},
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+23 -4
View File
@@ -1,6 +1,7 @@
import { fakeBrowser } from '@webext-core/fake-browser';
import { describe, it, expect, beforeEach, vi, expectTypeOf } from 'vitest';
import { MigrationError, type WxtStorageItem, storage } from '../index';
import { browser } from '@wxt-dev/browser';
/**
* This works because fakeBrowser is synchronous, and is will finish any number of chained
@@ -222,7 +223,7 @@ describe('Storage Utils', () => {
describe('setMeta', () => {
it('should set metadata at key+$', async () => {
const existing = { v: 1 };
await chrome.storage[storageArea].set({ count$: existing });
await browser.storage[storageArea].set({ count$: existing });
const newValues = {
date: Date.now(),
};
@@ -238,7 +239,7 @@ describe('Storage Utils', () => {
'should remove any properties set to %s',
async (version) => {
const existing = { v: 1 };
await chrome.storage[storageArea].set({ count$: existing });
await browser.storage[storageArea].set({ count$: existing });
const expected = {};
await storage.setMeta(`${storageArea}:count`, { v: version });
@@ -979,7 +980,7 @@ describe('Storage Utils', () => {
const actual = await item.getMeta();
expect(actual).toBe(expected);
expect(actual).toEqual(expected);
});
it('should return an empty object if missing', async () => {
@@ -1265,7 +1266,7 @@ describe('Storage Utils', () => {
await item.removeValue();
// Make sure it's actually blank before running the test
expect(await chrome.storage.local.get()).toEqual({});
expect(await browser.storage.local.get()).toEqual({});
init.mockClear();
const [value1, value2] = await Promise.all([
@@ -1281,6 +1282,19 @@ describe('Storage Utils', () => {
it('should define a nullable value when options are not passed', () => {
const item = storage.defineItem<number>(`local:test`);
expectTypeOf(item).toEqualTypeOf<WxtStorageItem<number | null, {}>>();
const item2 = storage.defineItem<number>(`local:test`, {});
expectTypeOf(item2).toEqualTypeOf<WxtStorageItem<number | null, {}>>();
const item3 = storage.defineItem<number>(`local:test`, {
fallback: undefined,
});
expectTypeOf(item3).toEqualTypeOf<WxtStorageItem<number | null, {}>>();
const item4 = storage.defineItem<number>(`local:test`, {
defaultValue: undefined,
});
expectTypeOf(item4).toEqualTypeOf<WxtStorageItem<number | null, {}>>();
});
it('should define a non-null value when options are passed with a nullish default value', () => {
@@ -1288,6 +1302,11 @@ describe('Storage Utils', () => {
defaultValue: 123,
});
expectTypeOf(item).toEqualTypeOf<WxtStorageItem<number, {}>>();
const item2 = storage.defineItem(`local:test`, {
fallback: 123,
});
expectTypeOf(item2).toEqualTypeOf<WxtStorageItem<number, {}>>();
});
it('should define a nullable value when options are passed with null default value', () => {
+11 -10
View File
@@ -1,4 +1,3 @@
/// <reference types="chrome" />
/**
* Simplified storage APIs with support for versioned fields, snapshots, metadata, and item definitions.
*
@@ -7,13 +6,7 @@
*/
import { dequal } from 'dequal/lite';
import { Mutex } from 'async-mutex';
const browser: typeof chrome =
// @ts-expect-error
globalThis.browser?.runtime?.id == null
? globalThis.chrome
: // @ts-expect-error
globalThis.browser;
import { browser, type Browser } from '@wxt-dev/browser';
export const storage = createStorage();
@@ -750,8 +743,16 @@ export interface WxtStorage {
): WxtStorageItem<TValue | null, TMetadata>;
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: StorageItemKey,
options: WxtStorageItemOptions<TValue>,
options: WxtStorageItemOptions<TValue> & { fallback: TValue },
): WxtStorageItem<TValue, TMetadata>;
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: StorageItemKey,
options: WxtStorageItemOptions<TValue> & { defaultValue: TValue },
): WxtStorageItem<TValue, TMetadata>;
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: StorageItemKey,
options: WxtStorageItemOptions<TValue>,
): WxtStorageItem<TValue | null, TMetadata>;
}
interface WxtStorageDriver {
@@ -889,7 +890,7 @@ export interface WxtStorageItemOptions<T> {
}
export type StorageAreaChanges = {
[key: string]: chrome.storage.StorageChange;
[key: string]: Browser.storage.StorageChange;
};
/**
+1 -2
View File
@@ -1,8 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"verbatimModuleSyntax": true,
"types": ["chrome"]
"verbatimModuleSyntax": true
},
"exclude": ["node_modules/**", "dist/**"]
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default defineConfig({
Now in your entrypoint, import UnoCSS:
```ts
import 'uno.css';
import 'virtual:uno.css';
```
> [!IMPORTANT]
+8 -8
View File
@@ -40,16 +40,16 @@
"wxt": ">=0.19.0"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"oxlint": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"unocss": "catalog:",
"@aklinker1/check": "2.0.0",
"oxlint": "^0.16.8",
"publint": "^0.3.12",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"unocss": "^0.64.0 || ^0.65.0 || ^65.0.0 || ^66.0.0",
"wxt": "workspace:*"
},
"dependencies": {
"defu": "catalog:",
"fast-glob": "catalog:"
"defu": "^6.1.4",
"fast-glob": "^3.3.3"
}
}
+6 -6
View File
@@ -45,12 +45,12 @@
"wxt": ">=0.20.0"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"@types/webextension-polyfill": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"webextension-polyfill": "catalog:",
"@aklinker1/check": "2.0.0",
"@types/webextension-polyfill": "^0.12.3",
"publint": "^0.3.12",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"webextension-polyfill": "^0.12.0",
"wxt": "workspace:*"
}
}
+9 -9
View File
@@ -18,19 +18,19 @@
},
"dependencies": {
"@wxt-dev/i18n": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@wxt-dev/auto-icons": "workspace:*",
"@wxt-dev/unocss": "workspace:*",
"sass": "catalog:",
"typescript": "catalog:",
"unocss": "catalog:",
"vitest": "catalog:",
"vitest-plugin-random-seed": "catalog:",
"sass": "^1.87.0",
"typescript": "^5.8.3",
"unocss": "^0.64.0 || ^0.65.0 || ^65.0.0 || ^66.0.0",
"vitest": "^3.1.2",
"vitest-plugin-random-seed": "^1.1.1",
"wxt": "workspace:*"
},
"buildc": {
+2 -1
View File
@@ -3,6 +3,7 @@ import { presetUno } from 'unocss';
export default defineConfig({
srcDir: 'src',
targetBrowsers: ['chrome', 'firefox', 'safari'],
manifest: {
permissions: ['storage'],
default_locale: 'en',
@@ -19,7 +20,7 @@ export default defineConfig({
analysis: {
open: true,
},
runner: {
webExt: {
startUrls: ['https://duckduckgo.com'],
},
example: {
+111 -14
View File
@@ -1,8 +1,118 @@
# Changelog
## v0.20.6
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.5...wxt-v0.20.6)
## v0.20.5
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.4...wxt-v0.20.5)
### 🩹 Fixes
- Don't use crypto.randUUID for shadow root UIs ([3577c0b](https://github.com/wxt-dev/wxt/commit/3577c0b))
### ❤️ Contributors
- Aaron ([@aklinker1](https://github.com/aklinker1))
## v0.20.4
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.3...wxt-v0.20.4)
### 🚀 Enhancements
- Ignore popup/index.ts instead of erroring ([#1520](https://github.com/wxt-dev/wxt/pull/1520))
- Ignore elements with a `vite-ignore` or `wxt-ignore` attribute ([#1603](https://github.com/wxt-dev/wxt/pull/1603))
- Add `{{packageVersion}}` as template variable ([#1604](https://github.com/wxt-dev/wxt/pull/1604))
### 🩹 Fixes
- Adding missing `""` to `PublicPath` and `browser.runtime.getUrl` ([#1597](https://github.com/wxt-dev/wxt/pull/1597))
- Fix CORS error in Firefox ([#1607](https://github.com/wxt-dev/wxt/pull/1607))
### 📖 Documentation
- **Content Script UI:** Add additional details about when `onRemove` is called ([656a9b3](https://github.com/wxt-dev/wxt/commit/656a9b3))
### ❤️ Contributors
- Yunsup Sim ([@SimYunSup](https://github.com/SimYunSup))
- ТΞNSΛI ([@Tensai75](https://github.com/Tensai75))
- Nishu ([@nishu-murmu](https://github.com/nishu-murmu))
- Aaron ([@aklinker1](https://github.com/aklinker1))
## v0.20.3
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.2...wxt-v0.20.3)
### 🚀 Enhancements
- Automatically place document-level CSS outside shadow root ([#1594](https://github.com/wxt-dev/wxt/pull/1594))
### 🩹 Fixes
- Fix double hashing of inline script keys ([b0f4ac8](https://github.com/wxt-dev/wxt/commit/b0f4ac8))
### ❤️ Contributors
- Aaron ([@aklinker1](https://github.com/aklinker1))
## v0.20.2
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.1...wxt-v0.20.2)
### 🩹 Fixes
- Fix hashing issue with inline scripts ([#1591](https://github.com/wxt-dev/wxt/pull/1591))
### 📖 Documentation
- Fix typo in changelog ([acb6cd1](https://github.com/wxt-dev/wxt/commit/acb6cd1))
### ❤️ Contributors
- Yunsup Sim ([@SimYunSup](https://github.com/SimYunSup))
- Aaron ([@aklinker1](https://github.com/aklinker1))
## v0.20.1
[compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.20.0...wxt-v0.20.1)
### 🚀 Enhancements
- Enable wxt usage inside of devcontainers ([#1406](https://github.com/wxt-dev/wxt/pull/1406))
- Type-safe `import.meta.env.BROWSER` with new `targetBrowsers` config ([#1574](https://github.com/wxt-dev/wxt/pull/1574))
### 🩹 Fixes
- Don't remove top-level destructured variable definitions when importing entrypoints ([#1561](https://github.com/wxt-dev/wxt/pull/1561))
- Add JSDoc type annotation to auto-imports for ESlint ([#1558](https://github.com/wxt-dev/wxt/pull/1558))
### 📖 Documentation
- Fix knowledge file generation ([#1550](https://github.com/wxt-dev/wxt/pull/1550))
### 🏡 Chore
- **deps:** Update all dependencies ([#1568](https://github.com/wxt-dev/wxt/pull/1568))
- Update comment ([61b42ef](https://github.com/wxt-dev/wxt/commit/61b42ef))
### ❤️ Contributors
- 7sDream ([@7sDream](https://github.com/7sDream))
- Aaron ([@aklinker1](https://github.com/aklinker1))
- Nostro ([@nostrorom](https://github.com/nostrorom))
- Khalil Yao ([@yyz945947732](https://github.com/yyz945947732))
- Alec WM ([@alecdwm](https://github.com/alecdwm))
## v0.20.0
[⚠️ breaking changes](https://wxt.dev/guide/upgrade-guide/wxt) &bull; [compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.29...wxt-v0.20.0)
Excited to release the next major version of WXT! Follow the [Upgrade guide](https://wxt.dev/guide/resources/upgrading.html) to update!
---
[⚠️ breaking changes](https://wxt.dev/guide/resources/upgrading.html) &bull; [compare changes](https://github.com/wxt-dev/wxt/compare/wxt-v0.19.29...wxt-v0.20.0)
### 🚀 Enhancements
@@ -33,19 +143,6 @@
- Fix type errors ([aad17c8](https://github.com/wxt-dev/wxt/commit/aad17c8))
- Remove duplicate test ([e54df0a](https://github.com/wxt-dev/wxt/commit/e54df0a))
#### ⚠️ Breaking Changes
- ⚠️ Remove `webextension-polyfill` ([#1084](https://github.com/wxt-dev/wxt/pull/1084))
- ⚠️ Individual exports and introduce the `#imports` module ([#1258](https://github.com/wxt-dev/wxt/pull/1258))
- ⚠️ Reset inherited styles inside shadow root ([#1269](https://github.com/wxt-dev/wxt/pull/1269))
- ⚠️ Auto-import types ([#1315](https://github.com/wxt-dev/wxt/pull/1315))
- ⚠️ Add suffix to non-production output directories ([#1086](https://github.com/wxt-dev/wxt/pull/1086))
- ⚠️ Remove deprecated `jiti` entrypoint loader ([#1087](https://github.com/wxt-dev/wxt/pull/1087))
- ⚠️ Rename `runner` to `webExt` ([#1180](https://github.com/wxt-dev/wxt/pull/1180))
- ⚠️ Remove `transformManfiest` option ([#1181](https://github.com/wxt-dev/wxt/pull/1181))
- ⚠️ Make `publicDir` and `modulesDir` relative to project root ([#1216](https://github.com/wxt-dev/wxt/pull/1216))
- ⚠️ Move `wxt/storage` to `wxt/utils/storage` ([#1271](https://github.com/wxt-dev/wxt/pull/1271))
### ❤️ Contributors
- Aaron ([@aklinker1](https://github.com/aklinker1))
@@ -110,6 +110,7 @@ export default {
name: "wxt/auto-imports",
languageOptions: {
globals,
/** @type {import('eslint').Linter.SourceType} */
sourceType: "module",
},
};
@@ -48,6 +48,7 @@ describe('TypeScript Project', () => {
declare module "wxt/browser" {
export type PublicPath =
| ""
| "/"
| "/options.html"
| "/popup.html"
@@ -396,6 +397,19 @@ describe('TypeScript Project', () => {
expect(output).toContain('./example.ts');
});
it('should set correct import.meta.env.BROWSER type based on targetBrowsers', async () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html', '<html></html>');
project.setConfigFileConfig({
targetBrowsers: ['firefox', 'chrome'],
});
await project.prepare();
const output = await project.serializeFile('.wxt/types/globals.d.ts');
expect(output).toContain('readonly BROWSER: "firefox" | "chrome";');
});
// TODO: Once a module has been published, use it here for testing - local files are never added to the .wxt/wxt.d.ts file
it.todo(
'should add modules from NPM to the TS project if they have a configKey',
+65 -61
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.20.0",
"version": "0.20.6",
"description": "⚡ Next-gen Web Extension Framework",
"license": "MIT",
"scripts": {
@@ -16,69 +16,69 @@
"prepack": "pnpm build"
},
"dependencies": {
"@1natsu/wait-element": "catalog:",
"@aklinker1/rollup-plugin-visualizer": "catalog:",
"@webext-core/fake-browser": "catalog:",
"@webext-core/isolated-element": "catalog:",
"@webext-core/match-patterns": "catalog:",
"@wxt-dev/browser": "workspace:*",
"@1natsu/wait-element": "^4.1.2",
"@aklinker1/rollup-plugin-visualizer": "5.12.0",
"@webext-core/fake-browser": "^1.3.2",
"@webext-core/isolated-element": "^1.1.2",
"@webext-core/match-patterns": "^1.0.3",
"@wxt-dev/browser": "workspace:^",
"@wxt-dev/storage": "workspace:^1.0.0",
"async-mutex": "catalog:",
"c12": "catalog:",
"cac": "catalog:",
"chokidar": "catalog:",
"ci-info": "catalog:",
"consola": "catalog:",
"defu": "catalog:",
"dotenv": "catalog:",
"dotenv-expand": "catalog:",
"esbuild": "catalog:",
"fast-glob": "catalog:",
"filesize": "catalog:",
"fs-extra": "catalog:",
"get-port-please": "catalog:",
"giget": "catalog:",
"hookable": "catalog:",
"import-meta-resolve": "catalog:",
"is-wsl": "catalog:",
"json5": "catalog:",
"jszip": "catalog:",
"linkedom": "catalog:",
"magicast": "catalog:",
"minimatch": "catalog:",
"nano-spawn": "catalog:",
"normalize-path": "catalog:",
"nypm": "catalog:",
"ohash": "catalog:",
"open": "catalog:",
"ora": "catalog:",
"perfect-debounce": "catalog:",
"picocolors": "catalog:",
"prompts": "catalog:",
"publish-browser-extension": "catalog:",
"scule": "catalog:",
"unimport": "catalog:",
"vite": "catalog:",
"vite-node": "catalog:",
"web-ext-run": "catalog:"
"async-mutex": "^0.5.0",
"c12": "^3.0.3",
"cac": "^6.7.14",
"chokidar": "^4.0.3",
"ci-info": "^4.2.0",
"consola": "^3.4.2",
"defu": "^6.1.4",
"dotenv": "^16.5.0",
"dotenv-expand": "^12.0.2",
"esbuild": "^0.25.0",
"fast-glob": "^3.3.3",
"filesize": "^10.1.6",
"fs-extra": "^11.3.0",
"get-port-please": "^3.1.2",
"giget": "^1.2.3 || ^2.0.0",
"hookable": "^5.5.3",
"import-meta-resolve": "^4.1.0",
"is-wsl": "^3.1.0",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"linkedom": "^0.18.10",
"magicast": "^0.3.5",
"minimatch": "^10.0.1",
"nano-spawn": "^0.2.0",
"normalize-path": "^3.0.0",
"nypm": "^0.6.0",
"ohash": "^2.0.11",
"open": "^10.1.2",
"ora": "^8.2.0",
"perfect-debounce": "^1.0.0",
"picocolors": "^1.1.1",
"prompts": "^2.4.2",
"publish-browser-extension": "^2.3.0 || ^3.0.0",
"scule": "^1.3.0",
"unimport": "^3.13.1 || ^4.0.0 || ^5.0.0",
"vite": "^5.4.19 || ^6.3.4",
"vite-node": "^2.1.4 || ^3.1.2",
"web-ext-run": "^0.2.3"
},
"devDependencies": {
"@aklinker1/check": "catalog:",
"@faker-js/faker": "catalog:",
"@types/fs-extra": "catalog:",
"@types/lodash.merge": "catalog:",
"@types/node": "catalog:",
"@types/normalize-path": "catalog:",
"@types/prompts": "catalog:",
"extract-zip": "catalog:",
"happy-dom": "catalog:",
"lodash.merge": "catalog:",
"oxlint": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"vitest": "catalog:",
"vitest-plugin-random-seed": "catalog:"
"@aklinker1/check": "2.0.0",
"@faker-js/faker": "^9.7.0",
"@types/fs-extra": "^11.0.4",
"@types/lodash.merge": "^4.6.9",
"@types/node": "^20.17.6",
"@types/normalize-path": "^3.0.2",
"@types/prompts": "^2.4.9",
"extract-zip": "^2.0.1",
"happy-dom": "^17.4.6",
"lodash.merge": "^4.6.2",
"oxlint": "^0.16.8",
"publint": "^0.3.12",
"typescript": "^5.8.3",
"unbuild": "^3.5.0",
"vitest": "^3.1.2",
"vitest-plugin-random-seed": "^1.1.1"
},
"peerDependenciesMeta": {},
"repository": {
@@ -167,6 +167,10 @@
"types": "./dist/utils/match-patterns.d.ts",
"default": "./dist/utils/match-patterns.mjs"
},
"./utils/split-shadow-root-css": {
"types": "./dist/utils/split-shadow-root-css.d.ts",
"default": "./dist/utils/split-shadow-root-css.mjs"
},
"./utils/storage": {
"types": "./dist/utils/storage.d.ts",
"default": "./dist/utils/storage.mjs"
+1 -3
View File
@@ -1,6 +1,4 @@
declare const __DEV_SERVER_PROTOCOL__: string;
declare const __DEV_SERVER_HOSTNAME__: string;
declare const __DEV_SERVER_PORT__: string;
declare const __DEV_SERVER_ORIGIN__: string;
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
interface ImportMetaEnv {
@@ -141,6 +141,7 @@ export default {
name: "wxt/auto-imports",
languageOptions: {
globals,
/** @type {import('eslint').Linter.SourceType} */
sourceType: "module",
},
};
+10 -7
View File
@@ -16,7 +16,8 @@ cli
.option('-c, --config <file>', 'use specified config file')
.option('-m, --mode <mode>', 'set env mode')
.option('-b, --browser <browser>', 'specify a browser')
.option('-p, --port <port>', 'specify a port for the dev server')
.option('--host <host>', 'specify a host for the dev server to bind to')
.option('-p, --port <port>', 'specify a port for the dev server to bind to')
.option(
'-e, --filter-entrypoint <entrypoint>',
'only build specific entrypoints',
@@ -28,6 +29,12 @@ cli
.option('--mv2', 'target manifest v2')
.action(
wrapAction(async (root, flags) => {
const serverOptions: NonNullable<
NonNullable<Parameters<typeof createServer>[0]>['dev']
>['server'] = {};
if (flags.host) serverOptions.host = flags.host;
if (flags.port) serverOptions.port = parseInt(flags.port);
const server = await createServer({
root,
mode: flags.mode,
@@ -37,13 +44,9 @@ cli
debug: flags.debug,
filterEntrypoints: getArrayFromFlags(flags, 'filterEntrypoint'),
dev:
flags.port == null
Object.keys(serverOptions).length === 0
? undefined
: {
server: {
port: parseInt(flags.port),
},
},
: { server: serverOptions },
});
await server.start();
return { isOngoing: true };
+1 -1
View File
@@ -327,9 +327,9 @@ export async function createViteBuilder(
async createServer(info) {
const serverConfig: vite.InlineConfig = {
server: {
host: info.host,
port: info.port,
strictPort: true,
host: info.hostname,
origin: info.origin,
},
};
@@ -20,7 +20,7 @@ describe('Dev HTML Prerender Plugin', () => {
['~local/style.css', 'http://localhost:5173/style.css'],
['~absolute/style.css', 'http://localhost:5173/assets/style.css'],
['~file', 'http://localhost:5173/example.css'],
// Absolute paths are loaded with the `/@fs/` base path
// Paths outside the project root are loaded with the `/@fs/` base path
[
'~outside/test.css',
`http://localhost:5173/@fs${
@@ -48,7 +48,7 @@ describe('Dev HTML Prerender Plugin', () => {
},
});
const server = fakeDevServer({
hostname: 'localhost',
host: 'localhost',
port: 5173,
origin: 'http://localhost:5173',
});
@@ -4,10 +4,10 @@ import { getEntrypointName } from '../../../utils/entrypoints';
import { parseHTML } from 'linkedom';
import { dirname, relative, resolve } from 'node:path';
import { normalizePath } from '../../../utils/paths';
import { murmurHash } from 'ohash';
import { hash } from 'ohash';
// Stored outside the plugin to effect all instances of the devHtmlPrerender plugin.
const inlineScriptContents: Record<number, string> = {};
const inlineScriptContents: Record<string, string> = {};
/**
* Pre-renders the HTML entrypoints when building the extension to connect to the dev server.
@@ -85,13 +85,13 @@ export function devHtmlPrerender(
inlineScripts.forEach((script) => {
// Save the text content for later
const textContent = script.textContent ?? '';
const hash = murmurHash(textContent);
inlineScriptContents[hash] = textContent;
const key = hash(textContent);
inlineScriptContents[key] = textContent;
// Replace unsafe inline script
const virtualScript = document.createElement('script');
virtualScript.type = 'module';
virtualScript.src = `${server.origin}/@id/${virtualInlineScript}?${hash}`;
virtualScript.src = `${server.origin}/@id/${virtualInlineScript}?${key}`;
script.replaceWith(virtualScript);
});
@@ -111,7 +111,7 @@ export function devHtmlPrerender(
},
},
{
name: 'wxt:virtualize-react-refresh',
name: 'wxt:virtualize-inline-scripts',
apply: 'serve',
resolveId(id) {
// Resolve inline scripts
@@ -127,9 +127,9 @@ export function devHtmlPrerender(
load(id) {
// Resolve virtualized inline scripts
if (id.startsWith(resolvedVirtualInlineScript)) {
// id="virtual:wxt-inline-script?<hash>"
const hash = Number(id.substring(id.indexOf('?') + 1));
return inlineScriptContents[hash];
// id="virtual:wxt-inline-script?<key>"
const key = id.substring(id.indexOf('?') + 1);
return inlineScriptContents[key];
}
// Ignore chunks during HTML file pre-rendering
@@ -150,6 +150,13 @@ export function pointToDevServer(
attr: string,
) {
document.querySelectorAll(querySelector).forEach((element) => {
if (
element.hasAttribute('vite-ignore') ||
element.hasAttribute('wxt-ignore')
) {
element.removeAttribute('wxt-ignore');
return;
}
const src = element.getAttribute(attr);
if (!src || isUrl(src)) return;
@@ -15,9 +15,9 @@ export function devServerGlobals(
return {
define: {
__DEV_SERVER_PROTOCOL__: JSON.stringify('ws:'),
__DEV_SERVER_HOSTNAME__: JSON.stringify(server.hostname),
__DEV_SERVER_PORT__: JSON.stringify(server.port),
__DEV_SERVER_ORIGIN__: JSON.stringify(
server.origin.replace(/^http(s):/, 'ws$1:'),
),
},
};
},
@@ -48,7 +48,7 @@ try {
handler(html, _ctx) {
const src =
config.command === 'serve'
? `http://${config.dev.server?.hostname}:${config.dev.server?.port}/@id/${virtualHtmlModuleId}`
? `${config.dev.server?.origin}/@id/${virtualHtmlModuleId}`
: virtualHtmlModuleId;
const { document } = parseHTML(html);
+7 -9
View File
@@ -52,12 +52,8 @@ export async function createServer(
async function createServerInternal(): Promise<WxtDevServer> {
const getServerInfo = (): ServerInfo => {
const { port, hostname } = wxt.config.dev.server!;
return {
port,
hostname,
origin: `http://${hostname}:${port}`,
};
const { host, port, origin } = wxt.config.dev.server!;
return { host, port, origin };
};
let [runner, builderServer] = await Promise.all([
@@ -71,8 +67,8 @@ async function createServerInternal(): Promise<WxtDevServer> {
// Server instance must be created first so its reference can be added to the internal config used
// to pre-render entrypoints
const server: WxtDevServer = {
get hostname() {
return getServerInfo().hostname;
get host() {
return getServerInfo().host;
},
get port() {
return getServerInfo().port;
@@ -96,7 +92,9 @@ async function createServerInternal(): Promise<WxtDevServer> {
}
await builderServer.listen();
wxt.logger.success(`Started dev server @ ${server.origin}`);
const hostInfo =
server.host === 'localhost' ? '' : ` (listening on ${server.host})`;
wxt.logger.success(`Started dev server @ ${server.origin}${hostInfo}`);
await wxt.hooks.callHook('server:started', wxt, server);
// Register content scripts for the first time after the background starts
+8 -6
View File
@@ -73,16 +73,18 @@ async function getPathsDeclarationEntry(
isHtmlEntrypoint(entry) ? '.html' : '.js',
),
)
.concat([''])
.concat(await getPublicFiles());
await wxt.hooks.callHook('prepare:publicPaths', wxt, paths);
const unions = paths
.map(normalizePath)
.sort()
.map((path) => ` | "/${path}"`)
.join('\n');
const unions = [
` | ""`,
` | "/"`,
...paths
.map(normalizePath)
.sort()
.map((path) => ` | "/${path}"`),
].join('\n');
const template = `// Generated by wxt
import "wxt/browser";
@@ -0,0 +1,11 @@
import { WxtPackageManagerImpl } from './types';
export const deno: WxtPackageManagerImpl = {
overridesKey: 'na',
downloadDependency() {
throw Error('Deno not supported');
},
listDependencies() {
throw Error('Deno not supported');
},
};
@@ -14,6 +14,7 @@ import { WxtPackageManagerImpl } from './types';
import { yarn } from './yarn';
import { pnpm } from './pnpm';
import { npm } from './npm';
import { deno } from './deno';
export async function createWxtPackageManager(
root: string,
@@ -73,4 +74,5 @@ const packageManagers: Record<PackageManagerName, WxtPackageManagerImpl> = {
pnpm,
bun,
yarn,
deno,
};
+29 -4
View File
@@ -70,6 +70,12 @@ export async function resolveConfig(
if (debug) logger.level = LogLevels.debug;
const browser = mergedConfig.browser ?? 'chrome';
const targetBrowsers = mergedConfig.targetBrowsers ?? [];
if (targetBrowsers.length > 0 && !targetBrowsers.includes(browser)) {
throw new Error(
`Current target browser \`${browser}\` is not in your \`targetBrowsers\` list!`,
);
}
const manifestVersion =
mergedConfig.manifestVersion ??
(browser === 'firefox' || browser === 'safari' ? 2 : 3);
@@ -143,19 +149,37 @@ export async function resolveConfig(
let devServerConfig: ResolvedConfig['dev']['server'];
if (command === 'serve') {
const hostname = mergedConfig.dev?.server?.hostname ?? 'localhost';
if (mergedConfig.dev?.server?.hostname)
logger.warn(
`The 'hostname' option is deprecated, please use 'host' or 'origin' depending on your circumstances.`,
);
const host =
mergedConfig.dev?.server?.host ??
mergedConfig.dev?.server?.hostname ??
'localhost';
let port = mergedConfig.dev?.server?.port;
const origin =
mergedConfig.dev?.server?.origin ??
mergedConfig.dev?.server?.hostname ??
'localhost';
if (port == null || !isFinite(port)) {
port = await getPort({
// Passing host required for Mac, unsure of Windows/Linux
host,
port: 3000,
portRange: [3001, 3010],
// Passing host required for Mac, unsure of Windows/Linux
host: hostname,
});
}
const originWithProtocolAndPort = [
origin.match(/^https?:\/\//) ? '' : 'http://',
origin,
origin.match(/:[0-9]+$/) ? '' : `:${port}`,
].join('');
devServerConfig = {
host,
port,
hostname,
origin: originWithProtocolAndPort,
watchDebounce: safeStringToNumber(process.env.WXT_WATCH_DEBOUNCE) ?? 800,
};
}
@@ -179,6 +203,7 @@ export async function resolveConfig(
return {
browser,
targetBrowsers,
command,
debug,
entrypointsDir,
@@ -1463,7 +1463,7 @@ describe('Manifest Utils', () => {
command: 'build',
},
server: {
hostname: 'localhost',
host: 'localhost',
port: 3000,
origin: 'http://localhost:3000',
},
@@ -1487,8 +1487,8 @@ describe('Manifest Utils', () => {
manifestVersion: 2,
},
server: fakeWxtDevServer({
host: 'localhost',
port: 3000,
hostname: 'localhost',
origin: 'http://localhost:3000',
}),
});
@@ -1515,7 +1515,7 @@ describe('Manifest Utils', () => {
browser: 'chrome',
},
server: fakeWxtDevServer({
hostname: 'localhost',
host: 'localhost',
port: 3000,
origin: 'http://localhost:3000',
}),
@@ -1561,8 +1561,8 @@ describe('Manifest Utils', () => {
},
},
server: fakeWxtDevServer({
host: 'localhost',
port: 3000,
hostname: 'localhost',
origin: 'http://localhost:3000',
}),
});
@@ -137,5 +137,38 @@ export default defineContentScript({
expect(actual).toEqual(expected);
});
it('should not remove any variables delcared outside the main function that are used', () => {
const input = `
const [ a ] = [ 123, 456 ];
const { b } = { b: 123 };
const { c: { d } } = { c: { d: 123 } };
const { e, ...rest } = { e: 123, f: 456 };
console.log(a);
console.log(b);
console.log(d);
console.log(e);
console.log(rest);
export default defineBackground(() => {
console.log('Hello background!', { id: browser.runtime.id });
});`;
const expected = `const [ a ] = [ 123, 456 ];
const { b } = { b: 123 };
const { c: { d } } = { c: { d: 123 } };
const { e, ...rest } = { e: 123, f: 456 };
console.log(a);
console.log(b);
console.log(d);
console.log(e);
console.log(rest);
export default defineBackground();`;
const actual = removeMainFunctionCode(input).code;
expect(actual).toEqual(expected);
});
});
});
@@ -668,27 +668,46 @@ describe('findEntrypoints', () => {
expect(entrypoints[0]).toEqual(expected);
});
it('should not allow multiple entrypoints with the same name', async () => {
it('should ignore other index files in the same directory when index.html exists', async () => {
globMock.mockResolvedValueOnce([
'content/index.ts',
'popup/index.html',
'popup/index.ts',
'popup/index.css',
]);
const entrypoints = await findEntrypoints();
expect(entrypoints).toHaveLength(2);
expect(entrypoints[0]).toMatchObject({
type: 'content-script',
name: 'content',
});
expect(entrypoints[1]).toMatchObject({
type: 'popup',
name: 'popup',
});
});
it('should not allow a file entrypoint and directory entrypoint to have the same name', async () => {
globMock.mockResolvedValueOnce([
'options/index.html',
'options/index.jsx',
'popup.html',
'popup/index.html',
'popup/index.ts',
'ui.html',
'other.ts',
'other/index.ts',
]);
await expect(() => findEntrypoints()).rejects.toThrowError(
[
'Multiple entrypoints with the same name detected, only one entrypoint for each name is allowed.',
'',
'- options',
` - ${unnormalizePath('src/entrypoints/options/index.html')}`,
` - ${unnormalizePath('src/entrypoints/options/index.jsx')}`,
'- other',
` - ${unnormalizePath('src/entrypoints/other.ts')}`,
` - ${unnormalizePath('src/entrypoints/other/index.ts')}`,
'- popup',
` - ${unnormalizePath('src/entrypoints/popup.html')}`,
` - ${unnormalizePath('src/entrypoints/popup/index.html')}`,
` - ${unnormalizePath('src/entrypoints/popup/index.ts')}`,
].join('\n'),
);
});
@@ -53,20 +53,34 @@ export async function findEntrypoints(): Promise<Entrypoint[]> {
relativePaths.sort();
const pathGlobs = Object.keys(PATH_GLOB_TO_TYPE_MAP);
const entrypointInfos: EntrypointInfo[] = relativePaths.reduce<
EntrypointInfo[]
>((results, relativePath) => {
const inputPath = resolve(wxt.config.entrypointsDir, relativePath);
const name = getEntrypointName(wxt.config.entrypointsDir, inputPath);
const matchingGlob = pathGlobs.find((glob) =>
minimatch(relativePath, glob),
);
if (matchingGlob) {
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
results.push({ name, inputPath, type });
}
return results;
}, []);
const entrypointInfos: EntrypointInfo[] = relativePaths
.reduce<EntrypointInfo[]>((results, relativePath) => {
const inputPath = resolve(wxt.config.entrypointsDir, relativePath);
const name = getEntrypointName(wxt.config.entrypointsDir, inputPath);
const matchingGlob = pathGlobs.find((glob) =>
minimatch(relativePath, glob),
);
if (matchingGlob) {
const type = PATH_GLOB_TO_TYPE_MAP[matchingGlob];
results.push({ name, inputPath, type });
}
return results;
}, [])
.filter(({ name, inputPath }, _, entrypointInfos) => {
// Remove <name>/index.* if <name>/index.html exists
if (inputPath.endsWith('.html')) return true;
const isIndexFile = /index\..+$/.test(inputPath);
if (!isIndexFile) return true;
const hasIndexHtml = entrypointInfos.some(
(entry) =>
entry.name === name && entry.inputPath.endsWith('index.html'),
);
if (hasIndexHtml) return false;
return true;
});
await wxt.hooks.callHook('entrypoints:found', wxt, entrypointInfos);
+4 -1
View File
@@ -12,7 +12,10 @@ export function getGlobals(
{
name: 'BROWSER',
value: config.browser,
type: `string`,
type:
config.targetBrowsers.length === 0
? 'string'
: config.targetBrowsers.map((browser) => `"${browser}"`).join(' | '),
},
{
name: 'CHROME',
+7 -1
View File
@@ -459,7 +459,13 @@ function discoverIcons(
}
function addDevModeCsp(manifest: Browser.runtime.Manifest): void {
const permission = `http://${wxt.server?.hostname ?? ''}/*`;
let permissonUrl = wxt.server?.origin;
if (permissonUrl) {
const permissionUrlInstance = new URL(permissonUrl);
permissionUrlInstance.port = '';
permissonUrl = permissionUrlInstance.toString();
}
const permission = `${permissonUrl}*`;
const allowedCsp = wxt.server?.origin ?? 'http://localhost:*';
if (manifest.manifest_version === 3) {
@@ -235,6 +235,7 @@ export const fakeResolvedConfig = fakeObjectCreator<ResolvedConfig>(() => {
return {
browser,
targetBrowsers: [],
command,
entrypointsDir: fakeDir(),
modulesDir: fakeDir(),
@@ -320,9 +321,9 @@ export const fakeWxt = fakeObjectCreator<Wxt>(() => ({
export const fakeWxtDevServer = fakeObjectCreator<WxtDevServer>(() => ({
currentOutput: fakeBuildOutput(),
hostname: 'localhost',
origin: 'http://localhost:3000',
host: 'localhost',
port: 3000,
origin: 'http://localhost:3000',
reloadContentScript: vi.fn(),
reloadExtension: vi.fn(),
reloadPage: vi.fn(),
@@ -367,9 +368,9 @@ export const fakeManifestCommand = fakeObjectCreator<Browser.commands.Command>(
);
export const fakeDevServer = fakeObjectCreator<WxtDevServer>(() => ({
hostname: 'localhost',
origin: 'http://localhost',
host: 'localhost',
port: 5173,
origin: 'http://localhost:3000',
reloadContentScript: vi.fn(),
reloadExtension: vi.fn(),
reloadPage: vi.fn(),
+71 -7
View File
@@ -50,19 +50,83 @@ function removeUnusedTopLevelVariables(mod: ProxifiedModule): number {
let deletedCount = 0;
const ast = mod.$ast as any;
for (let i = ast.body.length - 1; i >= 0; i--) {
if (ast.body[i].type === 'VariableDeclaration') {
for (let j = ast.body[i].declarations.length - 1; j >= 0; j--) {
if (!usedMap.get(ast.body[i].declarations[j].id.name)) {
ast.body[i].declarations.splice(j, 1);
const isUsed = (id: any) => {
return id?.type === 'Identifier' && usedMap.get(id.name);
};
const cleanArrayPattern = (pattern: any): boolean => {
const elements = pattern.elements;
for (let i = elements.length - 1; i >= 0; i--) {
const el = elements[i];
if (el?.type === 'Identifier' && !isUsed(el)) {
elements.splice(i, 1);
deletedCount++;
}
}
return elements.length === 0;
};
const cleanObjectPattern = (pattern: any): boolean => {
const properties = pattern.properties;
for (let i = properties.length - 1; i >= 0; i--) {
const prop = properties[i];
if (prop.type === 'Property') {
const value = prop.value;
// support nested object
if (value.type === 'ObjectPattern') {
const isEmpty = cleanObjectPattern(value);
if (isEmpty) {
properties.splice(i, 1);
}
} else if (value.type === 'ArrayPattern') {
const isEmpty = cleanArrayPattern(value);
if (isEmpty) {
properties.splice(i, 1);
}
} else if (value.type === 'Identifier' && !isUsed(value)) {
properties.splice(i, 1);
deletedCount++;
}
} else if (prop.type === 'RestElement') {
const arg = prop.argument;
if (arg.type === 'Identifier' && !isUsed(arg)) {
properties.splice(i, 1);
deletedCount++;
}
}
if (ast.body[i].declarations.length === 0) {
ast.body.splice(i, 1);
}
return properties.length === 0;
};
for (let i = ast.body.length - 1; i >= 0; i--) {
if (ast.body[i].type !== 'VariableDeclaration') continue;
for (let j = ast.body[i].declarations.length - 1; j >= 0; j--) {
const id = ast.body[i].declarations[j].id;
let shouldRemove = false;
if (id.type === 'Identifier') {
shouldRemove = !isUsed(id);
if (shouldRemove) deletedCount++;
} else if (id.type === 'ArrayPattern') {
shouldRemove = cleanArrayPattern(id);
} else if (id.type === 'ObjectPattern') {
shouldRemove = cleanObjectPattern(id);
}
if (shouldRemove) {
ast.body[i].declarations.splice(j, 1);
}
}
if (ast.body[i].declarations.length === 0) {
ast.body.splice(i, 1);
}
}
return deletedCount;
}
+3 -3
View File
@@ -26,11 +26,10 @@ export async function zip(config?: InlineConfig): Promise<string[]> {
wxt.logger.info('Zipping extension...');
const zipFiles: string[] = [];
const packageJson = await getPackageJson();
const projectName =
wxt.config.zip.name ??
safeFilename(
(await getPackageJson())?.name || path.basename(process.cwd()),
);
safeFilename(packageJson?.name || path.basename(process.cwd()));
const applyTemplate = (template: string): string =>
template
.replaceAll('{{name}}', projectName)
@@ -39,6 +38,7 @@ export async function zip(config?: InlineConfig): Promise<string[]> {
'{{version}}',
output.manifest.version_name ?? output.manifest.version,
)
.replaceAll('{{packageVersion}}', packageJson?.version)
.replaceAll('{{mode}}', wxt.config.mode)
.replaceAll('{{manifestVersion}}', `mv${wxt.config.manifestVersion}`);
+28 -6
View File
@@ -108,6 +108,12 @@ export interface InlineConfig {
* "chrome"
*/
browser?: TargetBrowser;
/**
* Target browsers to support. When set, `import.meta.env.BROWSER` will be narrowed to a string literal type containing only the specified browser names.
*
* @default []
*/
targetBrowsers?: TargetBrowser[];
/**
* Explicitly set a manifest version to target. This will override the default manifest version
* for each command, and can be overridden by the command line `--mv2` or `--mv3` option.
@@ -141,6 +147,7 @@ export interface InlineConfig {
*
* - <span v-pre>`{{name}}`</span> - The project's name converted to kebab-case
* - <span v-pre>`{{version}}`</span> - The version_name or version from the manifest
* - <span v-pre>`{{packageVersion}}`</span> - The version from the package.json
* - <span v-pre>`{{browser}}`</span> - The target browser from the `--browser` CLI flag
* - <span v-pre>`{{mode}}`</span> - The current mode
* - <span v-pre>`{{manifestVersion}}`</span> - Either "2" or "3"
@@ -165,6 +172,7 @@ export interface InlineConfig {
*
* - <span v-pre>`{{name}}`</span> - The project's name converted to kebab-case
* - <span v-pre>`{{version}}`</span> - The version_name or version from the manifest
* - <span v-pre>`{{packageVersion}}`</span> - The version from the package.json
* - <span v-pre>`{{browser}}`</span> - The target browser from the `--browser` CLI flag
* - <span v-pre>`{{mode}}`</span> - The current mode
* - <span v-pre>`{{manifestVersion}}`</span> - Either "2" or "3"
@@ -319,14 +327,26 @@ export interface InlineConfig {
*/
dev?: {
server?: {
/**
* Host to bind the dev server to.
*
* @default "localhost"
*/
host?: string;
/**
* Port to run the dev server on. Defaults to the first open port from 3000 to 3010.
*/
port?: number;
/**
* Origin to use to connect from the extension ui runtime to the dev server.
*
* @default "http://localhost:3000"
*/
origin?: string;
/**
* Hostname to run the dev server on.
*
* @default "localhost"
* @deprecated use `host` to specify the interface to bind to, or use `origin` to specify the dev server hostname.
*/
hostname?: string;
};
@@ -1067,14 +1087,14 @@ export interface WxtBuilderServer {
}
export interface ServerInfo {
/**
* Ex: `"localhost"`
*/
host: string;
/**
* Ex: `3000`
*/
port: number;
/**
* Ex: `"localhost"`
*/
hostname: string;
/**
* Ex: `"http://localhost:3000"`
*/
@@ -1299,6 +1319,7 @@ export interface ResolvedConfig {
mode: string;
command: WxtCommand;
browser: TargetBrowser;
targetBrowsers: TargetBrowser[];
manifestVersion: TargetManifestVersion;
env: ConfigEnv;
logger: Logger;
@@ -1343,8 +1364,9 @@ export interface ResolvedConfig {
dev: {
/** Only defined during dev command */
server?: {
host: string;
port: number;
hostname: string;
origin: string;
/**
* The milliseconds to debounce when a file is saved before reloading.
* The only way to set this option is to set the `WXT_WATCH_DEBOUNCE`

Some files were not shown because too many files have changed in this diff Show More