# Web extension development made easy ::u-page-hero #title Web extension development made easy #description A collection of easy-to-use utilities for writing and testing web extensions that work on all browsers. #links :::u-button --- color: neutral size: xl to: webext-core.aklinker1.io/get-started/introduction trailing-icon: i-lucide-arrow-right --- Get started ::: :::u-button --- color: neutral icon: simple-icons-github size: xl to: https://github.com/aklinker1/webext-core variant: outline --- Star on GitHub ::: :: ::u-page-section #title Packages #features :::u-page-feature --- icon: i-noto-optical-disk --- #title `@webext-core/storage` #description An alternative, type-safe API similar to local storage for accessing extension storage. [Go to docs →](webext-core.aklinker1.io/storage/installation) ::: :::u-page-feature --- icon: i-noto-left-speech-bubble --- #title `@webext-core/messaging` #description A simpler, type-safe API for sending and receiving messages. [Go to docs →](webext-core.aklinker1.io/messaging/installation) ::: :::u-page-feature --- icon: i-noto-construction-worker --- #title `@webext-core/job-scheduler` #description Easily schedule and manage reoccurring jobs. [Go to docs →](webext-core.aklinker1.io/job-scheduler/installation) ::: :::u-page-feature --- icon: i-noto-thumbs-up --- #title `@webext-core/match-patterns` #description Utilities for working with match patterns. [Go to docs →](webext-core.aklinker1.io/match-patterns/installation) ::: :::u-page-feature --- icon: i-noto-oncoming-bus --- #title `@webext-core/proxy-service` #description Call a function, but execute in a different JS context, like the background. [Go to docs →](webext-core.aklinker1.io/proxy-service/installation) ::: :::u-page-feature --- icon: i-noto-puzzle-piece --- #title `@webext-core/isolated-element` #description Create a container who's styles are isolated from the page's styles. [Go to docs →](webext-core.aklinker1.io/isolated-element/installation) ::: :::u-page-feature --- icon: i-noto-rocket --- #title `@webext-core/fake-browser` #description An in-memory implementation of the web extension APIs for testing. [Go to docs →](webext-core.aklinker1.io/fake-browser/installation) ::: :: # Introduction ## Overview All of `@webext-core`'s packages are provided via NPM. Depending on your project's setup, you can consume them in 2 different ways: 1. If your project uses a bundler or framework (like Vite, Webpack, WXT, or Plasmo), see [Bundler Setup](webext-core.aklinker1.io/#bundler-setup). 2. If your project does not use a bundler, see [Non-bundler Setup](webext-core.aklinker1.io/#non-bundler-setup) ## Bundler Setup If you haven't setup a bundler yet, I recommend using [WXT](https://wxt.dev/){rel="nofollow"} for the best DX and to support all browsers. ```bash pnpm dlx wxt@latest init ``` Install any of the packages and use them normally. Everything will just work 👍 ```bash pnpm i @webext-core/storage ``` ```ts import { localExtStorage } from '@webext-core/storage'; const value = await localExtStorage.getItem('some-key'); ``` ## Non-bundler Setup If you're not using a bundler, you'll have to download each package and put it inside your project. ::note **Why download them?** :br :br With Manifest V3, [Google doesn't approve of extensions using CDN URLs directly](https://developer.chrome.com/docs/extensions/mv3/intro/mv3-overview/#remotely-hosted-code){rel="nofollow"} , considering it "remotely hosted code" and a security risk. So you will need to download each package and ship them with your extension. :br :br If you're not on MV3 yet, you could use the CDN, but it's still recommended to download it so it loads faster. :: All of `@webext-core` NPM packages include a minified, `dist/index.iife.js` file that will create a global variable you can use to access the package's APIs. Lets say you've put all your third-party JS files inside a `vendor/` directory, and want to install the `@webext-core/storage` package. ```text . ├─ vendor │ └─ jquery.min.js └─ manifest.json ``` You can download the package like so: ```bash mkdir -p vendor/webext-core curl -o vendor/webext-core/storage.js https://cdn.jsdelivr.net/npm/@webext-core/storage/dist/index.iife.js ``` You project should now look like this: ```text . ├─ vendor │ ├─ jquery.min.js │ └─ webext-core │ └─ storage.js └─ manifest.json ``` Now you can include the `vendor/webext-core/storage.js` file in your extension! Each package sets up it's own global variable, so refer to the individual docs for that variable's name. In this case, it's `webExtCoreStorage`. ###### HTML Files ```html ``` ###### Content Scripts ```json "content_scripts": [{ "matches": [...], "js": ["vendor/webext-core/storage.js", "your-content-script.js"] }] ``` ###### MV2 Background ```json "background": { "scripts": ["vendor/webext-core/storage.js", "your-background-script.js"] } ``` ###### MV3 Background For MV3 background scripts, you need to use a bundler since `background.service_worker` only accepts a single script. # Browser Support ## Overview The `@webext-core` packages will work on: | Browser | Supported Versions | | ---------- | ------------------ | | Chrome | >= 87 | | Firefox | >= 78 | | Safari *1* | >= 14 | | Edge | >= 88 | # Contributing [![](https://contrib.rocks/image?repo=aklinker1/webext-core)](https://github.com/aklinker1/webext-core/graphs/contributors) ## First Time Contributing It's easy! Here are some resources to get started: - {rel="nofollow"} - {rel="nofollow"} ## Project Goals The goal of `webext-core` is to create useful, targeted, quality utilities for creating and publishing web extensions. Not just *Chrome* extensions, but web extensions that work on all browsers, for all manifest versions. With that in mind, there's a couple of expectations I have around new code: - Code is written in TypeScript and packages provide great TypeScript support. - Utilities support all browsers. - Well unit tested. I won't require 100% coverage, but it should be close. ## Before You Contribute If you're just fixing a bug or improving the docs, feel free to open a PR, no questions asked! If you want to add a new package or feature, open an issue first. That way we can collaborate and make sure it fits the purpose listed in the [project goals](webext-core.aklinker1.io/#project-goals). If you open a PR, but it's not something I want to maintain or it doesn't fit this project, you will have wasted your time. We both have lives to live 😃. ## Development Setup You'll need to install [Bun](https://bun.sh){rel="nofollow"} before contributing. Then you can fork the repo, install the dependencies, and build the packages for the first time! ```bash git clone {your-fork} cd webext-core bun i bun run build ``` ## Project Layout The `webext-core` repo is a monorepo containing all the packages under the [`@webext-core` scope](https://www.npmjs.com/search?q=%40webext-core){rel="nofollow"}. Here's an overview of the main directories: - `docs`: The website for {rel="nofollow"} - `packages/*`: Each NPM package has it's own directory - `packages/*-demo`: Some packages have a demo extension Each package's README (`packages/*/README.md`) will have additional details for setting up or testing the package. In general, all packages are the same. - They all have a `README.md` with additional documentation - They all use `src/index.ts` as the entrypoint - They all use `tsdown` for building the final package for NPM - They're all written in TypeScript - They all share the same basic scripts for common tasks ### Scripts In the root directory, you can run the following scripts: ```bash bun run build:all # Run the build script for all packages bun run check:all # Run TS, Oxlint, Oxfmt, Publint, etc bun run test:all # Run unit tests for all packages ``` Or `cd` into a package's directory and run these scripts ```bash bun run build # Build the package and it's dependencies bun run check # Check for type errors bun run test # Run unit tests in watch mode ``` Each directory might have additional scripts you can run. See each `package.json` for a complete list. ## Publishing Packages > Only owners of the repo can publish a new version of the extension. Use the [Publish Workflow](https://github.com/aklinker1/webext-core/actions/workflows/publish-packages.yml){rel="nofollow"} to publish a package. It will: 1. Detect the version bump for the package 2. Bump, commit, and push new version 3. Publish to NPM 4. Create github release Use the "Dry Run" setting and look at the logs if you're not sure what the version will be bumped to. ### Commit Style If you are submitting PRs, don't worry about this! A maintainer will squash and merge your PR with a commit message in the correct style. Each commit's title effects the publishing process. The style is based on conventional commits, any commits that have changes inside a package's directory will effect the version bump for that package. ### Publishing a New Package When publishing a package for the first time, publish it by hand and create a release manually. ```bash cd packages/package-name bun run build git commit -am "chore(release): package-name-v1.0.0" git tag package-name-v1.0.0 git push git push --tags npm publish ``` ## Updating Docs This documentation website is continuously deployed on Vercel. You do not need to run any actions or scripts to publish the docs. Just push changes to `main`. # Installation :badge[Vitest]{type="success"} :badge[Jest]{type="success"} :badge[Bun]{type="success"} :badge[Mocha]{type="success"} ## Overview An in-memory implementation of the web extension APIs for testing. Supports all test frameworks (Vitest, Jest, etc) and any wrapper that respects the `chrome` or `browser` globals when present (`webextension-polyfill`, `@wxt-dev/browser`). ```bash pnpm i -D @webext-core/fake-browser ``` ::alrt{type="warning"} This package only really works with projects using node, so only the NPM install steps are shown. :: See [Testing Frameworks](webext-core.aklinker1.io/fake-browser/testing-frameworks) to setup mocks for your testing framework of choice. ## Examples See [Implemented APIs](webext-core.aklinker1.io/fake-browser/implemented-apis) for example tests and details on how to use each API. # Testing Frameworks `@webext-core/fake-browser` does not depend on a specific testing framework, it will work with all of them. Setup is simple: import `@webext-core/fake-browser/auto` before using any of the APIs. No need to mock any modules! ```ts import '@webext-core/fake-browser/auto'; ``` `@webext-core/fake-browser/auto` just assigns the global `chrome` and `browser` variables to `fakeBrowser`. This is enough to make the polyfill think it's already in a browser environment with a `browser` variable, making the polyfill a noop. Below are some examples for how to do this in major testing frameworks. ## Vitest Add `@webext-core/fake-browser/auto` to your `vitest.config.ts` file as a setup file: ```ts // vitest.config.ts export default defineConfig({ test: { setupFiles: ['@webext-core/fake-browser/auto'], }, }); ``` ## Jest Add `@webext-core/fake-browser/auto` to your `jest.config.js` file as a setup file: ```js // jest.config.js module.exports = { setupFiles: ['@webext-core/fake-browser/auto'], }; ``` ## Bun Add `@webext-core/fake-browser/auto` to your `bunfig.toml` as a preload file: ```toml [test] preload = ['@webext-core/fake-browser/auto'] ``` # Triggering Events When possible, events are triggered based on other calls to other browser APIs. For example: - Calling `fakeBrowser.runtime.sendMessage()` will trigger the `fakeBrowser.runtime.onMessage` listeners - Calling `fakeBrowser.tabs.create()` will trigger the `fakeBrowser.tabs.onCreated` listeners Some events, like `runtime.onInstalled` or `alarms.onAlarm`, can't be triggered as they would be in a real extension. ::alert In the case of `onInstalled` , when is an extension "installed" during tests? Never? Or when the tests start? Either way, not useful for testing. :: ::alert In the case of `onAlarm` , alarms are meant to trigger in the far future, usually a much longer timespan than the duration of a unit test. Also, timers in tests are notoriously flakey and difficult to work with. :: Instead, the `fakeBrowser` provides a `trigger` method on every implemented event that you can call to trigger them manually. Pass in the arguments that the listeners are called with: ```ts await fakeBrowser.runtime.onInstalled.trigger({ reason: 'install' }); await fakeBrowser.alarms.onAlarm.trigger({ name: 'alarm-name', periodInMinutes: 5, scheduledTime: Date.now(), }); await fakeBrowser.tab.onCreated.trigger({ ... }); ``` ::info If you await the call to `trigger` , it will wait for all the listener to finish running. :: # Resetting State Implemented APIs store state in memory. When unit testing, we often want to reset all that state before each test so each test has a blank state. There are 3 ways to reset that in-memory state: 1. Reset everything: `fakeBrowser.reset()` 2. Reset just one API: `fakeBrowser.{api}.resetState()` 3. Call `fakeBrowser.{api}.on{Event}.removeAllListeners()` to remove all the listeners setup for an event ::alert All the reset methods are synchronous :: For example, to clear the in-memory stored values for `browser.storage.local`, you could call any of the following: - `fakeBrowser.reset()` - `fakeBrowser.storage.resetState()` All these reset methods should show up in your editor's intellisense. ::alert Generally, you should put a call to `fakeBrowser.reset()` in a `beforeEach` block to cleanup the state before every test. :: # Implemented Apis This file lists all the implemented APIs, their caveots, limitations, and example tests. Example tests are written with vitest. ::warning **Not all APIs are implemented!** :br :br For all APIs not listed here, you will have to mock the functions behavior yourself, or you can submit a PR to add support 😄 :: ## `alarms` - All alarms APIs are implemented as in production, except for `onAlarm`. - You have to manually call `onAlarm.trigger()` for your event listeners to be executed. ## `notifications` - `create`, `clear`, and `getAll` are fully implemented - You have to manually trigger all the events (`onClosed`, `onClicked`, `onButtonClicked`, `onShown`) ### Example Tests ::code-group ```ts [ensureNotificationExists.test.ts] import { describe, it, beforeEach, vi, expect } from 'vitest'; import { browser, type Browser } from '@wxt-dev/browser'; import { fakeBrowser } from '@webext-core/fake-browser'; async function ensureNotificationExists( id: string, notification: Browser.notifications.NotificationCreateOptions, ): Promise { const notifications = await browser.notifications.getAll(); if (!notifications[id]) await browser.notifications.create(id, notification); } describe('ensureNotificationExists', () => { const id = 'some-id'; const notification: Browser.notifications.NotificationCreateOptions = { type: 'basic', title: 'Some Title', message: 'Some message...', }; beforeEach(() => { fakeBrowser.reset(); }); it('should create a notification if it does not exist', async () => { const createSpy = vi.spyOn(browser.notifications, 'create'); await ensureNotificationExists(id, notification); expect(createSpy).toBeCalledTimes(1); expect(createSpy).toBeCalledWith(id, notification); }); it('should not create the notification if it already exists', async () => { await fakeBrowser.notifications.create(id, notification); const createSpy = vi.spyOn(browser.notifications, 'create'); await ensureNotificationExists(id, notification); expect(createSpy).not.toBeCalled(); }); }); ``` ```ts [setupNotificationShownReports.test.ts] import { describe, it, beforeEach, vi, expect } from 'vitest'; import { browser } from '@wxt-dev/browser'; import { fakeBrowser } from '@webext-core/fake-browser'; async function setupNotificationShownReports( reportEvent: (notificationId: string) => void, ): Promise { browser.notifications.onShown.addListener((id) => reportEvent(id)); } describe('setupNotificationShownReports', () => { beforeEach(() => { fakeBrowser.reset(); }); it('should properly report an analytics event when a notification is shown', async () => { const reportAnalyticsEvent = vi.fn(); const id = 'notification-id'; setupNotificationShownReports(reportAnalyticsEvent); await fakeBrowser.notifications.onShown.trigger(id); expect(reportAnalyticsEvent).toBeCalledTimes(1); expect(reportAnalyticsEvent).toBeCalledWith(id); }); }); ``` :: ## `runtime` - All events have been implemented, but all of them other than `onMessage` must be triggered manually. - `runtime.id` is a hardcoded string. You can set this to whatever you want, but it is reset to the hardcoded value when calling `reset()`. - Unlike in a real production, `sendMessage` will trigger `onMessage` listeners setup in the same JS context. This allows you to add a listener when setting up your test, then call `sendMessage` to trigger it. ## `storage` - The `local`, `sync`, `session`, and `managed` storages are all stored separately in memory. - `storage.onChanged`, `storage.{area}.onChanged` events are all triggered when updating values. - Each storage area can be reset individually. ## `tabs` and `windows` - Fully implemented. - All methods trigger corresponding `tabs` events AND `windows` events depending on what happened (ie: closing the last tab of a window would trigger both `tabs.onRemoved` and `windows.onRemoved`). ## `webNavigation` - The two functions, `getFrame` and `getAllFrames` are not implemented. You will have to mock their return values yourself. - All the event listeners are implemented, but none are triggered automatically. They can be triggered manually by calling `browser.webNavigation.{event}.trigger(...)` # Installation :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} ## Overview `@webext-core/isolated-element` uses the [`ShadowRoot` API](https://developer.mozilla.org/en-US/docs/Web/API/Element/shadowRoot){rel="nofollow"} to create a custom element who's CSS is completely separate from the page it's injected into. It also allows controlling event bubbling from the isolated element to the host page. It will let you load UIs from content scripts without worrying about the page's CSS effecting your UI or events interfering with the host page, no `iframe` needed! ## Installation ###### NPM ```bash pnpm i @webext-core/isolated-element ``` ```ts import { createIsolatedElement } from '@webext-core/isolated-element'; ``` ###### CDN ```bash curl -o isolated-element.js https://cdn.jsdelivr.net/npm/@webext-core/isolated-element/dist/index.iife.js ``` ```html ``` ## Usage `createIsolatedElement` returns two elements: - `parentElement` needs to be added to the DOM where you want your UI to show up. - `isolatedElement` is where you should mount your UI. Here, we're creating the UI using vanilla JS. ```ts // content-script.ts import { createIsolatedElement } from '@webext-core/isolated-element'; import { browser } from '@wxt-dev/browser'; const { parentElement, isolatedElement } = await createIsolatedElement({ name: 'some-name', css: { url: browser.runtime.getURL('/path/to/styles.css'), }, isolateEvents: true, // or array of event names to isolate, e.g., ['click', 'keydown'] }); // Mount our UI inside the isolated element const ui = document.createElement('div'); ui.textContent = 'Isolated text'; isolatedElement.appendChild(ui); // Add the UI to the DOM document.body.append(parentElement); ``` Here's a couple of other ways to mount your UI inside the `isolatedElement`: ### Vue ```ts import { createApp } from 'vue'; import App from './App.vue'; createApp(App).mount(isolatedElement); ``` ### React ```ts import ReactDOM from 'react-dom'; import App from './App.tsx'; ReactDOM.createRoot(isolatedElement).render(); ``` # API Reference ## `createIsolatedElement` ```ts async function createIsolatedElement(options: CreateIsolatedElementOptions): Promise<{ parentElement: HTMLElement; isolatedElement: HTMLElement; shadow: ShadowRoot; }> { // ... } ``` Create an HTML element that has isolated styles from the rest of the page. ### Parameters - ***`options: CreateIsolatedElementOptions`*** ### Returns A `parentElement` that can be added to the DOM, the `shadow` root, and an `isolatedElement` that you should mount your UI to. ### Examples ```ts const { isolatedElement, parentElement } = createIsolatedElement({ name: 'example-ui', css: { textContent: 'p { color: red }' }, isolateEvents: true, // or ['keydown', 'keyup', 'keypress'] }); // Create and mount your app inside the isolation const ui = document.createElement('p'); ui.textContent = 'Example UI'; isolatedElement.appendChild(ui); // Add the UI to the DOM document.body.appendChild(parentElement); ``` ## `CreateIsolatedElementOptions` ```ts interface CreateIsolatedElementOptions { name: string; mode?: "open" | "closed"; css?: { url: string } | { textContent: string }; isolateEvents?: boolean | string[]; } ``` Options that can be passed into `createIsolatedElement`. ### Properties - ***`name: string`***:br An HTML tag name used for the shadow root container. Note that you can't attach a shadow root to every type of element. There are some that can't have a shadow DOM for security reasons (for example [).]() []() - []() []()[***`mode?: 'open' | 'closed'`*** (default: `'closed'`) :br See ]()[`ShadowRoot.mode`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode){rel="nofollow"}. - ***`css?: { url: string } | { textContent: string }`***:br Either the URL to a CSS file or the text contents of a CSS file. The styles will be mounted inside the shadow DOM so they don't effect the rest of the page. - ***`isolateEvents?: boolean | string[]`***:br When enabled, `event.stopPropagation` will be called on events trying to bubble out of the shadow root. - Set to `true` to stop the propagation of a default set of events, `["keyup", "keydown", "keypress"]` - Set to an array of event names to stop the propagation of a custom list of events :br:br --- *API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts){rel="nofollow"}* # Installation :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} ## Overview `@webext-core/job-scheduler` uses the [alarms API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms){rel="nofollow"} to manage different types of reoccurring jobs: - One-time jobs - Jobs that run on an interval - Cron jobs ## Installation ###### NPM ```bash pnpm i @webext-core/job-scheduler ``` ```ts import { defineJobScheduler } from '@webext-core/job-scheduler'; ``` ###### CDN ```bash curl -o job-scheduler.js https://cdn.jsdelivr.net/npm/@webext-core/job-scheduler/dist/index.iife.js ``` ```html ``` ## Usage `defineJobScheduler` should to be executed once in the background. It returns an object that can be used to schedule or remove jobs. ::code-group ```ts [background.ts] import { defineJobScheduler } from '@webext-core/job-scheduler'; const jobs = defineJobScheduler(); ``` :: Once the job scheduler is created, call `scheduleJob`. To see all the options for configuring jobs, see the [API reference](webext-core.aklinker1.io/job-scheduler/api). ::code-group ```ts [One time] jobs.scheduleJob({ id: 'job1', type: 'once', date: Date.now() + 1.44e7, // In 4 hours execute: () => { console.log('Executed job once'); }, }); ``` ```ts [On an interval] jobs.scheduleJob({ id: 'job2', type: 'interval', interval: DAY, // Runs every 24 hours execute: () => { console.log('Executed job on interval'); }, }); ``` ```ts [CRON] jobs.scheduleJob({ id: 'job3', type: 'cron', expression: '0 */2 * * *', // https://crontab.guru/#0_*/2_*_*_* execute: () => { console.log('Executed CRON job'); }, }); ``` :: If a job has been created in the past, and nothing has changed, `scheduleJob` will do nothing. If something changed, it will update the job. To stop running a job, call `removeJob`. ```ts job.removeJob('some-old-job'); ``` ::warning This is especially important when releasing an update after removing a job that is no longer needed - even if `scheduleJob` isn't called anymore. If you don't call `removeJob` , the alarm managed internally for that job will not be deleted. :: ## Parameterized Jobs You can't pass parameters into each individual job execution, but you can pass dependencies when scheduling a job by using higher-order functions: ::code-group ```ts [background.ts] import { someJob } from './someJob.ts'; // Create your dependency const someDependency = new SomeDependency(); const jobs = defineJobScheduler(); jobs.scheduleJob({ // ... execute: someJob(someDependency), }); ``` ```ts [someJob.ts] function someJob(someDependency: SomeDependency) { return async () => { // Use someDependency }; } ``` :: ## Other JS Contexts You should only create one scheduler, and it should be created in the background page/service worker. To schedule jobs from a UI or content script, you can use [`@webext-core/proxy-service`](webext-core.aklinker1.io/proxy-service/installation). ::code-group ```ts [job-scheduler.ts] import { defineProxyService } from '@webext-core/proxy-service'; export const [registerJobScheduler, getJobScheduler] = defineProxyService('JobScheduler', () => defineJobScheduler(), ); ``` ```ts [background.ts] import { registerJobScheduler } from './job-scheduler'; const jobs = registerJobScheduler(); // Schedule any jobs in the background jobs.scheduleJob({ // ... }); ``` ```ts [content-script.ts] import { getJobScheduler } from './job-scheduler'; // Get a proxy instance and use it to schedule more jobs const jobs = getJobScheduler(); jobs.scheduleJob({ // ... }); ``` :: # API Reference ## `CronJob` ```ts interface CronJob extends cron.ParserOptions { id: string; type: "cron"; expression: string; execute: ExecuteFn; } ``` A job that is executed based on a CRON expression. Backed by `cron-parser`. [`cron.ParserOptions`](https://github.com/harrisiirak/cron-parser#options){rel="nofollow"} includes options like timezone. ### Properties - ***`id: string`*** - ***`type: 'cron'`*** - ***`expression: string`***:br See `cron-parser`'s [supported expressions](https://github.com/harrisiirak/cron-parser#supported-format){rel="nofollow"} - ***`execute: ExecuteFn`*** ## `defineJobScheduler` ```ts function defineJobScheduler(options?: JobSchedulerConfig): JobScheduler { // ... } ``` > Requires the `alarms` permission. Creates a `JobScheduler` backed by the [alarms API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms){rel="nofollow"}. ### Parameters - ***`options?: JobSchedulerConfig`*** ### Returns A `JobScheduler` that can be used to schedule and manage jobs. ## `ExecuteFn` ```ts type ExecuteFn = () => Promise | any; ``` Function ran when executing the job. Errors are automatically caught and will trigger the `"error"` event. If a value is returned, the result will be available in the `"success"` event. ## `IntervalJob` ```ts interface IntervalJob { id: string; type: "interval"; duration: number; immediate?: boolean; execute: ExecuteFn; } ``` A job that executes on a set interval, starting when the job is scheduled for the first time. ### Properties - ***`id: string`*** - ***`type: 'interval'`*** - ***`duration: number`***:br Interval in milliseconds. Due to limitations of the alarms API, it must be greater than 1 minute. - ***`immediate?: boolean`*** (default: `false`) :br Execute the job immediately when it is scheduled for the first time. If `false`, it will execute for the first time after `duration`. This has no effect when updating an existing job. - ***`execute: ExecuteFn`*** ## `Job` ```ts type Job = IntervalJob | CronJob | OnceJob; ``` ## `JobScheduler` ```ts interface JobScheduler { scheduleJob(job: Job): Promise; removeJob(jobId: string): Promise; on(event: "success", callback: (job: Job, result: any) => void): RemoveListenerFn; on(event: "error", callback: (job: Job, error: unknown) => void): RemoveListenerFn; } ``` ## `JobSchedulerConfig` ```ts interface JobSchedulerConfig { logger?: Logger | null; } ``` Configures how the job scheduler behaves. ### Properties - ***`logger?: Logger | null`*** (default: `console`) :br The logger to use when logging messages. Set to `null` to disable logging. ## `Logger` ```ts interface Logger { debug(...args: any[]): void; log(...args: any[]): void; warn(...args: any[]): void; error(...args: any[]): void; } ``` Interface used to log text to the console when creating and executing jobs. ## `OnceJob` ```ts interface OnceJob { id: string; type: "once"; date: Date | string | number; execute: ExecuteFn; } ``` Runs a job once, at a specific date/time. ### Properties - ***`id: string`*** - ***`type: 'once'`*** - ***`date: Date | string | number`***:br The date to run the job on. - ***`execute: ExecuteFn`*** :br:br --- *API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts){rel="nofollow"}* # Installation :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} ## Overview `@webext-core/match-patterns` provides utilities for working with match patterns. ## Installation ###### NPM ```bash pnpm i @webext-core/match-patterns ``` ```ts import { MatchPattern } from '@webext-core/match-patterns'; ``` ###### CDN ```bash curl -o match-patterns.js https://cdn.jsdelivr.net/npm/@webext-core/match-patterns/dist/index.iife.js ``` ```html ``` ## Usage `MatchPattern` includes one function: `includes`. It can be used to check if a URL is included (or matches) the match pattern. ```ts import { MatchPattern } from '@webext-core/match-patterns'; const google = new MatchPattern('*://*.google.com'); google.includes('https://accounts.google.com'); // true google.includes('https://google.com/search?q=test'); // true const youtube = new MatchPattern('*://youtube.com/watch'); youtube.includes('https://youtube.com/watch'); // true youtube.includes('https://youtube.com/mrbeast'); // false youtube.includes('https://accounts.google.com'); // false ``` `includes` also accepts URLs and `window.location` ```ts google.includes(new URL('https://google.com')); google.includes(window.location); ``` # API Reference ## `InvalidMatchPattern` ```ts class InvalidMatchPattern extends Error { constructor(matchPattern: string, reason: string) { // ... } } ``` ## `MatchPattern` ```ts class MatchPattern { constructor(matchPattern: string) { // ... } includes(url: string | URL | Location): boolean { // ... } } ``` Class for parsing and performing operations on match patterns. ### Examples ```ts const pattern = new MatchPattern('*://google.com/*'); pattern.includes('https://google.com'); // true pattern.includes('http://youtube.com/watch?v=123'); // false ``` :br:br --- *API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts){rel="nofollow"}* # Installation :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} ## Overview `@webext-core/messaging` a simplified, type-safe wrapper around the web extension messaging APIs. It also provides a similar interface for communicating with web pages or injected scripts. ::alert Don't like lower-level messaging APIs? Try out [`@webext-core/proxy-service`](webext-core.aklinker1.io/proxy-service/installation) for a more DX-friendly approach to executing code in the background script. :: ## Installation ###### NPM ```bash pnpm i @webext-core/messaging ``` ```ts import { defineExtensionMessaging } from '@webext-core/messaging'; ``` ###### CDN ```bash curl -o messaging.js https://cdn.jsdelivr.net/npm/@webext-core/messaging/dist/index.iife.js ``` ```html ``` ## Basic Usage First, define a protocol map: ::code-group ```ts [messaging.ts] interface ProtocolMap { getStringLength(data: string): number; } ``` :: Then call `defineExtensionMessaging`, passing your `ProtocolMap` as the first type parameter. Export the `sendMessage` and `onMessage` methods. These are what the rest of your extension will use to pass messages around. ::code-group ```ts [messaging.ts] import { defineExtensionMessaging } from '@webext-core/messaging'; interface ProtocolMap { getStringLength(data: string): number; } export const { sendMessage, onMessage } = defineExtensionMessaging(); ``` :: Usually the `onMessage` function will be used in the background and messages will be sent from other parts of the extension. ::code-group ```ts [background.ts] import { onMessage } from './messaging'; onMessage('getStringLength', (message) => { return message.data.length; }); ``` ```ts [content-script.ts] import { sendMessage } from './messaging'; const length = await sendMessage('getStringLength', 'hello world'); console.log(length); // 11 ``` :: ### Sending Messages to Tabs You can also send messages from your background script to a tab, but you need to know the `tabId`. This would send the message to all frames in the tab. If you want to send a message to a specific frame, you can pass an object to `sendMessage` with the `tabId` and `frameId`. ::code-group ```ts [content-script.ts] import { onMessage } from './messaging'; onMessage('getStringLength', (message) => { return message.data.length; }); ``` ```ts [background.ts] import { sendMessage } from './messaging'; const length = await sendMessage('getStringLength', 'hello world', tabId); const length = await sendMessage('getStringLength', 'hello world', { tabId, frameId }); ``` :: ## Window Messaging Inside a content script, you may need to communicate with a webpage or an injected script running in the page's JS context. In this case, you can use `defineWindowMessenger` or `defineCustomEventMessenger`, which use the `window.postMessage` and `CustomEvent` APIs respectively. ::code-group ```ts [Window] import { defineWindowMessaging } from '@webext-core/messaging/page'; export interface WebsiteMessengerSchema { init(data: unknown): void; somethingHappened(data: unknown): void; } export const websiteMessenger = defineWindowMessaging({ namespace: '', }); ``` ```ts [Custom Event] import { defineCustomEventMessaging } from '@webext-core/messaging/page'; export interface WebsiteMessengerSchema { init(data: unknown): void; somethingHappened(data: unknown): void; } export const websiteMessenger = defineCustomEventMessaging({ namespace: '', }); ``` :: ::note **Which one should I use?** :br :br In general, if you don't need to communicate with iframes, use `defineCustomEventMessaging` . If you need to communicate with iframes, use `defineWindowMessaging` . :: Note the namespace option. Only messengers of the same type (window vs custom event) and same namespace will communicate. This prevents accidentally reacting to messages from the page or from another extension. Usually, it should be a unique string for your extension. The easiest method is to set it to `browser.runtime.id`, but if you're injecting a script, neither `chrome` nor `browser` will not be available in the page context and you'll have to use something else or hardcode the extension's ID. The messenger object can be used in the same way as the extension messenger, with `sendMessage` and `onMessage`. Here, we're injecting a script, initializing it with data, and allowing the script to send data back to our content script. ::code-group ```ts [Content Script] import { websiteMessenger } from './website-messaging'; const script = document.createElement('script'); script.src = browser.runtime.getURL('/path/to/injected.js'); document.head.appendChild(script); script.onload = () => { websiteMessenger.sendMessage('init', { ... }); }; websiteMessenger.onMessage('somethingHappened', (data) => { // React to messages from the injected script }); ``` ```ts [Injected script] import { websiteMessenger } from './website-messaging'; websiteMessenger.onMessage('init', data => { // initialize injected script // eventually, send data back to the content script // third and fourth parameter is optional // third parameter is targetOrigin is additional optional value for postMessage which is default to '*' // fourth parameter is reference of window object which is window on which message is passed passed in case of from iframe to Content Script(ie. parent window) it will be window.parent websiteMessenger.sendMessage('somethingHappened', { ... }, '*', window.parent); }); ``` :: # Protocol Maps ::alert Only relevant to TypeScript projects. :: ## Overview Protocol maps define types for `sendMessage` and `onMessage` in a single place. You'll never need to write type parameters; the data and return types will be inferred automatically! ## Syntax Protocol maps are simple interfaces passed into `defineExtensionMessaging`. They specify a list of valid message types, as well as each message's data type and return type. ```ts interface ProtocolMap { message1(): void; // No data and no return type message2(data: string): void; // Only data message3(): boolean; // Only a return type message4(data: string): boolean; // Data and return type } export const { sendMessage, onMessage } = defineExtensionMessaging(); ``` When calling `sendMessage` or `onMessage`, all the types will be inferred: ```ts onMessage('message2', ({ data /* string */ }) /* : void */ => {}); onMessage('message3', (message) /* : boolean */ => true); const res /* : boolean */ = await sendMessage('message3', undefined); const res /* : boolean */ = await sendMessage('message4', 'text'); ``` ## Async Messages All messages are async. In your protocol map, you don't need to make the return type `Promise`, `T` will work just fine. ```diff interface ProtocolMap { - someMessage(): Promise; + someMessage(): string; } ``` ## Multiple Arguments Protocol map functions should be defined with a single parameter, `data`. To pass more than one argument, make the `data` parameter an object instead! ```diff interface ProtocolMap { - someMessage(arg1: string, arg2: boolean): void; + someMessage(data: { arg1: string; arg2: boolean }): void; } ``` ```ts await sendMessage('someMessage', { arg1: ..., arg2: ... }); ``` # API Reference ## `BaseMessagingConfig` ```ts interface BaseMessagingConfig { logger?: Logger; throwOnUnknownMessageFormat?: boolean; } ``` Shared configuration between all the different messengers. ### Properties - ***`logger?: Logger`*** (default: `console`) :br The logger to use when logging messages. Set to `null` to disable logging. - ***`throwOnUnknownMessageFormat?: boolean`*** (default: `false`) :br When a message is received that doesn't follow `@webext-core/messaging`'s format, it is ignored. Set this value to true to throw an error instead of ignoring it. ## `CustomEventMessage` ```ts interface CustomEventMessage { event: CustomEvent; } ``` Additional fields available on the `Message` from a `CustomEventMessenger`. ### Properties - ***`event: CustomEvent`***:br The event that was fired, resulting in the message being passed. ## `CustomEventMessagingConfig` ```ts interface CustomEventMessagingConfig extends NamespaceMessagingConfig {} ``` Configuration passed into `defineCustomEventMessaging`. ## `CustomEventMessenger` ```ts type CustomEventMessenger> = GenericMessenger< TProtocolMap, CustomEventMessage, [] >; ``` Messenger returned by `defineCustomEventMessenger`. ## `defineCustomEventMessaging` ```ts function defineCustomEventMessaging = Record>( config: CustomEventMessagingConfig, ): CustomEventMessenger { // ... } ``` Creates a `CustomEventMessenger`. This messenger is backed by the `CustomEvent` APIs. It can be used to communicate between: - Content script and website - Content script and injected script `sendMessage` does not accept any additional arguments.. ### Examples ```ts interface WebsiteMessengerSchema { initInjectedScript(data: ...): void; } export const websiteMessenger = defineCustomEventMessenger(); // Content script websiteMessenger.sendMessage("initInjectedScript", ...); // Injected script websiteMessenger.onMessage("initInjectedScript", (...) => { // ... }) * ``` ## `defineExtensionMessaging` ```ts function defineExtensionMessaging = Record>( config?: ExtensionMessagingConfig, ): ExtensionMessenger { // ... } ``` Returns an `ExtensionMessenger` that is backed by the `browser.runtime.sendMessage` and `browser.tabs.sendMessage` APIs. It can be used to send messages to and from the background page/service worker. ## `defineWindowMessaging` ```ts function defineWindowMessaging = Record>( config: WindowMessagingConfig, ): WindowMessenger { // ... } ``` Returns a `WindowMessenger`. It is backed by the `window.postMessage` API. It can be used to communicate between: - Content script and website - Content script and injected script ### Examples ```ts interface WebsiteMessengerSchema { initInjectedScript(data: ...): void; } export const websiteMessenger = defineWindowMessaging(); // Content script websiteMessenger.sendMessage("initInjectedScript", ...); // Injected script websiteMessenger.onMessage("initInjectedScript", (...) => { // ... }) ``` ## `ExtensionMessage` ```ts interface ExtensionMessage { sender: chrome.runtime.MessageSender; } ``` Additional fields available on the `Message` from an `ExtensionMessenger`. ### Properties - ***`sender: chrome.runtime.MessageSender`***:br Information about where the message came from. See [`Runtime.MessageSender`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/MessageSender){rel="nofollow"}. ## `ExtensionMessagingConfig` ```ts interface ExtensionMessagingConfig extends BaseMessagingConfig {} ``` Configuration passed into `defineExtensionMessaging`. ## `ExtensionMessenger` ```ts type ExtensionMessenger> = GenericMessenger< TProtocolMap, ExtensionMessage, ExtensionSendMessageArgs >; ``` Messenger returned by `defineExtensionMessaging`. ## `ExtensionSendMessageArgs` ```ts type ExtensionSendMessageArgs = [arg?: number | SendMessageOptions]; ``` Send message accepts either: - No arguments to send to background - A tabId number to send to a specific tab - A SendMessageOptions object to target a specific tab and frame You cannot message between tabs directly. It must go through the background script. ## `GenericMessenger` ```ts interface GenericMessenger< TProtocolMap extends Record, TMessageExtension, TSendMessageArgs extends any[], > { sendMessage( this: void, type: TType, ...args: GetDataType extends undefined ? [data?: undefined, ...args: TSendMessageArgs] : never ): Promise>; sendMessage( this: void, type: TType, data: GetDataType, ...args: TSendMessageArgs ): Promise>; onMessage( this: void, type: TType, onReceived: ( message: Message & TMessageExtension, ) => void | MaybePromise>, ): RemoveListenerCallback; removeAllListeners(this: void): void; } ``` Messaging interface shared by all messengers. Type parameters accept: - `TProtocolMap` to define the data and return types of messages. - `TMessageExtension` to define additional fields that are available on a message inside `onMessage`'s callback - `TSendMessageArgs` to define a list of additional arguments for `sendMessage` ## `GetDataType` ```ts type GetDataType = T extends (...args: infer Args) => any ? Args["length"] extends 0 | 1 ? Args[0] : never : T extends ProtocolWithReturn ? T["BtVgCTPYZu"] : T; ``` Given a function declaration, `ProtocolWithReturn`, or a value, return the message's data type. ## `GetReturnType` ```ts type GetReturnType = T extends (...args: any[]) => infer R ? R : T extends ProtocolWithReturn ? T["RrhVseLgZW"] : void; ``` Given a function declaration, `ProtocolWithReturn`, or a value, return the message's return type. ## `Logger` ```ts interface Logger { debug(...args: any[]): void; log(...args: any[]): void; warn(...args: any[]): void; error(...args: any[]): void; } ``` Interface used to log text to the console when sending and receiving messages. ## `MaybePromise` ```ts type MaybePromise = Promise | T; ``` Either a Promise of a type, or that type directly. Used to indicate that a method can by sync or async. ## `Message` ```ts interface Message, TType extends keyof TProtocolMap> { id: number; data: GetDataType; type: TType; timestamp: number; } ``` Contains information about the message received. ### Properties - ***`id: number`***:br A semi-unique, auto-incrementing number used to trace messages being sent. - ***`data: GetDataType`***:br The data that was passed into `sendMessage` - ***`type: TType`*** - ***`timestamp: number`***:br The timestamp the message was sent in MS since epoch. ## `NamespaceMessagingConfig` ```ts interface NamespaceMessagingConfig extends BaseMessagingConfig { namespace: string; } ``` ### Properties - ***`namespace: string`***:br A string used to ensure the messenger only sends messages to and listens for messages from other messengers of the same type, with the same namespace. ## `ProtocolWithReturn` \:::danger Deprecated Use the function syntax instead: {rel="nofollow"} \::: ```ts interface ProtocolWithReturn { BtVgCTPYZu: TData; RrhVseLgZW: TReturn; } ``` Used to add a return type to a message in the protocol map. > Internally, this is just an object with random keys for the data and return types. ### Properties - ***`BtVgCTPYZu: TData`***:br Stores the data type. Randomly named so that it isn't accidentally implemented. - ***`RrhVseLgZW: TReturn`***:br Stores the return type. Randomly named so that it isn't accidentally implemented. ### Examples ```ts interface ProtocolMap { // data is a string, returns undefined type1: string; // data is a string, returns a number type2: ProtocolWithReturn; } ``` ## `RemoveListenerCallback` ```ts type RemoveListenerCallback = () => void; ``` Call to ensure an active listener has been removed. If the listener has already been removed with `Messenger.removeAllListeners`, this is a noop. ## `SendMessageOptions` ```ts interface SendMessageOptions { tabId: number; frameId?: number; } ``` Options for sending a message to a specific tab/frame ### Properties - ***`tabId: number`***:br The tab to send a message to - ***`frameId?: number`***:br The frame to send a message to. 0 represents the main frame. ## `WindowMessagingConfig` ```ts interface WindowMessagingConfig extends NamespaceMessagingConfig {} ``` Configuration passed into `defineWindowMessaging`. ## `WindowMessenger` ```ts type WindowMessenger> = GenericMessenger< TProtocolMap, {}, WindowSendMessageArgs >; ``` ## `WindowSendMessageArgs` ```ts type WindowSendMessageArgs = [targetOrigin?: string, targetWindow?: Window]; ``` For a `WindowMessenger`, `sendMessage` requires an additional argument, the `targetOrigin`. It defines which frames inside the page should receive the message. > See > {rel="nofollow"} > for more details. Message is posted on window which can as per your need like: - Parent window in iframe -> window\.parent - Iframe content window -> iframe.contentWindow - Opener original window -> window\.opener - By default global window is used to send message :br:br --- *API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts){rel="nofollow"}* # Installation :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} ## Overview `@webext-core/proxy-service` provides a simple, type-safe way to execute code in the extension's background. ::code-group ```ts [MathService.ts] // 1. Define your service export class MathService { async fibonacci(number: number): Promise { ... } } ``` ```ts [proxy-service-keys.ts] import type { ProxyServiceKey } from '@webext-core/proxy-service'; import type { MathService } from './MathService'; // ^^^^ IMPORTANT: do not import the math service's value, just it's type. // 2. [Optional] Define a key with a branded type to ensure type-safety export const MATH_SERVICE_KEY = 'math-service' as ProxyServiceKey; ``` ```ts [background.ts] import { registerService } from '@webext-core/proxy-service'; import { MathService } from './MathService'; import { MATH_SERVICE_KEY } from './proxy-service-keys'; // 3. Instantiate your service const mathService = new MathService(); // 4. Register the service BEFORE awaiting anything registerService(MATH_SERVICE_KEY, mathService); ``` ```ts [anywhere-else.ts] import { createProxyService } from './MathService'; import { MATH_SERVICE_KEY } from './proxy-service-keys'; // 5. Get a proxy of your service const mathService = createProxyService(MATH_SERVICE_KEY); // 6. Call methods like normal, they will execute in the background await mathService.fibonacci(100); ``` :: ## Installation ###### NPM ```bash pnpm i @webext-core/proxy-service ``` ```ts import { createProxyService, registerService } from '@webext-core/proxy-service'; ``` ###### CDN ```bash curl -o proxy-service.js https://cdn.jsdelivr.net/npm/@webext-core/proxy-service/dist/index.iife.js ``` ```html ``` ## Usage Lets look at a more realistic example, IndexedDB! Since the same IndexedDB database is not available in every JS context of an extension, it's common to use the IndexedDB instance in the background script as a database for web extensions. First, we need to implement our service. In this case, the service will contain CRUD operations for todos in the database: ::code-group ```ts [todos-repo.ts] import { IDBPDatabase } from 'idb'; export function createTodosRepo(idbPromise: Promise) { return { async create(todo: Todo): Promise { const idb = await idbPromise; await idb.add('todos', todo); }, async getOne(id: Pick): Promise { const idb = await idbPromise; return await idb.get('todos', id); }, async getAll(): Promise { const idb = await idbPromise; return await idb.getAll('todos'); }, async update(todo: Todo): Promise { const idb = await idbPromise; await idb.put('todos', todo); }, async delete(todo: Todo): Promise { const idb = await idbPromise; await idb.delete('todos', todo.id); }, }; } ``` :: ::alert In this example, we're using a plain object instead of a class as the service. See the [Defining Services](webext-core.aklinker1.io/proxy-service/defining-services) docs for examples of all the different ways to create a proxy service. :: Now that you have a service implemented, we need to register it in the background so it starts listening for messages from other parts of the extension. ::code-group ```ts [proxy-service-keys.ts] import type { ProxyServiceKey } from '@webext-core/proxy-service'; import type { TodosRepo } from './todos-repo'; export const TODOS_REPO_KEY = 'todos-repo' as ProxyServiceKey; ``` ```ts [background.ts] import { registerService } from '@webext-core/proxy-service'; import { openDB } from 'idb'; import { createTodosRepo } from './todos-repo'; import { TODOS_REPO_KEY } from './proxy-service-keys'; // DO NOT await the promise here. registerService must be called synchronously. const idbPromise = openDB("todos", ...); const todosRepo = createTodosRepo(idbPromise); registerService(TODOS_REPO_KEY, todosRepo); ``` :: ::alert Here, even though `openDB` returns a promise, we're not awaiting it because `registerService` must be called synchronously on service worker/background script startup. Otherwise, the message listeners might not be setup by the time a content script tries to proxy a function call. You can follow the pattern of passing `Promise` into your services and awaiting them internally to stay synchronous. :: And that's it. You can now access your IndexedDB database from any JS context inside your extension: ::code-group ```html [extension-page.html] ``` ```ts [content-script.ts] import { TODOS_REPO_KEY } from './proxy-service-keys'; import { createProxyService } from '@webext-core/proxy-service'; // Inside content scripts const todosRepo = createProxyService(TODOS_REPO_KEY); const todos = await todosRepo.getAll(); console.log(todos); ``` :: # Defining Services There are several different ways to define a services, `@webext-core/proxy-service` works with all of them! ## Class Define a class whose methods are available in other JS contexts: ```ts import { IDBPDatabase } from 'idb'; class TodosRepo { constructor(private db: Promise) {} async getAll(): Promise { const db = await this.db; return await db.getAll('todos'); } } ``` ## Object Objects can be used as services as well. All functions defined on the object are available in other contexts. ```ts import { IDBPDatabase } from 'idb'; export interface TodosRepo { getAll(): Promise; } export function createTodosRepo(dbPromise: Promise): TodosRepo { return { async getAll() { const db = await dbPromise; return await db.getAll('todos'); }, }; } ``` ## Function If you only need to define a single function, you can! ```ts import { IDBPDatabase } from 'idb'; export type GetAllTodos = () => Promise; export function createGetAllTodos(dbPromise: Promise) { return async () => { const db = await dbPromise; return await db.getAll('todos'); }; } ``` ```ts const db = openDB('todos'); const getAllTodos = createGetAllTodos(db); registerService('get-all-todos', getAllTodos); ``` ```ts const getAllTodos = createProxyService('get-all-todos'); const todos = await getAllTodos(); ``` ## Nested Objects If you need to register "deep" objects containing multiple services, you can do that as well. You can use classes, objects, and functions at any level. ```ts class TodosRepo { constructor(private db: Promise) {} async getAll(): Promise { return (await this.db).getAll('todos'); } } const createAuthorsRepo = (db: Promise) => ({ async getOne(id: string): Promise { return (await this.db).getAll('authors', id); }, }); export function createApi(db: Promise) { return { todos: new TodosRepo(db), authors: createAuthorsRepo(db), }; } ``` ```ts const db = openDB('todos', ...); const api = createApi(db); registerService("api", api); ``` ```ts const api = createProxyService('api'); const todos = await api.todos.getAll(); const firstAuthor = await api.authors.getOne(todos.authorId); ``` # Service Keys There are two ways of defining service keys: 1. **Using a string literal**: This is simple, but it provides no type-safety guaranteeing that the registered service is the same type as the proxy. Notice how you have to provide a type argument to the `createProxyService` function. ```ts const key = 'math-service'; registerService(key, new MathService()); const proxy = createProxyService(key); ``` 2. **Cast to `ProxyServiceKey`**: The key is cast to a "branded type" that contains the service type. This guarantees you both register the expected service and create a proxy with the correct type with minimal code. ```ts import type { ProxyServiceKey } from '@webext-core/proxy-service'; const key = 'math-service' as ProxyServiceKey; registerService(key, new MathService()); const proxy = createProxyService(key); ``` Using `ProxyServiceKey` is highly recommended. It ensures type-safety everywhere, and it usually a good idea to create a shared constant for keys like this, so just add a simple cast! Just make sure wherever your store the service keys, you DO NOT IMPORT THE REAL SERVICES, just their types: ```ts [proxy-service-keys.ts] import type { ProxyServiceKey } from '@webext-core/proxy-service'; import type { MathService } from './math-service'; // ^^^^ DO NOT FORGET THE type KEYWORD export const MATH_SERVICE_KEY = 'math-service' as ProxyServiceKey; ``` # Installation :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} ## Overview `@webext-core/storage` provides a type-safe, `localStorage`-like API for interacting with extension storage. ```ts const { key: value } = await browser.storage.local.get('key'); // VS const value = await localExtStorage.getItem('key'); ``` ::alert{type="warning"} Requires the `storage` permission. :: ## Installation ###### NPM ```bash pnpm i @webext-core/storage ``` ```ts import { localExtStorage } from '@webext-core/storage'; const value = await localExtStorage.getItem('key'); await localExtStorage.setItem('key', 123); ``` ###### CDN ```bash curl -o storage.js https://cdn.jsdelivr.net/npm/@webext-core/storage/dist/index.iife.js ``` ```html ``` ## Differences with `localStorage` and `browser.storage` | | `@webext-core/storage`{style="white-space: nowrap"} | `localStorage` | `browser.storage` | | ---------------------------------------- | :-------------------------------------------------: | :------------: | :---------------: | | **Set value to `undefined` removes it?** | ✅ | ✅ | ❌ | | **Returns `null` for missing values?** | ✅ | ✅ | ❌ | | **Stores non-string values?** | ✅ | ❌ | ✅ | | **Async?** | ✅ | ❌ | ✅ | Otherwise, the storage behaves the same as `localStorage` / `sessionStorage`. # Typescript ## Adding Type Safety If your project uses TypeScript, you can make your own type-safe storage by passing a schema into the first type argument of `defineExtensionStorage`. ```ts import { defineExtensionStorage } from '@webext-core/storage'; import { browser } from '@wxt-dev/browser'; export interface ExtensionStorageSchema { installDate: number; notificationsEnabled: boolean; favoriteUrls: string[]; } export const extensionStorage = defineExtensionStorage( browser.storage.local, ); ``` Then, when you use this `extensionStorage`, not the one exported from the package, you'll get type errors when using keys not in the schema: ```ts extensionStorage.getItem('unknownKey'); // ~~~~~~~~~~~~ Error: 'unknownKey' does not match `keyof LocalExtStorageSchema` const installDate: Date = await extensionStorage.getItem('installDate'); // ~~~~~~~~~~~~~~~~~ Error: value of type 'number' cannot be assigned to type 'Date' await extensionStorage.setItem('favoriteUrls', 'not-an-array'); // ~~~~~~~~~~~~~~ Error: type 'string' is not assignable to 'string[]' ``` When used correctly, types will be automatically inferred without having to specify the type anywhere: ```ts const installDate /*: number | null */ = await extensionStorage.getItem('installDate'); await extensionStorage.setItem('installDate', 123); const notificationsEnabled /*: boolean | null */ = await extensionStorage.getItem('notificationsEnabled'); const favorites /*: string[] | null */ = await extensionStorage.getItem('favoriteUrls'); favorites ??= []; favorites.push('https://github.com'); await localExtSTorage.setItem('favoriteUrls', favorites); ``` ## Handling `null` Correctly When using a schema, you'll notice that `getItem` returns `T | null`, but `setItem` requires a non-null value. By default, getting items from storage could always return `null` if a value hasn't been set. But if you type the schema as required fields, you're only be allowed to set non-null values. If you want a key to be "optional" in storage, add `null` to it's type, then you'll be able to set the value to `null`. ```diff export interface LocalExtStorageSchema { installDate: number; + notificationsEnabled: boolean; - notificationsEnabled: boolean | null; favoriteUrls: string[]; } ``` ### Never Use `undefined` Missing storage values will always be returned as `null`, never as `undefined`. So you shouldn't use `?:` or `| undefined` since that doesn't represent the actual type of your values. ```diff export interface LocalExtStorageSchema { - key1?: number; - key2: string | undefined; + key1: number | null; + key2: string | null; } ```