Skip to content

nothingrandom/typed-webextensions-api

Repository files navigation

typed-webextensions-api

Typed, Promise-based WebExtensions API wrappers for both browser (Firefox, Safari) and Chrome/Chromium engines, a strongly typed alternative to mozilla/webextension-polyfill.

  • One API, both engines. Every method returns a Promise on both engines: native promises on Firefox/Safari, wrapped callbacks on Chromium (where chrome.runtime.lastError becomes a rejection).
  • Honest types. No hand-written API shapes: every type is derived from @types/chrome and @types/firefox-webext-browser:
    • Data you receive (e.g. tabs, cookies, windows, bookmarks, downloads) is a union (browser.tabs.Tab | chrome.tabs.Tab). You can use every field the two engines have in common, and must narrow for engine-specific fields.
    • Data you send (e.g. query filters, create properties, injection targets) is an intersection (chrome.tabs.QueryInfo & browser.tabs._QueryQueryInfo). If it compiles, it is valid on both engines.
  • Manifest V3 first. Covers the cross-browser MV3 API surface (action, scripting, service-worker-safe messaging helpers).
  • Context-aware. Tell createWebExtensionsApi where it runs (background, popup, options, content-script) and the type system only offers the APIs that exist there, with descriptive runtime errors as a backstop.
  • Safe to import anywhere. Namespace wrappers are built lazily on first use, so importing the package never touches an engine namespace you lack the permission (or context) for.
  • Zero runtime dependencies. ESM-only (extensions run in browsers, bundled or as native modules), full .d.ts declarations, tree-shakeable per-namespace modules.

Installation

npm install typed-webextensions-api
# or
bun add typed-webextensions-api

The package ships plain JavaScript plus .d.ts declarations. There is no runtime dependency on TypeScript. For TypeScript consumers (≥ 5.7), the declarations reference the global browser and chrome namespaces, so install the two @types packages (declared as optional peer dependencies) and add them to your tsconfig types:

npm install -D @types/chrome @types/firefox-webext-browser
// tsconfig.json
{
  "compilerOptions": {
    "types": ["chrome", "firefox-webext-browser"]
  }
}

JavaScript-only consumers can skip both; package managers never install optional peers on their own.

Usage

Auto-detect the engine (recommended)

import { createWebExtensionsApi } from 'typed-webextensions-api';

const api = createWebExtensionsApi(); // browser.* on Firefox/Safari, chrome.* on Chromium

const [activeTab] = await api.tabs.query({ active: true, currentWindow: true });
await api.storage.local.set({ lastTabUrl: activeTab?.url });

Declare your context

Pass the context the code runs in and the returned type matches what that context can actually do. Content scripts only get runtime (messaging subset), i18n, and storage. Everything else becomes a compile-time error, and reaching it anyway (say, from untyped code) throws a descriptive runtime error instead of the engine's cryptic undefined TypeError:

// content-script.ts
const api = createWebExtensionsApi({ context: 'content-script' });

await api.storage.local.get('settings');            // works
await api.runtime.sendMessage({ type: 'ping' });    // works
api.tabs.query({ active: true });
// TS error: Property 'tabs' does not exist on type 'ContentScriptApi'
// Runtime error: "typed-webextensions-api: 'tabs' is not available in content scripts.
//    Use runtime.sendMessage()/connect() to ask a background or extension-page
//    context instead."

'background', 'popup', and 'options' return the full API. Declaring them documents intent and future-proofs your code:

// background.ts
const api = createWebExtensionsApi({ context: 'background' }); // full WebExtensionsApi

You can combine context with an explicit engine: createWebExtensionsApi({ engine: 'chrome', context: 'content-script' }).

Import a specific engine

If you ship separate builds per store, import the engine directly: the other engine's code never ends up in your bundle.

import { tabs, storage } from 'typed-webextensions-api/browser'; // Firefox/Safari
// or
import { tabs, storage } from 'typed-webextensions-api/chrome'; // Chromium

Messaging with Promise-returning listeners

runtime.onMessage accepts a Promise-returning listener on both engines. Firefox supports this natively; on Chrome the wrapper keeps the message channel open and forwards the resolved value to sendResponse for you:

// background script
api.runtime.onMessage.addListener(async (message) => {
  if ((message as { type: string }).type === 'fetch-user') {
    return await lookupUser(); // resolved value is the response on both engines
  }
  return undefined;
});

// content script or popup
const user = await api.runtime.sendMessage({ type: 'fetch-user' });

Events

Every event exposes the familiar addListener / removeListener / hasListener trio, fully typed:

api.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status === 'complete') console.log(`${tabId} finished loading`);
});

The webNavigation events also accept the optional URL-filter argument:

api.webNavigation.onCompleted.addListener(
  (details) => console.log('loaded', details.url),
  { url: [{ hostContains: 'example' }] },
);

Scripting (MV3)

const [{ result }] = await api.scripting.executeScript({
  target: { tabId: activeTab.id! },
  func: () => document.title,
});

Supported APIs

All namespaces below are implemented for both engines, with the same Promise-based surface:

action · alarms · bookmarks · browsingData · commands · contextMenus · cookies · downloads · history · i18n · identity · idle · management · notifications · omnibox · permissions · runtime · scripting · search · sessions · storage · tabs · topSites · webNavigation · windows

