Compare commits

..

15 Commits

Author SHA1 Message Date
GitHub Actions 712f2ed4ac chore(release): v0.13.0 2023-12-27 17:05:45 +00:00
Aaron d1b9e5ded6 feat!: New wxt/storage APIs (#300)
BREAKING CHANGE: `wxt/storage` no longer relies on `unstorage`. Some `unstorage` APIs, like `prefixStorage`, have been removed, while others, like `snapshot`, are methods on the new `storage` object. Most of the standard usage remains the same.
2023-12-27 11:00:10 -06:00
GitHub Actions 86182044d1 chore(release): v0.12.5 2023-12-24 14:28:21 +00:00
Aaron e1f181356c fix: Correct import in dev-only, noop background (#298) 2023-12-24 08:24:53 -06:00
GitHub Actions 5f241cf407 chore(release): v0.12.4 2023-12-23 15:53:43 +00:00
Aaron b4a2cf4cb2 fix: Disable Vite CJS warnings (#296) 2023-12-23 09:35:43 -06:00
GitHub Actions aea137a3a1 chore(release): v0.12.3 2023-12-23 04:26:47 +00:00
Aaron ad0e8e4144 fix: Correctly mock webextension-polyfill for Vitest (#294) 2023-12-22 22:21:09 -06:00
GitHub Actions 7fd5752ef1 chore(release): v0.12.2 2023-12-19 17:48:24 +00:00
Aaron 1a8b7ff635 feat: Support PNPM without hoisting dependencies (#291) 2023-12-19 11:42:20 -06:00
GitHub Actions fe3ca0fcbd chore(release): v0.12.1 2023-12-16 17:24:16 +00:00
Aaron f49ee9f005 fix: Fix package.json lint errors (#290) 2023-12-16 11:18:16 -06:00
Aaron 961440c0ac fix: Upgrade @webext-core/match-patterns to 1.0.3 (#289) 2023-12-16 11:07:18 -06:00
Aaron 9b562b0ca8 chore: Upgrade templates to wxt@^0.12.0 (#285) 2023-12-13 16:21:52 -06:00
Aaron Klinker 7b2563b2dc Update changelog with breaking changes 2023-12-13 16:02:24 -06:00
53 changed files with 2019 additions and 1139 deletions
+6 -3
View File
@@ -13,6 +13,12 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: pnpm format:check
lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: pnpm lint
type-check:
runs-on: ubuntu-22.04
steps:
@@ -74,6 +80,3 @@ jobs:
working-directory: templates/${{ matrix.template }}
- run: pnpm build
working-directory: templates/${{ matrix.template }}
env:
# Debug Vite 5's deprecated CJS support
VITE_CJS_TRACE: true
+65 -1
View File
@@ -1,5 +1,62 @@
# Changelog
## v0.13.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.5...v0.13.0)
### 🚀 Enhancements
- ⚠️ New `wxt/storage` APIs ([#300](https://github.com/wxt-dev/wxt/pull/300))
#### ⚠️ Breaking Changes
- ⚠️ New `wxt/storage` APIs ([#300](https://github.com/wxt-dev/wxt/pull/300))
## v0.12.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.4...v0.12.5)
### 🩹 Fixes
- Correct import in dev-only, noop background ([#298](https://github.com/wxt-dev/wxt/pull/298))
## v0.12.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.3...v0.12.4)
### 🩹 Fixes
- Disable Vite CJS warnings ([#296](https://github.com/wxt-dev/wxt/pull/296))
## v0.12.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.2...v0.12.3)
### 🩹 Fixes
- Correctly mock `webextension-polyfill` for Vitest ([#294](https://github.com/wxt-dev/wxt/pull/294))
## v0.12.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.1...v0.12.2)
### 🚀 Enhancements
- Support PNPM without hoisting dependencies ([#291](https://github.com/wxt-dev/wxt/pull/291))
## v0.12.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.12.0...v0.12.1)
### 🩹 Fixes
- Upgrade `@webext-core/match-patterns` to `1.0.3` ([#289](https://github.com/wxt-dev/wxt/pull/289))
- Fix `package.json` lint errors ([#290](https://github.com/wxt-dev/wxt/pull/290))
### 🏡 Chore
- Upgrade templates to `wxt@^0.12.0` ([#285](https://github.com/wxt-dev/wxt/pull/285))
## v0.12.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.11.2...v0.12.0)
@@ -24,7 +81,14 @@
#### ⚠️ Breaking Changes
- ⚠️ Add support for "main world" content scripts ([#284](https://github.com/wxt-dev/wxt/pull/284))
`defineContentScript` and `defineBackground` are now exported from `wxt/sandbox` instead of `wxt/client`. ([#284](https://github.com/wxt-dev/wxt/pull/284))
- If you use auto-imports, no changes are required.
- If you have disabled auto-imports, you'll need to manually update your import statements:
```diff
- import { defineBackground, defineContentScript } from 'wxt/client';
+ import { defineBackground, defineContentScript } from 'wxt/sandbox';
```
## v0.11.2
+1 -3
View File
@@ -19,13 +19,11 @@
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vitest": "^0.34.6",
"webextension-polyfill": "^0.10.0"
"vitest": "^1.1.0"
},
"devDependencies": {
"@types/react": "^18.2.34",
"@types/react-dom": "^18.2.14",
"@types/webextension-polyfill": "^0.10.5",
"sass": "^1.69.5",
"wxt": "workspace:*"
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import background from '../background';
browser.i18n.getMessage = () => 'fake-message';
@@ -7,6 +7,10 @@ const logMock = vi.fn();
console.log = logMock;
describe('Background Entrypoint', () => {
beforeEach(() => {
fakeBrowser.reset();
});
it("should log the extenion's runtime ID", () => {
const id = 'some-id';
fakeBrowser.runtime.id = id;
@@ -15,4 +19,11 @@ describe('Background Entrypoint', () => {
expect(logMock).toBeCalledWith(id);
});
it('should set the start time in storage', async () => {
background.main();
await new Promise((res) => setTimeout(res));
expect(await storage.getItem('session:startTime')).toBeDefined();
});
});
@@ -11,7 +11,7 @@ const chromeExtensionIds = [
'bfbnagnphiehemkdgmmficmjfddgfhpl', // UltraWideo
'elfaihghhjjoknimpccccmkioofjjfkf', // StayFree - Website Blocker & Web Analytics
'okifoaikfmpfcamplcfjkpdnhfodpkil', // Doozy: Ai Made Easy
'lknmjhcajhfbbglglccadlfdjbaiifig' // tl;dv - Record, Transcribe & ChatGPT for Google Meet
'lknmjhcajhfbbglglccadlfdjbaiifig', // tl;dv - Record, Transcribe & ChatGPT for Google Meet
];
const { data } = useListExtensionDetails(chromeExtensionIds);
+246 -28
View File
@@ -1,36 +1,254 @@
# Storage API
WXT's storage API is powered by `unstorage`. See [their docs](https://unstorage.unjs.io/usage#usage-1) for more details.
## Overview
:::code-group
```ts [native]
const { installDate } = await browser.storage.local.get('installDate');
await browser.storage.local.set({ key: 'value' });
```
```ts [wxt/browser]
const installDate = await storage.get('local:installDate');
await storage.setItem('key', 'value');
```
:::
Use the `"local:"`, `"session:"`, `"sync:"`, and `"managed:"` prefixes to specify which storage area to use.
## Customization
WXT also provides a driver for `unstorage`. To customize the `storage` object's setup, like removing the prefixes and using a single storage area, you can create your own storage:
WXT provides a simplified API to replace the `browser.storage.*` APIs. Use the `storage` auto-import from `wxt/storage` or import it manually to get started:
```ts
// storage.ts
export default createStorage({
driver: webExtensionDriver({ storageArea: 'local' }),
import { storage } from 'wxt/storage';
```
[[toc]]
## Basic Usage
All storage keys must be prefixed by their storage area.
```ts
// ❌ This will throw an error
await storage.getItem('installDate');
// ✅ This is good
await storage.getItem('local:installDate');
```
You can use `local:`, `session:`, `sync:`, or `managed:`.
If you use TypeScript, you can add a type parameter to most methods to specify the expected type of the key's value:
```ts
await storage.getItem<number>('local:installDate');
await storage.watch<number>(
'local:installDate',
(newInstallDate, oldInstallDate) => {
// ...
},
);
await storage.getMeta<{ v: number }>('local:installDate');
```
## Watchers
To listen for storage changes, use the `storage.watch` function. It lets you setup a listener for a single key:
```ts
const unwatch = storage.watch<number>('local:counter', (newCount, oldCount) => {
console.log('Count changed:', { newCount, oldCount });
});
```
:::note
`wxt/browser` re-exports all of `unstorage`, which is where `createStorage` comes from.
To remove the listener, call the returned `unwatch` function:
```ts
const unwatch = storage.watch(...);
// Some time later...
unwatch();
```
## Metadata
`wxt/storage` also supports setting metadata for keys, stored at `key + "$"`. Metadata is a collection of properties associated with a key. It might be a version number, last modified date, etc.
[Other than versioning](#versioning-and-migrations), you are responsible for managing a field's metadata:
```ts
await Promise.all([
storage.setItem('local:preference', true),
storage.setMeta('local:preference', { lastModified: Date.now() }),
]);
```
When setting different properties of metadata from multiple calls, the properties are combined instead of overwritten:
```ts
await storage.setMeta('local:preference', { lastModified: Date.now() });
await storage.setMeta('local:preference', { v: 2 });
await storage.getMeta('local:preference'); // { v: 2, lastModified: 1703690746007 }
```
You can remove all metadata associated with a key, or just specific properties:
```ts
// Remove all properties
await storage.removeMeta('local:preference');
// Remove one property
await storage.removeMeta('local:preference', 'lastModified');
// Remove multiple properties
await storage.removeMeta('local:preference', ['lastModified', 'v']);
```
## Defining Storage Items
Writing the key and type parameter for the same key over and over again can be annoying. As an alternative, you can use `storage.defineItem` to create a "storage item".
Storage items contain the same APIs as the `storage` variable, but you can configure its type, default value, and more in a single place:
```ts
// utils/storage.ts
const showChangelogOnUpdate = storage.defineItem<boolean>(
'local:showChangelogOnUpdate',
{
defaultValue: true,
},
);
```
Now, instead of using the `storage` variable, you can use the helper functions on the storage item you created:
```ts
await showChangelogOnUpdate.getValue();
await showChangelogOnUpdate.setValue(false);
await showChangelogOnUpdate.removeValue();
const unwatch = showChangelogOnUpdate.watch(() => {
// ...
});
```
### Versioning and Migrations
You can add versioning to storage items if you expect them to grow or change over time. When defining the first version of an item, start with version 1.
For example, consider a storage item that stores a list of websites that are ignored by an extension.
:::code-group
```ts [v1]
type IgnoredWebsiteV1 = string;
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV1[]>(
'local:ignoredWebsites',
{
defaultValue: [],
version: 1,
},
);
```
<!-- prettier-ignore -->
```ts [v2]
import { nanoid } from 'nanoid'; // [!code ++]
type IgnoredWebsiteV1 = string;
interface IgnoredWebsiteV2 { // [!code ++]
id: string; // [!code ++]
website: string; // [!code ++]
} // [!code ++]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV1[]>( // [!code --]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code ++]
'local:ignoredWebsites',
{
defaultValue: [],
version: 1, // [!code --]
version: 2, // [!code ++]
migrations: { // [!code ++]
// Ran when migrating from v1 to v2 // [!code ++]
2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => { // [!code ++]
return websites.map((website) => ({ id: nanoid(), website })); // [!code ++]
}, // [!code ++]
}, // [!code ++]
},
);
```
<!-- prettier-ignore -->
```ts [v3]
import { nanoid } from 'nanoid';
type IgnoredWebsiteV1 = string;
interface IgnoredWebsiteV2 {
id: string;
website: string;
}
interface IgnoredWebsiteV3 { // [!code ++]
id: string; // [!code ++]
website: string; // [!code ++]
enabled: boolean; // [!code ++]
} // [!code ++]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code --]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV3[]>( // [!code ++]
'local:ignoredWebsites',
{
defaultValue: [],
version: 2, // [!code --]
version: 3, // [!code ++]
migrations: {
// Ran when migrating from v1 to v2
2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => {
return websites.map((website) => ({ id: nanoid(), website }));
},
// Ran when migrating from v2 to v3 // [!code ++]
3: (websites: IgnoredWebsiteV2[]): IgnoredWebsiteV3[] => { // [!code ++]
return websites.map((website) => ({ ...website, enabled: true })); // [!code ++]
}, // [!code ++]
},
},
);
```
:::
:::info
Internally, this uses a metadata property called `v` to track the value's current version.
:::
In this case, we thought that the ignored website list might change in the future, and were able to setup a versioned storage item from the start.
Realistically, you won't know a item needs versioned until you need to change it's schema. Thankfully, it's simple to add versioning to an unversioned storage item.
When a previous version isn't found, WXT assumes the version was `1`. That means you just need to set `version: 2` and add a migration for `2`, and it will just work!
Lets look at the same ignored websites example from before, but start with an unversioned item this time:
:::code-group
```ts [Unversioned]
export const ignoredWebsites = storage.defineItem<string[]>(
'local:ignoredWebsites',
{
defaultValue: [],
},
);
```
<!-- prettier-ignore -->
```ts [v2]
import { nanoid } from 'nanoid'; // [!code ++]
// Retroactively add a type for the first version // [!code ++]
type IgnoredWebsiteV1 = string; // [!code ++]
interface IgnoredWebsiteV2 { // [!code ++]
id: string; // [!code ++]
website: string; // [!code ++]
} // [!code ++]
export const ignoredWebsites = storage.defineItem<string[]>( // [!code --]
export const ignoredWebsites = storage.defineItem<IgnoredWebsiteV2[]>( // [!code ++]
'local:ignoredWebsites',
{
defaultValue: [],
version: 2, // [!code ++]
migrations: { // [!code ++]
// Ran when migrating from v1 to v2 // [!code ++]
2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => { // [!code ++]
return websites.map((website) => ({ id: nanoid(), website })); // [!code ++]
}, // [!code ++]
}, // [!code ++]
},
);
```
:::
+16 -151
View File
@@ -1,162 +1,27 @@
# Testing
WXT provides several utils for writing tests.
## Official Frameworks
## Unit tests
WXT officially supports [Vitest](https://vitest.dev/) for unit tests and either [Playwright](https://playwright.dev/) or [Puppeteer](https://pptr.dev/) for E2E tests against Chromium browsers.
If you're using auto-imports (enabled by default), [Vitest](https://vitest.dev/) is the only testing framework that supports them.
For details setting up each testing framework, see the official examples:
If you want to use a different testing library/framework (like Jest, mocha, node:test, etc), you can keep using it, but you have two options:
- [Vitest example](https://github.com/wxt-dev/wxt-examples/tree/main/examples/vanilla-vitest#readme)
- [Playwright example](https://github.com/wxt-dev/wxt-examples/tree/main/examples/vanilla-playwright#readme)
- [Puppeteer example](https://github.com/wxt-dev/wxt-examples/tree/main/examples/vanilla-puppeteer#readme)
1. Switch to Vitest (recommended)
2. Configure the testing library manually
- Disable auto-imports by setting `imports: false` in your `wxt.config.ts` file
- Manually add globals normally provided by WXT (like `__BROWSER__`) that you consume to the global scope before accessing them (`globalThis.__BROWSER__ = "chrome"`)
### Unofficial Frameworks
### Vitest Setup
Puppeteer and Playwright are the only E2E test runners that support Chrome Extensions. There are no other options at the time of writing.
Install vitest and add the `WxtVitest` plugin to your `vitest.config.ts` file.
There are other options for unit tests however, like [Jest](https://jestjs.io/), [Mocha](https://mochajs.org/), or [`node:test`](https://nodejs.org/api/test.html). **_WXT does not claim to support any of them_** because none of them support all of WXT's features, like TypeScript or auto-imports.
```sh
pnpm i -D vitest
```
If you want to try to use a different framework for unit tests, you will need to configure the environment manually:
```ts
// <root>/vitest.config.ts
import { defineConfig } from 'vitest/config';
import { WxtVitest } from 'wxt/testing';
- **Auto-imports**: Add `unimport` to your test environment or disable them by setting `imports: false` in your `wxt.config.ts` file
- **`browser` mock**: Mock the `webextension-polyfill` module globally with `wxt/dist/virtual/mock-browser.js`
- **[Remote Code Bundling](/guide/remote-code)**: If you use it, configure your environment to handle the `url:` module prefix
- **Global Variables**: If you consume them, manually define globals provided by WXT (like `__BROWSER__`) by adding them to the global scope before accessing them (`globalThis.__BROWSER__ = "chrome"`)
- **Import paths**: If you use the `@/` or `~/` path aliases, add them to your test environment
export default defineConfig({
plugins: [WxtVitest()],
test: {
server: {
deps: {
// Add any dependencies that import webextension-polyfill here, otherwise tests will attempt to import the real polyfill, breaking the
// TODO: Auto-detect these dependencies inside `WxtVitest` so maintaining this list manually isn't necessary
inline: [...],
},
},
},
});
```
And that's it. You're ready to start writing tests.
### Writing Tests
Here's a very basic test, written with a few different testing libraries, with a few different approaches for mocking the `browser` global.
:::code-group
```ts [Vitest]
import { describe, it, expect, vi } from 'vitest';
function logRuntimeId() {
// Vitest automatically mocks "browser" with "fakeBrowser"
console.log(browser.runtime.id);
}
describe('logRuntimeId', () => {
it("should log the extension's runtime ID", () => {
// Set a known ID on fakeBrowser for the test
const id = 'some-runtime-id';
fakeBrowser.runtime.id = id;
const logSpy = vi.spyOn(console, 'log');
logRuntimeId();
expect(logSpy).toBeCalledWith(id);
});
});
```
```ts [Jest - Manual Mock]
import { fakeBrowser } from 'wxt/testing';
import { browser } from 'wxt/browser';
function logRuntimeId() {
console.log(browser.runtime.id);
}
// Manually mock
jest.mock('wxt/browser', () => {
const { fakeBrowser } = require('wxt/testing');
return { browser: fakeBrowser };
});
describe('logRuntimeId', () => {
it("should log the extension's runtime ID", () => {
// Set a known ID on fakeBrowser for the test
const id = 'some-runtime-id';
fakeBrowser.runtime.id = id;
const logSpy = jest.spyOn(console, 'log');
logRuntimeId();
expect(logSpy).toBeCalledWith(id);
});
});
```
```ts [node:test - Parameterized]
import { describe, it, mock } from 'node:test';
import { assert } from 'node:assert';
import { fakeBrowser } from 'wxt/testing';
import { browser } from 'wxt/browser';
// Add browser as a parameter so fakeBrowser can be passed instead of browser
function logRuntimeId(browser = browser) {
console.log(browser.runtime.id);
}
describe('logRuntimeId', () => {
it("should log the extension's runtime ID", () => {
// Set a known ID on fakeBrowser for the test
const id = 'some-runtime-id';
fakeBrowser.runtime.id = id;
console.log = mock.fn();
// pass in fakeBrowser during tests
logRuntimeId(fakeBrowser);
assert.deepStrictEqual(console.log.mock.calls[0].arguments, [id]);
});
});
```
:::
:::warning
Without mocking the `browser` variable, you'll see errors like this:
```
This script should only be loaded in a browser extension.
```
:::
WXT provides an in-memory, partial implementation of `browser`, [`fakeBrowser`](/api/wxt/testing/variables/fakeBrowser), from the [`@webext-core/fake-browser`](https://webext-core.aklinker1.io/guide/fake-browser/) package. `fakeBrowser` works with all testing frameworks/libraries. See their docs for a list of [implemented APIs](https://webext-core.aklinker1.io/guide/fake-browser/implemented-apis.html) and more example tests.
## E2E Tests
WXT does not provide any utils for running E2E tests. There are two libraries you can use to run E2E tests for any chrome extension.
- [`playwright`](https://playwright.dev/docs/chrome-extensions) (recommended) - "A high-level API to automate web browsers"
- [`puppeteer`](https://pptr.dev/guides/chrome-extensions) - "A high-level API to control headless Chrome over the DevTools Protocol"
:::info
Note that both only support running tests on Chrome.
:::
Before running tests with either of these tools, you must build the extension with `wxt build` and then load the extension from the output directory in a new tab.
To test an extension's UI, like the popup or options page, you'll need to know the extension's ID to open the URL directly.
> _chrome-extension://`browser.runtime.id`/popup.html_
- Playwright provides an API to get your extension ID after it has been installed. [See their docs](https://playwright.dev/docs/chrome-extensions#testing).
- Puppeteer requires you know the ID before installing the extension, so you can hard code it into the URLs you open. Follow [Chrome's guide](https://developer.chrome.com/docs/extensions/mv3/manifest/key/) to setup a consistent runtime id.
:::info
You cannot test popups in their normal popup window, you have to open them in a tab.
:::
[Here's how Vitest is configured](https://github.com/wxt-dev/wxt/blob/main/src/testing/wxt-vitest-plugin.ts) for reference.
+2 -10
View File
@@ -17,25 +17,17 @@ describe('Auto Imports', () => {
export {}
declare global {
const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext']
const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern']
const MatchPattern: typeof import('wxt/sandbox')['MatchPattern']
const browser: typeof import('wxt/browser')['browser']
const builtinDrivers: typeof import('wxt/storage')['builtinDrivers']
const createContentScriptIframe: typeof import('wxt/client')['createContentScriptIframe']
const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi']
const createStorage: typeof import('wxt/storage')['createStorage']
const defineBackground: typeof import('wxt/sandbox')['defineBackground']
const defineConfig: typeof import('wxt')['defineConfig']
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
const defineDriver: typeof import('wxt/storage')['defineDriver']
const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript']
const fakeBrowser: typeof import('wxt/testing')['fakeBrowser']
const joinKeys: typeof import('wxt/storage')['joinKeys']
const normalizeBaseKey: typeof import('wxt/storage')['normalizeBaseKey']
const normalizeKey: typeof import('wxt/storage')['normalizeKey']
const prefixStorage: typeof import('wxt/storage')['prefixStorage']
const restoreSnapshot: typeof import('wxt/storage')['restoreSnapshot']
const snapshot: typeof import('wxt/storage')['snapshot']
const storage: typeof import('wxt/storage')['storage']
const webExtensionDriver: typeof import('wxt/storage')['webExtensionDriver']
}
"
`);
+42 -21
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.12.0",
"version": "0.13.0",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18",
@@ -36,39 +36,59 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./client": {
"import": "./dist/client.js",
"types": "./dist/client.d.ts"
"types": "./dist/client.d.ts",
"import": "./dist/client.js"
},
"./sandbox": {
"import": "./dist/sandbox.js",
"types": "./dist/sandbox.d.ts"
"types": "./dist/sandbox.d.ts",
"import": "./dist/sandbox.js"
},
"./browser": {
"import": "./dist/browser.js",
"types": "./dist/browser.d.ts"
"types": "./dist/browser.d.ts",
"import": "./dist/browser.js"
},
"./testing": {
"require": "./dist/testing.cjs",
"import": "./dist/testing.js",
"types": "./dist/testing.d.ts"
"import": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
},
"require": {
"types": "./dist/testing.d.cts",
"default": "./dist/testing.cjs"
}
},
"./storage": {
"require": "./dist/storage.cjs",
"import": "./dist/storage.js",
"types": "./dist/storage.d.ts"
"import": {
"types": "./dist/storage.d.ts",
"default": "./dist/storage.js"
},
"require": {
"types": "./dist/storage.d.cts",
"default": "./dist/storage.cjs"
}
}
},
"scripts": {
"wxt": "tsx src/cli.ts",
"build": "tsx scripts/build.ts",
"format": "prettier --write .",
"format:check": "prettier --write .",
"compile": "tsc --noEmit",
"format:check": "prettier --check .",
"lint": "run-p -c -s lint:*",
"lint:eslint": "echo 'ESLint: TODO'",
"lint:package": "publint",
"compile": "run-s -c compile:*",
"compile:wxt": "tsc --noEmit",
"compile:virtual": "tsc --noEmit -p src/virtual",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "simple-git-hooks",
@@ -83,13 +103,14 @@
"@types/webextension-polyfill": "^0.10.5",
"@webext-core/fake-browser": "^1.3.1",
"@webext-core/isolated-element": "^1.0.4",
"@webext-core/match-patterns": "^1.0.2",
"@webext-core/match-patterns": "^1.0.3",
"async-mutex": "^0.4.0",
"c12": "^1.5.1",
"cac": "^6.7.14",
"chokidar": "^3.5.3",
"consola": "^3.2.3",
"defu": "^6.1.3",
"dequal": "^2.0.3",
"esbuild": "^0.19.5",
"fast-glob": "^3.3.1",
"filesize": "^10.0.8",
@@ -108,7 +129,6 @@
"prompts": "^2.4.2",
"rollup-plugin-visualizer": "^5.9.2",
"unimport": "^3.4.0",
"unstorage": "^1.9.0",
"vite": "^5.0.0",
"web-ext-run": "^0.1.0",
"webextension-polyfill": "^0.10.0",
@@ -129,6 +149,7 @@
"npm-run-all": "^4.1.5",
"p-map": "^7.0.0",
"prettier": "^3.1.0",
"publint": "^0.2.6",
"simple-git-hooks": "^2.9.0",
"tsup": "^8.0.1",
"tsx": "^4.6.2",
@@ -137,7 +158,7 @@
"typedoc-vitepress-theme": "1.0.0-next.3",
"typescript": "^5.3.2",
"vitepress": "1.0.0-rc.31",
"vitest": "^1.0.0",
"vitest": "^1.1.0",
"vitest-mock-extended": "^1.3.1",
"vue": "^3.3.10"
},
+122 -680
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -16,18 +16,17 @@ const startTime = Date.now();
const outDir = 'dist';
await fs.rm(path.join(outDir, '*'), { recursive: true, force: true });
const preset: tsup.Options = {
const preset = {
dts: true,
silent: true,
sourcemap: false,
external: [
'vite',
'virtual:user-unlisted-script',
'virtual:user-content-script-isolated-world',
'virtual:user-content-script-main-world',
'virtual:user-background',
],
};
} satisfies tsup.Options;
function spinnerPMap(configs: tsup.Options[]) {
let completed = 0;
@@ -89,6 +88,7 @@ const config: tsup.Options[] = [
format: ['esm'],
splitting: false,
dts: false,
external: [...preset.external, 'wxt'],
},
// CJS-only
{
+730 -9
View File
@@ -1,11 +1,12 @@
import { fakeBrowser } from '@webext-core/fake-browser';
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { browser } from '~/browser';
import { storage } from '~/storage';
describe('Storage Utils', () => {
beforeEach(() => {
fakeBrowser.reset();
storage.unwatch();
});
describe.each(['local', 'sync', 'managed', 'session'] as const)(
@@ -15,33 +16,80 @@ describe('Storage Utils', () => {
it('should return the value from the correct storage area', async () => {
const expected = 123;
await fakeBrowser.storage[storageArea].set({ count: expected });
expect(await storage.getItem(`${storageArea}:count`)).toBe(expected);
const actual = await storage.getItem(`${storageArea}:count`);
expect(actual).toBe(expected);
});
it("should return null if the value doesn't exist", async () => {
expect(await storage.getItem(`${storageArea}:count`)).toBeNull();
const actual = await storage.getItem(`${storageArea}:count`);
expect(actual).toBeNull();
});
it('should return the default value if passed in options', async () => {
const expected = 0;
const actual = await storage.getItem(`${storageArea}:count`, {
defaultValue: expected,
});
expect(actual).toBe(expected);
});
});
describe('getItems', () => {
it('should return an array of values in the same order as the keys passed in', async () => {
it('should return an array of values', async () => {
const expected = [
{ key: `${storageArea}:count`, value: 234 },
{ key: `${storageArea}:installDate`, value: null },
{ key: `${storageArea}:otherValue`, value: 345 },
];
const params = [
expected[0].key,
expected[1].key,
{
key: expected[2].key,
options: { defaultValue: expected[2].value },
},
];
const keys = expected.map((item) => item.key);
await fakeBrowser.storage[storageArea].set({
count: expected[0].value,
});
const actual = await storage.getItems(keys);
const actual = await storage.getItems(params);
expect(actual).toHaveLength(3);
expected.forEach((item) => {
expect(actual).toContainEqual(item);
});
});
});
describe('getMeta', () => {
it('should return item metadata from key+$', async () => {
const expected = { v: 1 };
await fakeBrowser.storage[storageArea].set({ count$: expected });
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
});
it('should return an empty object if missing', async () => {
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({});
});
});
describe('setItem', () => {
it('should set the value in the correct storage area', () => {});
it('should set the value in the correct storage area', async () => {
const key = `${storageArea}:count`;
const value = 321;
await storage.setItem(key, value);
});
it.each([undefined, null])(
'should remove the item from storage when setting the value to %s',
@@ -57,12 +105,685 @@ describe('Storage Utils', () => {
);
});
describe('setItems', () => {
it('should set multiple items in storage', async () => {
const expected = [
{ key: `${storageArea}:count`, value: 234 },
{ key: `${storageArea}:installDate`, value: null },
];
await fakeBrowser.storage[storageArea].set({
count: 123,
installDate: 321,
});
await storage.setItems(expected);
const actual = await storage.getItems(
expected.map((item) => item.key),
);
expect(actual).toHaveLength(2);
expected.forEach((item) => {
expect(actual).toContainEqual(item);
});
});
});
describe('setMeta', () => {
it('should set metadata at key+$', async () => {
const existing = { v: 1 };
await browser.storage[storageArea].set({ count$: existing });
const newValues = {
date: Date.now(),
};
const expected = { ...existing, ...newValues };
await storage.setMeta(`${storageArea}:count`, newValues);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
});
it.each([undefined, null])(
'should remove any properties set to %s',
async (version) => {
const existing = { v: 1 };
await browser.storage[storageArea].set({ count$: existing });
const expected = {};
await storage.setMeta(`${storageArea}:count`, { v: version });
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
},
);
});
describe('removeItem', () => {
it('should remove the key from storage', async () => {
await fakeBrowser.storage[storageArea].set({ count: 456 });
await storage.removeItem(`${storageArea}:count`);
expect(await browser.storage[storageArea].get()).toEqual({});
await storage.removeItem(`${storageArea}:count`);
const actual = await storage.getItem(`${storageArea}:count`);
expect(actual).toBeNull();
});
it('should not remove the metadata by default', async () => {
const expected = { v: 1 };
await fakeBrowser.storage[storageArea].set({
count$: expected,
count: 3,
});
await storage.removeItem(`${storageArea}:count`);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual(expected);
});
it('should remove the metadata when requested', async () => {
await fakeBrowser.storage[storageArea].set({
count$: { v: 1 },
count: 3,
});
await storage.removeItem(`${storageArea}:count`, {
removeMeta: true,
});
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({});
});
});
describe('removeItems', () => {
it('should remove multiple items', async () => {
const key1 = `${storageArea}:one`;
const key2 = `${storageArea}:two`;
const key3 = `${storageArea}:three`;
await fakeBrowser.storage[storageArea].set({
['one']: '1',
['two']: null,
['two$']: { v: 1 },
['three']: '1',
['three$']: { v: 1 },
});
await storage.removeItems([
key1,
key2,
{ key: key3, options: { removeMeta: true } },
]);
expect(await storage.getItem(key1)).toBeNull();
expect(await storage.getItem(key2)).toBeNull();
expect(await storage.getMeta(key2)).toEqual({ v: 1 });
expect(await storage.getItem(key3)).toBeNull();
expect(await storage.getMeta(key3)).toEqual({});
});
});
describe('removeMeta', () => {
it('should remove all metadata', async () => {
await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } });
await storage.removeMeta(`${storageArea}:count`);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({});
});
it('should only remove specific properties', async () => {
await fakeBrowser.storage[storageArea].set({
count$: { v: 4, d: Date.now() },
});
await storage.removeMeta(`${storageArea}:count`, ['d']);
const actual = await storage.getMeta(`${storageArea}:count`);
expect(actual).toEqual({ v: 4 });
});
});
describe('snapshot', () => {
it('should return a snapshot of the entire storage without area prefixes', async () => {
const expected = {
count: 1,
count$: { v: 2 },
example: 'test',
};
await fakeBrowser.storage[storageArea].set(expected);
const actual = await storage.snapshot(storageArea);
expect(actual).toEqual(expected);
});
it('should exclude specific properties and their metadata', async () => {
const input = {
count: 1,
count$: { v: 2 },
example: 'test',
};
const excludeKeys = ['count'];
const expected = {
example: 'test',
};
await fakeBrowser.storage[storageArea].set(input);
const actual = await storage.snapshot(storageArea, { excludeKeys });
expect(actual).toEqual(expected);
});
});
describe('restoreSnapshot', () => {
it('should restore a snapshot object by setting all values in storage', async () => {
const data = {
one: 'one',
two: 'two',
};
const existing = {
two: 'two-two',
three: 'three',
};
await fakeBrowser.storage[storageArea].set(existing);
await storage.restoreSnapshot(storageArea, data);
const actual = await storage.snapshot(storageArea);
expect(actual).toEqual({ ...existing, ...data });
});
it('should overwrite, not merge, any metadata keys in the snapshot', async () => {
const existing = {
count: 1,
count$: {
v: 2,
},
};
const data = {
count$: {
restoredAt: Date.now(),
},
};
const expected = {
...existing,
count$: data.count$,
};
await fakeBrowser.storage[storageArea].set(existing);
await storage.restoreSnapshot(storageArea, data);
const actual = await storage.snapshot(storageArea);
expect(actual).toEqual(expected);
});
});
describe('watch', () => {
it('should not trigger if the changed key is different from the requested key', async () => {
const cb = vi.fn();
storage.watch(`${storageArea}:key`, cb);
await storage.setItem(`${storageArea}:not-the-key`, '123');
expect(cb).not.toBeCalled();
});
it("should not trigger if the value doesn't change", async () => {
const cb = vi.fn();
const value = '123';
await storage.setItem(`${storageArea}:key`, value);
storage.watch(`${storageArea}:key`, cb);
await storage.setItem(`${storageArea}:key`, value);
expect(cb).not.toBeCalled();
});
it('should call the callback when the value changes', async () => {
const cb = vi.fn();
const newValue = '123';
const oldValue = null;
storage.watch(`${storageArea}:key`, cb);
await storage.setItem(`${storageArea}:key`, newValue);
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith(newValue, oldValue);
});
it('should remove the listener when calling the returned function', async () => {
const cb = vi.fn();
const unwatch = storage.watch(`${storageArea}:key`, cb);
unwatch();
await storage.setItem(`${storageArea}:key`, '123');
expect(cb).not.toBeCalled();
});
});
describe('unwatch', () => {
it('should remove all watch listeners', async () => {
const cb = vi.fn();
storage.watch(`${storageArea}:key`, cb);
storage.unwatch();
await storage.setItem(`${storageArea}:key`, '123');
expect(cb).not.toBeCalled();
});
});
describe('defineItem', () => {
describe('versioning', () => {
/**
* This works because fakeBrowser is syncrounous, and is will finish any number of chained
* calls within a single tick of the event loop, ie: a timeout of 0.
*/
async function waitForMigrations() {
return new Promise((res) => setTimeout(res));
}
it('should migrate values to the latest when a version upgrade is detected', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
count$: { v: 1 },
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(12);
expect(actualMeta).toEqual({ v: 3 });
expect(migrateToV2).toBeCalledTimes(1);
expect(migrateToV2).toBeCalledWith(2);
expect(migrateToV3).toBeCalledTimes(1);
expect(migrateToV3).toBeCalledWith(4);
});
it("should not run migrations if the value doesn't exist yet", async () => {
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toBeNull();
expect(actualMeta).toEqual({});
expect(migrateToV2).not.toBeCalled();
expect(migrateToV3).not.toBeCalled();
});
it('should run the v2 migration when converting an unversioned item to a versioned one', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 2,
migrations: {
2: migrateToV2,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(4);
expect(actualMeta).toEqual({ v: 2 });
expect(migrateToV2).toBeCalledTimes(1);
expect(migrateToV2).toBeCalledWith(2);
});
it('Should not run old migrations if the version is unchanged', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
count$: { v: 3 },
});
const migrateToV2 = vi.fn((oldCount) => oldCount * 2);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
storage.defineItem<number, { v: number }>(`${storageArea}:count`, {
version: 3,
migrations: {
2: migrateToV2,
3: migrateToV3,
},
});
await waitForMigrations();
expect(migrateToV2).not.toBeCalled();
expect(migrateToV3).not.toBeCalled();
});
it('should skip missing migration functions', async () => {
await fakeBrowser.storage[storageArea].set({
count: 2,
count$: { v: 0 },
});
const migrateToV1 = vi.fn((oldCount) => oldCount * 1);
const migrateToV3 = vi.fn((oldCount) => oldCount * 3);
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
{
version: 3,
migrations: {
1: migrateToV1,
3: migrateToV3,
},
},
);
await waitForMigrations();
const actualValue = await item.getValue();
const actualMeta = await item.getMeta();
expect(actualValue).toEqual(6);
expect(actualMeta).toEqual({ v: 3 });
expect(migrateToV1).toBeCalledTimes(1);
expect(migrateToV1).toBeCalledWith(2);
expect(migrateToV3).toBeCalledTimes(1);
expect(migrateToV3).toBeCalledWith(2);
});
it('should throw an error if the new version is less than the previous version', async () => {
const prevVersion = 2;
const nextVersion = 1;
await fakeBrowser.storage[storageArea].set({
count: 0,
count$: { v: prevVersion },
});
const item = storage.defineItem(`${storageArea}:count`, {
version: nextVersion,
});
// @ts-expect-error: _migrationsCompleted is returned, but untyped
await expect(item._migrationsCompleted).rejects.toThrow(
'version downgrade detected',
);
});
});
describe('getValue', () => {
it('should return the value from storage', async () => {
const expected = 2;
const item = storage.defineItem<number>(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({ count: expected });
const actual = await item.getValue();
expect(actual).toBe(expected);
});
it('should return null if missing', async () => {
const item = storage.defineItem<number>(`${storageArea}:count`);
const actual = await item.getValue();
expect(actual).toBeNull();
});
it('should return the provided default value if missing', async () => {
const expected = 0;
const item = storage.defineItem(`${storageArea}:count`, {
defaultValue: expected,
});
const actual = await item.getValue();
expect(actual).toEqual(expected);
});
});
describe('getMeta', () => {
it('should return the value from storage at key+$', async () => {
const expected = { v: 2 };
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
);
await fakeBrowser.storage[storageArea].set({ count$: expected });
const actual = await item.getMeta();
expect(actual).toBe(expected);
});
it('should return an empty object if missing', async () => {
const expected = {};
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
);
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
});
describe('setValue', () => {
it('should set the value in storage', async () => {
const expected = 1;
const item = storage.defineItem<number>(`${storageArea}:count`);
await item.setValue(expected);
const actual = await item.getValue();
expect(actual).toBe(expected);
});
it.each([undefined, null])(
'should remove the value in storage when %s is passed in',
async (value) => {
const item = storage.defineItem<number>(`${storageArea}:count`);
// @ts-expect-error: undefined is not assignable to null, but we're testing that case on purpose
await item.setValue(value);
const actual = await item.getValue();
expect(actual).toBeNull();
},
);
});
describe('setMeta', () => {
it('should set metadata at key+$', async () => {
const expected = { date: Date.now() };
const item = storage.defineItem<number, { date: number }>(
`${storageArea}:count`,
);
await item.setMeta(expected);
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
it('should add to metadata if already present', async () => {
const existing = { v: 2 };
const newFields = { date: Date.now() };
const expected = { ...existing, ...newFields };
const item = storage.defineItem<
number,
{ date: number; v: number }
>(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({
count$: existing,
});
await item.setMeta(newFields);
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
});
describe('removeValue', () => {
it('should remove the key from storage', async () => {
const item = storage.defineItem(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({ count: 456 });
await item.removeValue();
const actual = await item.getValue();
expect(actual).toBeNull();
});
it('should not remove the metadata by default', async () => {
const item = storage.defineItem(`${storageArea}:count`);
const expected = { v: 1 };
await fakeBrowser.storage[storageArea].set({
count$: expected,
count: 3,
});
await item.removeValue();
const actual = await item.getMeta();
expect(actual).toEqual(expected);
});
it('should remove the metadata when requested', async () => {
const item = storage.defineItem(`${storageArea}:count`);
await fakeBrowser.storage[storageArea].set({
count$: { v: 1 },
count: 3,
});
await item.removeValue({ removeMeta: true });
const actual = await item.getMeta();
expect(actual).toEqual({});
});
});
describe('removeMeta', () => {
it('should remove all metadata', async () => {
const item = storage.defineItem<number, { v: number }>(
`${storageArea}:count`,
);
await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } });
await item.removeMeta();
const actual = await item.getMeta();
expect(actual).toEqual({});
});
it('should only remove specific properties', async () => {
const item = storage.defineItem<number, { v: number; d: number }>(
`${storageArea}:count`,
);
await fakeBrowser.storage[storageArea].set({
count$: { v: 4, d: Date.now() },
});
await item.removeMeta(['d']);
const actual = await item.getMeta();
expect(actual).toEqual({ v: 4 });
});
});
describe('watch', () => {
it("should not trigger if the changed key is different from the item's key", async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
item.watch(cb);
await storage.setItem(`${storageArea}:not-the-key`, '123');
expect(cb).not.toBeCalled();
});
it("should not trigger if the value doesn't change", async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
const value = '123';
await item.setValue(value);
item.watch(cb);
await item.setValue(value);
expect(cb).not.toBeCalled();
});
it('should call the callback when the value changes', async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
const newValue = '123';
const oldValue = null;
item.watch(cb);
await item.setValue(newValue);
expect(cb).toBeCalledTimes(1);
expect(cb).toBeCalledWith(newValue, oldValue);
});
it('should remove the listener when calling the returned function', async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
const unwatch = item.watch(cb);
unwatch();
await item.setValue('123');
expect(cb).not.toBeCalled();
});
});
describe('unwatch', () => {
it('should remove all watch listeners', async () => {
const item = storage.defineItem(`${storageArea}:key`);
const cb = vi.fn();
item.watch(cb);
storage.unwatch();
await item.setValue('123');
expect(cb).not.toBeCalled();
});
});
});
},
+62
View File
@@ -17,3 +17,65 @@ export interface WxtI18n extends I18n.Static {
}
export const browser: AugmentedBrowser = originalBrowser;
// re-export all the types from webextension-polyfill
// Because webextension-polyfill uses a weird namespace with "import export", there isn't a good way
// to get these types without re-listing them.
/** @ignore */
export type {
ActivityLog,
Alarms,
Bookmarks,
Action,
BrowserAction,
BrowserSettings,
BrowsingData,
CaptivePortal,
Clipboard,
Commands,
ContentScripts,
ContextualIdentities,
Cookies,
DeclarativeNetRequest,
Devtools,
Dns,
Downloads,
Events,
Experiments,
Extension,
ExtensionTypes,
Find,
GeckoProfiler,
History,
I18n,
Identity,
Idle,
Management,
Manifest,
ContextMenus,
Menus,
NetworkStatus,
NormandyAddonStudy,
Notifications,
Omnibox,
PageAction,
Permissions,
Pkcs11,
Privacy,
Proxy,
Runtime,
Scripting,
Search,
Sessions,
SidebarAction,
Storage,
Tabs,
Theme,
TopSites,
Types,
Urlbar,
UserScripts,
WebNavigation,
WebRequest,
Windows,
} from 'webextension-polyfill';
+3
View File
@@ -5,6 +5,9 @@ import consola, { LogLevels } from 'consola';
import { printHeader } from '~/core/utils/log';
import { formatDuration } from '~/core/utils/time';
// TODO: Remove. See https://github.com/wxt-dev/wxt/issues/277
process.env.VITE_CJS_IGNORE_WARNING = 'true';
const cli = cac('wxt');
cli.help();
cli.version(version);
@@ -1,6 +1,6 @@
import { ContentScriptDefinition } from '~/types';
import { browser } from '~/browser';
import { logger } from '~/client/utils/logger';
import { logger } from '~/sandbox/utils/logger';
import { WxtLocationChangeEvent, getUniqueEventName } from './custom-events';
import { createLocationWatcher } from './location-watcher';
@@ -1,4 +1,4 @@
import browser from 'webextension-polyfill';
import { browser } from '~/browser';
import {
ContentScriptAnchoredOptions,
ContentScriptPositioningOptions,
@@ -1,6 +1,6 @@
import { createIsolatedElement } from '@webext-core/isolated-element';
import { browser } from '~/browser';
import { logger } from '~/client/utils/logger';
import { logger } from '~/sandbox/utils/logger';
import { ContentScriptContext } from './content-script-context';
import {
ContentScriptAnchoredOptions,
+1 -2
View File
@@ -9,7 +9,6 @@ export * from './noopBackground';
export * from './cssEntrypoints';
export * from './bundleAnalysis';
export * from './globals';
export * from './webextensionPolyfillAlias';
export * from './webextensionPolyfillInlineDeps';
export * from './webextensionPolyfillMock';
export * from './excludeBrowserPolyfill';
export * from './entrypointGroupGlobals';
@@ -2,9 +2,8 @@ import { Plugin } from 'vite';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '~/core/utils/constants';
/**
* In dev mode, if there's not a background script listed, we need to add one.
*
* This define's a virtual module that is basically just a noop.
* In dev mode, if there's not a background script listed, we need to add one so that the web socket
* connection is setup and the extension reloads HTML pages and content scripts correctly.
*/
export function noopBackground(): Plugin {
const virtualModuleId = VIRTUAL_NOOP_BACKGROUND_MODULE_ID;
@@ -16,7 +15,7 @@ export function noopBackground(): Plugin {
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `import { defineBackground } from 'wxt/client';\nexport default defineBackground(() => void 0)`;
return `import { defineBackground } from 'wxt/sandbox';\nexport default defineBackground(() => void 0)`;
}
},
};
@@ -1,28 +0,0 @@
import path from 'node:path';
import type * as vite from 'vite';
import { InternalConfig } from '~/types';
/**
* Creates an alias to redirect "webextension-polyfill" imports to WXT's `fakeBrowser`.
*
* This should only be used during tests.
*/
export function webextensionPolyfillAlias(
config: Omit<InternalConfig, 'builder'>,
): vite.PluginOption {
return {
name: 'wxt:webextension-polyfill-test-alias',
config() {
return {
resolve: {
alias: {
'webextension-polyfill': path.resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser',
),
},
},
};
},
};
}
@@ -1,24 +0,0 @@
import type * as vite from 'vite';
/**
* Add all deps that import `webextension-polyfill` to `test.server.deps.inline`.
*
* TODO: Auto-detect non-wxt dependencies via `npm list`.
*/
export function webextensionPolyfillInlineDeps(): vite.PluginOption {
return {
name: 'wxt:testing-inline-deps',
config() {
const wxtModules = ['wxt/browser', 'wxt/client'];
return {
test: {
server: {
deps: {
inline: [...wxtModules],
},
},
},
};
},
};
}
@@ -0,0 +1,41 @@
import path from 'node:path';
import type * as vite from 'vite';
import { InternalConfig } from '~/types';
/**
* Mock `webextension-polyfill` by inlining all dependencies that import it and adding a custom
* alias so that Vite resolves to a mocked version of the module.
*
* There are two ways to mark a module as inline:
* 1. Use partial file paths ("wxt/dist/browser.js") in the `test.server.deps.inline` option.
* 2. Use module names ("wxt" or "@webext-core/messaging") in the `ssr.noExternalize` option.
*
* This plugin uses the second approach since it's a little more intuative to understand.
*
* TODO: Detect non-wxt dependencies (like `@webext-core/*`) that import `webextension-polyfill` via
* `npm list` and inline them automatically.
*/
export function webextensionPolyfillMock(
config: Omit<InternalConfig, 'builder'>,
): vite.PluginOption {
return {
name: 'wxt:testing-inline-deps',
config() {
return {
resolve: {
alias: {
// Alias to use a mocked version of the polyfill
'webextension-polyfill': path.resolve(
config.root,
'node_modules/wxt/dist/virtual/mock-browser',
),
},
},
ssr: {
// Inline all WXT modules
noExternal: ['wxt'],
},
};
},
};
}
-8
View File
@@ -150,14 +150,6 @@ async function cloneProject({
consola.warn('Failed to move _gitignore to .gitignore:', err),
);
// 3. Add .npmrc for pnpm
if (packageManager === 'pnpm') {
await fs.writeFile(
path.join(directory, '.npmrc'),
'shamefully-hoist=true\n',
);
}
spinner.succeed();
} catch (err) {
spinner.fail();
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import { BuildOutput, EntrypointGroup, InternalConfig } from '~/types';
import { findEntrypoints } from './find-entrypoints';
import { generateTypesDir } from './generate-wxt-dir';
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import { ContentScriptEntrypoint, InternalConfig } from '~/types';
import { resolvePerBrowserOption } from './entrypoints';
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import {
Entrypoint,
BackgroundEntrypoint,
+1 -1
View File
@@ -4,7 +4,7 @@
import { resolve } from 'path';
import { faker } from '@faker-js/faker';
import merge from 'lodash.merge';
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import {
FsCache,
InternalConfig,
+1
View File
@@ -6,3 +6,4 @@
export * from './define-unlisted-script';
export * from './define-background';
export * from './define-content-script';
export * from '@webext-core/match-patterns';
@@ -1,3 +1,5 @@
/// <reference types="vite/client" />
function print(method: (...args: any[]) => void, ...args: any[]) {
if (import.meta.env.MODE === 'production') return;
+570 -101
View File
@@ -1,118 +1,587 @@
/**
* Simplfied storage APIs with support for versioned fields, snapshots, metadata, and item definitions.
*
* See [the guide](https://wxt.dev/guide/storage.html) for more information.
*
* @module wxt/storage
*/
import {
Driver,
WatchCallback,
createStorage,
defineDriver,
Storage,
} from 'unstorage';
import browser, { Storage as BrowserStorage } from 'webextension-polyfill';
import { Storage, browser } from '~/browser';
import { dequal } from 'dequal/lite';
export interface WebExtensionDriverOptions {
storageArea: 'sync' | 'local' | 'managed' | 'session';
}
export const storage = createStorage();
export const webExtensionDriver: (opts: WebExtensionDriverOptions) => Driver =
defineDriver((opts) => {
const checkPermission = () => {
if (browser.storage == null)
throw Error(
"You must request the 'storage' permission to use webExtensionDriver",
);
};
const _storageListener: (
changes: BrowserStorage.StorageAreaSyncOnChangedChangesType,
) => void = (changes) => {
Object.entries(changes).forEach(([key, { newValue }]) => {
_listeners.forEach((callback) => {
callback(newValue ? 'update' : 'remove', key);
});
});
};
const _listeners = new Set<WatchCallback>();
function createStorage(): WxtStorage {
const drivers: Record<string, WxtStorageDriver> = {
local: createDriver('local'),
session: createDriver('session'),
sync: createDriver('sync'),
managed: createDriver('managed'),
};
const getDriver = (area: string) => {
const driver = drivers[area];
if (driver == null) {
const areaNames = Object.keys(drivers).join(', ');
throw Error(`Invalid area "${area}". Options: ${areaNames}`);
}
return driver;
};
const resolveKey = (key: string) => {
const [driverArea, driverKey] = key.split(':', 2);
if (driverKey == null)
throw Error(
`Storage key should be in the form of "area:key", but recieved "${key}"`,
);
return {
name: 'web-extension:' + opts.storageArea,
async hasItem(key) {
checkPermission();
const res = await browser.storage[opts.storageArea].get(key);
return res[key] != null;
},
async getItem(key) {
checkPermission();
const res = await browser.storage[opts.storageArea].get(key);
return res[key] ?? null;
},
async getItems(items) {
checkPermission();
const res = await browser.storage[opts.storageArea].get(
items.map((item) => item.key),
driverArea,
driverKey,
driver: getDriver(driverArea),
};
};
const getMetaKey = (key: string) => key + '$';
const getValueOrDefault = (value: any, defaultValue: any) =>
value ?? defaultValue ?? null;
const getMetaValue = (properties: any) =>
typeof properties === 'object' && !Array.isArray(properties)
? properties
: {};
const getItem = async (
driver: WxtStorageDriver,
driverKey: string,
opts: GetItemOptions<any> | undefined,
) => {
const res = await driver.getItem<any>(driverKey);
return getValueOrDefault(res, opts?.defaultValue);
};
const getMeta = async (driver: WxtStorageDriver, driverKey: string) => {
const metaKey = getMetaKey(driverKey);
const res = await driver.getItem<any>(metaKey);
return getMetaValue(res);
};
const setItem = async (
driver: WxtStorageDriver,
driverKey: string,
value: any,
) => {
await driver.setItem(driverKey, value ?? null);
};
const setMeta = async (
driver: WxtStorageDriver,
driverKey: string,
properties: any | undefined,
) => {
const metaKey = getMetaKey(driverKey);
const existingFields = getMetaValue(await driver.getItem(metaKey));
const newFields = { ...existingFields };
Object.entries(properties).forEach(([key, value]) => {
if (value == null) {
delete newFields[key];
} else {
newFields[key] = value;
}
});
await driver.setItem(metaKey, newFields);
};
const removeItem = async (
driver: WxtStorageDriver,
driverKey: string,
opts: RemoveItemOptions | undefined,
) => {
await driver.removeItem(driverKey);
if (opts?.removeMeta) {
const metaKey = getMetaKey(driverKey);
await driver.removeItem(metaKey);
}
};
const removeMeta = async (
driver: WxtStorageDriver,
driverKey: string,
properties: string | string[] | undefined,
) => {
const metaKey = getMetaKey(driverKey);
if (properties == null) {
await driver.removeItem(metaKey);
} else {
const newFields = getMetaValue(await driver.getItem(metaKey));
[properties].flat().forEach((field) => delete newFields[field]);
await driver.setItem(metaKey, newFields);
}
};
const watch = (
driver: WxtStorageDriver,
driverKey: string,
cb: WatchCallback<any>,
) => {
return driver.watch(driverKey, cb);
};
const storage: WxtStorage = {
getItem: async (key, opts) => {
const { driver, driverKey } = resolveKey(key);
return await getItem(driver, driverKey, opts);
},
getItems: async (keys) => {
const areaToKeyMap = new Map<string, string[]>();
const keyToOptsMap = new Map<string, GetItemOptions<any> | undefined>();
keys.forEach((key) => {
let keyStr: string;
let opts: GetItemOptions<any> | undefined;
if (typeof key === 'string') {
keyStr = key;
} else {
keyStr = key.key;
opts = key.options;
}
const { driverArea, driverKey } = resolveKey(keyStr);
const keys = areaToKeyMap.get(driverArea) ?? [];
areaToKeyMap.set(driverArea, keys.concat(driverKey));
keyToOptsMap.set(keyStr, opts);
});
const results = await Promise.all(
Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => {
const driverResults = await drivers[driverArea].getItems(keys);
return driverResults.map((driverResult) => {
const key = `${driverArea}:${driverResult.key}`;
const value = getValueOrDefault(
driverResult.value,
keyToOptsMap.get(key)?.defaultValue,
);
return { key, value };
});
}),
);
return results.flat();
},
getMeta: async (key) => {
const { driver, driverKey } = resolveKey(key);
return await getMeta(driver, driverKey);
},
setItem: async (key, value) => {
const { driver, driverKey } = resolveKey(key);
await setItem(driver, driverKey, value);
},
setItems: async (values) => {
const areaToKeyValueMap = new Map<
string,
Array<{ key: string; value: any }>
>();
values.forEach(({ key, value }) => {
const { driverArea, driverKey } = resolveKey(key);
const values = areaToKeyValueMap.get(driverArea) ?? [];
areaToKeyValueMap.set(
driverArea,
values.concat({ key: driverKey, value }),
);
return items.map((item) => ({
key: item.key,
value: res[item.key] ?? null,
}));
},
async setItem(key, value) {
checkPermission();
await browser.storage[opts.storageArea].set({ [key]: value ?? null });
},
async setItems(items) {
checkPermission();
const map = items.reduce<Record<string, any>>((map, item) => {
map[item.key] = item.value ?? null;
return map;
}, {});
await browser.storage[opts.storageArea].set(map);
},
async removeItem(key) {
checkPermission();
await browser.storage[opts.storageArea].remove(key);
},
async getKeys() {
checkPermission();
const all = await browser.storage[opts.storageArea].get();
return Object.keys(all);
},
async clear() {
checkPermission();
await browser.storage[opts.storageArea].clear();
},
watch(callback) {
checkPermission();
_listeners.add(callback);
if (_listeners.size === 1) {
browser.storage[opts.storageArea].onChanged.addListener(
_storageListener,
});
await Promise.all(
Array.from(areaToKeyValueMap.entries()).map(
async ([driverArea, values]) => {
const driver = getDriver(driverArea);
await driver.setItems(values);
},
),
);
},
setMeta: async (key, properties) => {
const { driver, driverKey } = resolveKey(key);
await setMeta(driver, driverKey, properties);
},
removeItem: async (key, opts) => {
const { driver, driverKey } = resolveKey(key);
await removeItem(driver, driverKey, opts);
},
removeItems: async (keys) => {
const areaToKeysMap = new Map<string, string[]>();
keys.forEach((key) => {
let keyStr: string;
let opts: RemoveItemOptions | undefined;
if (typeof key === 'string') {
keyStr = key;
} else {
keyStr = key.key;
opts = key.options;
}
const { driverArea, driverKey } = resolveKey(keyStr);
const areaKeys = areaToKeysMap.get(driverArea) ?? [];
areaKeys.push(driverKey);
if (opts?.removeMeta) {
areaKeys.push(getMetaKey(driverKey));
}
areaToKeysMap.set(driverArea, areaKeys);
});
await Promise.all(
Array.from(areaToKeysMap.entries()).map(async ([driverArea, keys]) => {
const driver = getDriver(driverArea);
await driver.removeItems(keys);
}),
);
},
removeMeta: async (key, properties) => {
const { driver, driverKey } = resolveKey(key);
await removeMeta(driver, driverKey, properties);
},
snapshot: async (base, opts) => {
const driver = getDriver(base);
const data = await driver.snapshot();
opts?.excludeKeys?.forEach((key) => {
delete data[key];
delete data[getMetaKey(key)];
});
return data;
},
restoreSnapshot: async (base, data) => {
const driver = getDriver(base);
await driver.restoreSnapshot(data);
},
watch: (key, cb) => {
const { driver, driverKey } = resolveKey(key);
return watch(driver, driverKey, cb);
},
unwatch() {
Object.values(drivers).forEach((driver) => {
driver.unwatch();
});
},
defineItem: (key, opts) => {
const { driver, driverKey } = resolveKey(key);
const { version: targetVersion = 1, migrations = {} } = opts ?? {};
if (targetVersion < 1) {
throw Error(
'Storage item version cannot be less than 1. Initial versions should be set to 1, not 0.',
);
}
const runMigrations = async () => {
const [value, meta] = await Promise.all([
// TODO: Optimize with getItems
getItem(driver, driverKey, undefined),
getMeta(driver, driverKey),
]);
if (value == null) return;
const currentVersion = meta.v ?? 1;
if (currentVersion > targetVersion) {
throw Error(
`[wxt/storage] Migration ignored for "${key}", version downgrade detected (${currentVersion} -> ${targetVersion})`,
);
}
return () => {
_listeners.delete(callback);
if (_listeners.size === 0) {
browser.storage[opts.storageArea].onChanged.removeListener(
_storageListener,
);
}
};
},
};
});
const migrationsToRun = Array.from(
{ length: targetVersion - currentVersion },
(_, i) => currentVersion + i + 1,
);
let migratedValue = value;
for (const migrateToVersion of migrationsToRun) {
migratedValue =
(await migrations?.[migrateToVersion]?.(migratedValue)) ??
migratedValue;
}
await Promise.all([
// TODO: Optimize with `setItem`
setItem(driver, driverKey, migratedValue),
setMeta(driver, driverKey, { v: targetVersion }),
]);
};
let _migrationsCompleted = runMigrations();
function createWebExtensionStorage() {
const storage = createStorage();
storage.mount('local', webExtensionDriver({ storageArea: 'local' }));
storage.mount('session', webExtensionDriver({ storageArea: 'session' }));
storage.mount('sync', webExtensionDriver({ storageArea: 'sync' }));
storage.mount('managed', webExtensionDriver({ storageArea: 'managed' }));
return {
_migrationsCompleted,
getValue: () => getItem(driver, driverKey, opts),
getMeta: () => getMeta(driver, driverKey),
setValue: (value) => setItem(driver, driverKey, value),
setMeta: (properties) => setMeta(driver, driverKey, properties),
removeValue: (opts) => removeItem(driver, driverKey, opts),
removeMeta: (properties) => removeMeta(driver, driverKey, properties),
watch: (cb) => watch(driver, driverKey, cb),
};
},
};
return storage;
}
export type StorageValue = null | string | number | boolean | object;
function createDriver(
storageArea: 'local' | 'session' | 'sync' | 'managed',
): WxtStorageDriver {
const getStorageArea = () => {
if (browser.storage == null)
throw Error(
"You must add the 'storage' permission to your manifest to use 'wxt/storage'",
);
export const storage: Storage<StorageValue> = createWebExtensionStorage();
return browser.storage[storageArea];
};
const watchListeners = new Set<
(changes: Storage.StorageAreaOnChangedChangesType) => void
>();
return {
getItem: async (key) => {
const res = await getStorageArea().get(key);
return res[key];
},
getItems: async (keys) => {
const result = await getStorageArea().get(keys);
return keys.map((key) => ({ key, value: result[key] ?? null }));
},
setItem: async (key, value) => {
if (value == null) {
await getStorageArea().remove(key);
} else {
await getStorageArea().set({ [key]: value });
}
},
setItems: async (values) => {
const map = values.reduce<Record<string, unknown>>(
(map, { key, value }) => {
map[key] = value;
return map;
},
{},
);
await getStorageArea().set(map);
},
removeItem: async (key) => {
await getStorageArea().remove(key);
},
removeItems: async (keys) => {
await getStorageArea().remove(keys);
},
snapshot: async () => {
return await getStorageArea().get();
},
restoreSnapshot: async (data) => {
await getStorageArea().set(data);
},
watch(key, cb) {
const listener = (changes: Storage.StorageAreaOnChangedChangesType) => {
const change = changes[key];
if (change == null) return;
if (dequal(change.newValue, change.oldValue)) return;
cb(change.newValue ?? null, change.oldValue ?? null);
};
getStorageArea().onChanged.addListener(listener);
watchListeners.add(listener);
return () => {
getStorageArea().onChanged.removeListener(listener);
watchListeners.delete(listener);
};
},
unwatch() {
watchListeners.forEach((listener) => {
getStorageArea().onChanged.removeListener(listener);
});
watchListeners.clear();
},
};
}
export * from 'unstorage';
export interface WxtStorage {
/**
* Get an item from storage, or return `null` if it doesn't exist.
*
* @example
* await storage.getItem<number>("local:installDate");
*/
getItem<T>(key: string, opts?: GetItemOptions<T>): Promise<T | null>;
/**
* Get multiple items from storage. There is no guarentee of order in the returned array.
*
* @example
* await storage.getItems(["local:installDate", "session:someCounter"]);
*/
getItems(
keys: Array<string | { key: string; options?: GetItemOptions<any> }>,
): Promise<Array<{ key: string; value: any }>>;
/**
* Return an object containing metadata about the key. Object is stored at `key + "$"`. If value
* is not an object, it returns an empty object.
*
* @example
* await storage.getMeta("local:installDate");
*/
getMeta<T extends Record<string, unknown>>(key: string): Promise<T>;
/**
* Set a value in storage. Setting a value to `null` or `undefined` is equivalent to calling
* `removeItem`.
*
* @example
* await storage.setItem<number>("local:installDate", Date.now());
*/
setItem<T>(key: string, value: T | null): Promise<void>;
/**
* Set multiple values in storage. If a value is set to `null` or `undefined`, the key is removed.
*
* @example
* await storage.setItem([
* { key: "local:installDate", value: Date.now() },
* { key: "session:someCounter, value: 5 },
* ]);
*/
setItems(values: Array<{ key: string; value: any }>): Promise<void>;
/**
* Sets metadata properties. If some properties are already set, but are not included in the
* `properties` parameter, they will not be removed.
*
* @example
* await storage.setMeta("local:installDate", { appVersion });
*/
setMeta<T extends Record<string, unknown>>(
key: string,
properties: T | null,
): Promise<void>;
/**
* Removes an item from storage.
*
* @example
* await storage.removeItem("local:installDate");
*/
removeItem(key: string, opts?: RemoveItemOptions): Promise<void>;
/**
* Remove a list of keys from storage.
*/
removeItems(
keys: Array<string | { key: string; options?: RemoveItemOptions }>,
): Promise<void>;
/**
* Remove the entire metadata for a key, or specific properties by name.
*
* @example
* // Remove all metadata properties from the item
* await storage.removeMeta("local:installDate");
*
* // Remove only specific the "v" field
* await storage.removeMeta("local:installDate", "v")
*/
removeMeta(key: string, properties?: string | string[]): Promise<void>;
/**
* Return all the items in storage.
*/
snapshot(
base: string,
opts?: SnapshotOptions,
): Promise<Record<string, unknown>>;
/**
* Restores the results of `snapshot`. If new properties have been saved since the snapshot, they are
* not overridden. Only values existing in the snapshot are overritten.
*/
restoreSnapshot(base: string, data: any): Promise<void>;
/**
* Watch for changes to a specific key in storage.
*/
watch<T>(key: string, cb: WatchCallback<T>): Unwatch;
/**
* Remove all watch listeners.
*/
unwatch(): void;
/**
* Define a constant with utilities for reading/writing to a single value in storage.
*
* @example
* export const installDate = storage.defineItem<number>("local:installDate");
*/
defineItem<TValue, TMetadata extends Record<string, unknown> = {}>(
key: string,
options?: WxtStorageItemOptions<TValue>,
): WxtStorageItem<TValue, TMetadata>;
}
interface WxtStorageDriver {
getItem<T>(key: string): Promise<T | null>;
getItems(keys: string[]): Promise<{ key: string; value: any }[]>;
setItem<T>(key: string, value: T | null): Promise<void>;
setItems(values: Array<{ key: string; value: any }>): Promise<void>;
removeItem(key: string): Promise<void>;
removeItems(keys: string[]): Promise<void>;
snapshot(): Promise<Record<string, unknown>>;
restoreSnapshot(data: Record<string, unknown>): Promise<void>;
watch<T>(key: string, cb: WatchCallback<T>): Unwatch;
unwatch(): void;
}
export interface WxtStorageItem<
TValue,
TMetadata extends Record<string, unknown>,
> {
/**
* Get the latest value from storage.
*/
getValue(): Promise<TValue>;
/**
* Get metadata.
*/
getMeta(): Promise<NullablePartial<TMetadata>>;
/**
* Set the value in storage.
*/
setValue(value: TValue | null): Promise<void>;
/**
* Set metadata properties.
*/
setMeta(properties: NullablePartial<TMetadata>): Promise<void>;
/**
* Remove the value from storage.
*/
removeValue(opts?: RemoveItemOptions): Promise<void>;
/**
* Remove all metadata or certain properties from metadata.
*/
removeMeta(properties?: string[]): Promise<void>;
/**
* Listen for changes to the value in storage.
*/
watch(cb: WatchCallback<TValue>): Unwatch;
}
export interface GetItemOptions<T> {
/**
* Value returned from `getValue` when it would otherwise return null.
*/
defaultValue?: T;
}
export interface RemoveItemOptions {
/**
* Optionally remove metadata when deleting a key.
*
* @default false
*/
removeMeta?: boolean;
}
export interface SnapshotOptions {
/**
* Exclude a list of keys. The storage area prefix should be removed since the snapshot is for a
* specific storage area already.
*/
excludeKeys?: string[];
}
export interface WxtStorageItemOptions<T> extends GetItemOptions<T> {
/**
* Provide a version number for the storage item to enable migrations. When changing the version
* in the future, migration functions will be ran on application startup.
*/
version?: number;
/**
* A map of version numbers to the functions used to migrate the data to that version.
*/
migrations?: Record<number, (oldValue: any) => any>;
}
/**
* Same as `Partial`, but includes `| null`. It makes all the properties of an object optional and
* nullable.
*/
export type NullablePartial<T> = {
[key in keyof T]+?: T[key] | undefined | null;
};
/**
* Callback called when a value in storage is changed.
*/
export type WatchCallback<T> = (newValue: T | null, oldValue: T | null) => void;
/**
* Call to remove a watch listener
*/
export type Unwatch = () => void;
+2 -4
View File
@@ -4,8 +4,7 @@ import {
download,
tsconfigPaths,
globals,
webextensionPolyfillAlias,
webextensionPolyfillInlineDeps,
webextensionPolyfillMock,
} from '../core/builders/vite/plugins';
import { getInternalConfig } from '~/core/utils/building';
import { InlineConfig } from '../types';
@@ -27,8 +26,7 @@ import { InlineConfig } from '../types';
*/
export function WxtVitest(inlineConfig?: InlineConfig): vite.PluginOption {
return getInternalConfig(inlineConfig ?? {}, 'serve').then((config) => [
webextensionPolyfillAlias(config),
webextensionPolyfillInlineDeps(),
webextensionPolyfillMock(config),
unimport(config),
globals(config),
download(config),
+1 -1
View File
@@ -1,5 +1,5 @@
import type * as vite from 'vite';
import type { Manifest, Scripting } from 'webextension-polyfill';
import type { Manifest, Scripting } from '~/browser';
import { UnimportOptions } from 'unimport';
import { LogLevel } from 'consola';
import { ContentScriptContext } from '../client/content-scripts/content-script-context';
+7
View File
@@ -0,0 +1,7 @@
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
declare const __COMMAND__: 'build' | 'serve';
declare const __DEV_SERVER_PROTOCOL__: string;
declare const __DEV_SERVER_HOSTNAME__: string;
declare const __DEV_SERVER_PORT__: string;
declare const __MANIFEST_VERSION__: 2 | 3;
declare const __ENTRYPOINT__: string;
+1 -1
View File
@@ -11,7 +11,7 @@ import {
} from './external';
import { UnimportOptions } from 'unimport';
import { ResolvedConfig } from 'c12';
import type { Manifest } from 'webextension-polyfill';
import type { Manifest } from '~/browser';
import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
export interface InternalConfig {
+11
View File
@@ -0,0 +1,11 @@
# WXT Virtual Entrypoints
This folder contains scripts that are either loaded as entrypoints to JS files or included in HTML files, just like a project using WXT might load their own scripts.
While they are bundled and shipped inside WXT, Vite considers them apart of your project's source code, not WXT's. This means they cannot import 3rd party modules directly, otherwise `pnpm i --shamefully-hoist=false` and Yarn PnP will fail.
For this reason, the virtual entrypoints get their own TS project to isolate them from the rest of the project. They can only import from `wxt/*` or utils that don't have any imports from node_modules, like the logger.
When bundling WXT for publishing to NPM, all the `wxt/*` imports are marked as external and resolved when building your application. Other imports are added inline.
See https://github.com/wxt-dev/wxt/issues/286#issuecomment-1858888390 for more details.
+5 -5
View File
@@ -1,9 +1,9 @@
import definition from 'virtual:user-background';
import { setupWebSocket } from '../client/utils/setup-web-socket';
import { logger } from '../client/utils/logger';
import browser from 'webextension-polyfill';
import { keepServiceWorkerAlive } from '../client/utils/keep-service-worker-alive';
import { reloadContentScript } from '../client/utils/reload-content-scripts';
import { setupWebSocket } from './utils/setup-web-socket';
import { logger } from '../sandbox/utils/logger';
import { browser } from 'wxt/browser';
import { keepServiceWorkerAlive } from './utils/keep-service-worker-alive';
import { reloadContentScript } from './utils/reload-content-scripts';
if (__COMMAND__ === 'serve') {
try {
@@ -1,6 +1,6 @@
import definition from 'virtual:user-content-script-isolated-world';
import { logger } from '~/client/utils/logger';
import { ContentScriptContext } from '~/client/content-scripts/content-script-context';
import { logger } from '../sandbox/utils/logger';
import { ContentScriptContext } from 'wxt/client';
(async () => {
try {
@@ -1,5 +1,5 @@
import definition from 'virtual:user-content-script-main-world';
import { logger } from '~/client/utils/logger';
import { logger } from '../sandbox/utils/logger';
(async () => {
try {
+1 -1
View File
@@ -1,3 +1,3 @@
import { fakeBrowser as mockBrowser } from '~/testing';
import { fakeBrowser as mockBrowser } from 'wxt/testing';
export default mockBrowser;
+2 -4
View File
@@ -1,7 +1,5 @@
/// <reference types="vite/client" />
import { logger } from '~/client/utils/logger';
import { setupWebSocket } from '~/client/utils/setup-web-socket';
import { logger } from '../sandbox/utils/logger';
import { setupWebSocket } from './utils/setup-web-socket';
if (__COMMAND__ === 'serve') {
try {
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["vite/client", "../types/globals.d.ts"]
},
"include": ["./*"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
import definition from 'virtual:user-unlisted-script';
import { logger } from '~/client/utils/logger';
import { logger } from '../sandbox/utils/logger';
(async () => {
try {
@@ -1,4 +1,4 @@
import browser from 'webextension-polyfill';
import { browser } from 'wxt/browser';
/**
* https://developer.chrome.com/blog/longer-esw-lifetimes/
@@ -1,8 +1,14 @@
import browser, { Manifest } from 'webextension-polyfill';
import { logger } from './logger';
import { MatchPattern } from '@webext-core/match-patterns';
import { browser } from 'wxt/browser';
import { logger } from '../../sandbox/utils/logger';
import { MatchPattern } from 'wxt/sandbox';
export function reloadContentScript(contentScript: Manifest.ContentScript) {
interface ContentScript {
matches: string[];
js?: string[];
css?: string[];
}
export function reloadContentScript(contentScript: ContentScript) {
const manifest = browser.runtime.getManifest();
if (manifest.manifest_version == 2) {
void reloadContentScriptMv2(contentScript);
@@ -11,9 +17,7 @@ export function reloadContentScript(contentScript: Manifest.ContentScript) {
}
}
export async function reloadContentScriptMv3(
contentScript: Manifest.ContentScript,
) {
export async function reloadContentScriptMv3(contentScript: ContentScript) {
const id = `wxt:${contentScript.js![0]}`;
logger.log('Reloading content script:', contentScript);
const registered = await browser.scripting.getRegisteredContentScripts();
@@ -41,8 +45,6 @@ export async function reloadContentScriptMv3(
await Promise.all(matchingTabs.map((tab) => browser.tabs.reload(tab.id)));
}
export async function reloadContentScriptMv2(
contentScript: Manifest.ContentScript,
) {
export async function reloadContentScriptMv2(contentScript: ContentScript) {
throw Error('TODO: reloadContentScriptMv2');
}
@@ -1,4 +1,4 @@
import { logger } from './logger';
import { logger } from '../../sandbox/utils/logger';
interface WebSocketMessage {
type: string;
+21 -16
View File
@@ -1,34 +1,39 @@
// Types required to make the virtual modules happy.
declare module '*?raw' {
const content: any;
export default content;
}
declare module 'virtual:user-background' {
const definition: import('~/types').BackgroundDefinition;
const definition: { main: () => void };
export default definition;
}
declare module 'virtual:user-content-script-isolated-world' {
const definition: import('~/types').ContentScriptIsolatedWorldDefinition;
const definition: {
main: (
ctx: import('wxt/client').ContentScriptContext,
) => void | Promise<void>;
};
export default definition;
}
declare module 'virtual:user-content-script-main-world' {
const definition: import('~/types').ContentScriptMainWorldDefinition;
const definition: { main: () => void | Promise<void> };
export default definition;
}
declare module 'virtual:user-unlisted-script' {
const definition: import('~/types').UnlistedScriptDefinition;
const definition: { main: () => void | Promise<void> };
export default definition;
}
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
declare const __COMMAND__: 'build' | 'serve';
declare const __DEV_SERVER_PROTOCOL__: string;
declare const __DEV_SERVER_HOSTNAME__: string;
declare const __DEV_SERVER_PORT__: string;
declare const __MANIFEST_VERSION__: 2 | 3;
declare const __ENTRYPOINT__: string;
declare module 'wxt/browser' {
export const browser: import('webextension-polyfill').Browser;
}
declare module 'wxt/client' {
export class ContentScriptContext {
constructor(name: string, options: any);
}
}
declare module 'wxt/testing' {
export const fakeBrowser: import('webextension-polyfill').Browser;
}
+1 -1
View File
@@ -23,6 +23,6 @@
"@types/react-dom": "^18.2.6",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.2",
"wxt": "^0.11.0"
"wxt": "^0.12.0"
}
}
+1 -1
View File
@@ -20,6 +20,6 @@
"devDependencies": {
"typescript": "^5.3.2",
"vite-plugin-solid": "^2.8.0",
"wxt": "^0.11.0"
"wxt": "^0.12.0"
}
}
+1 -1
View File
@@ -20,6 +20,6 @@
"svelte-check": "^3.4.6",
"tslib": "^2.6.0",
"typescript": "^5.3.2",
"wxt": "^0.11.0"
"wxt": "^0.12.0"
}
}
+1 -1
View File
@@ -16,6 +16,6 @@
},
"devDependencies": {
"typescript": "^5.3.2",
"wxt": "^0.11.0"
"wxt": "^0.12.0"
}
}
+1 -1
View File
@@ -21,6 +21,6 @@
"@vitejs/plugin-vue": "^4.5.1",
"typescript": "^5.3.2",
"vue-tsc": "^1.8.25",
"wxt": "^0.11.0"
"wxt": "^0.12.0"
}
}
+1 -1
View File
@@ -5,5 +5,5 @@
"~/*": ["./src/*"]
}
},
"exclude": ["dist", "demo", "e2e/dist", "templates"]
"exclude": ["dist", "demo", "e2e/dist", "templates", "src/virtual"]
}
+1
View File
@@ -20,6 +20,7 @@ const resolve = {
alias: {
'~': path.resolve('src'),
'webextension-polyfill': path.resolve('src/virtual/mock-browser'),
'wxt/testing': path.resolve('src/testing'),
},
};