Every method carries JSDoc with a link to its MDN page, the permissions it requires, and browser-compatibility notes. Hover any method in your editor to see them.

Out of scope (and why)

API Reason
webRequest The engines diverge in MV3: Chrome allows observation only (blocking moved to declarativeNetRequest), Firefox still supports blocking listeners. Wrap the native API for the engine you target.
declarativeNetRequest Chrome-centric rule engine; Firefox support differs in rule limits and features. Mostly driven by static manifest rules.
devtools.* Only usable inside a devtools page; niche context with an incompatible entry point.
privacy, proxy, browserSettings, types Built on incompatible settings primitives (ChromeSetting vs BrowserSetting), and proxy has entirely different models (Firefox onRequest vs Chrome PAC-script settings).
identity (Google account helpers) getAuthToken, getProfileUserInfo, getAccounts are Chrome-only. The cross-browser subset (launchWebAuthFlow, getRedirectURL) is included.
userScripts The MV3 userScripts APIs differ substantially (worlds/configuration) between engines.
tabGroups Very new in Firefox (138+); revisit once stable.
Firefox-only APIs (contextualIdentities, dns, find, pkcs11, sidebarAction, theme, captivePortal, clipboard, dom, publicSuffix) No Chromium counterpart to unify with. Use browser.* directly.
MV2-legacy (browserAction, pageAction, contentScripts, extension) Superseded in MV3 by action, scripting, and runtime.

Differences between the browser and chrome implementations

The wrappers smooth over engine differences wherever possible; the remainder is typed and documented explicitly.

Handled for you

  • Missing namespaces never crash your import. Engines only expose a namespace when its permission is granted (and content scripts only get a small subset). All wrappers bind lazily on first use, so importing the package is always safe; only the namespaces you call need to exist.
  • Promises vs callbacks. Firefox/Safari return Promises natively. The Chrome implementation wraps the callback API and converts chrome.runtime.lastError into a rejected Error, so try/catch works identically on both engines.
  • Async message responses. Chrome requires return true + sendResponse; Firefox accepts a returned Promise. The Chrome wrapper bridges Promise-returning listeners automatically (and keeps removeListener working via an internal wrapper cache).
  • Optional-argument overloads. Firefox often lacks (undefined, value) overloads (tabs.update, tabs.setZoom, alarms.create, tabs.reload, tabs.captureVisibleTab, runtime.connect). The wrappers drop omitted arguments so the right native overload is hit.
  • tabs.discard with an array. Chrome's native API takes a single tab ID; the wrapper discards arrays sequentially. Firefox takes the array natively.
  • action.isEnabled. Chrome takes a bare tabId; Firefox takes a { tabId } details object. The wrapper exposes the Chrome-style signature on both.
  • omnibox.setDefaultSuggestion / downloads.show. Synchronous on one engine, async on the other; normalised to one signature (Promise<void> / void respectively).

Typed differences to be aware of

  • Union data types. Fields present on only one engine (e.g. Firefox's Tab.cookieStoreId, Chrome's Tab.groupId) require narrowing before use. That is the union type doing its job.
  • runtime.onInstalled reports reason: "browser_update" on Firefox and "chrome_update" on Chrome.
  • notifications: buttons, imageUrl, items, and progress options are Chrome-only; Firefox shows only "basic" notifications and never fires onButtonClicked. getAll() resolves options objects on Firefox but true values on Chrome.
  • management.onUninstalled passes the full ExtensionInfo on Firefox but only the extension ID string on Chrome, so the listener argument is typed as ExtensionInfo | string.
  • cookies.remove resolves null on failure on Firefox; Chrome rejects instead.
  • tabs.duplicate / windows.create may resolve undefined on Chrome when details are unavailable; Firefox always resolves the object.
  • identity.getRedirectURL() returns a different origin per engine (*.chromiumapp.org vs *.extensions.allizom.org), so register both with your OAuth provider.
  • storage.session requires Chrome 102+ / Firefox 115+; storage.*.getKeys requires Chrome 130+ / Firefox 131+.
  • tabs.group / tabs.ungroup require Chrome 88+ / Firefox 138+.
  • action.openPopup requires Chrome 127+ / Firefox 106+ and may need a user gesture.

Versioning

This package uses independent semantic versioning: a major bump means this wrapper's API changed, not the browsers'. There is no versioned "WebExtensions spec" to mirror; the API is a living standard that each browser ships incrementally. Browser compatibility therefore lives where it can be precise:

  • per-method JSDoc notes the minimum Chrome/Firefox versions where they differ (e.g. tabs.group: Chrome 88+, Firefox 138+);
  • the baseline is Manifest V3 (Chrome 88+, Firefox 109+, Safari 15.4+);
  • new upstream APIs arrive as minor releases; type updates tracking new @types/chrome / @types/firefox-webext-browser releases are patch releases unless they change this package's surface.

Development

bun install
bun run lint    # tsc --noEmit + biome
bun test        # runs every wrapper against both engine mocks
bun run build   # ESM + CJS + .d.ts into dist/

License

MIT

About

Typed, Promise-based WebExtensions API wrappers for browser (Firefox, Safari) and Chrome/Chromium engines",

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages