├── packages ├── messaging-demo │ ├── src │ │ ├── public │ │ │ └── .keep │ │ ├── utils │ │ │ ├── google-messaging.ts │ │ │ ├── duckduckgo-messaging.ts │ │ │ └── messaging.ts │ │ └── entrypoints │ │ │ ├── background.ts │ │ │ ├── google-injected.ts │ │ │ ├── duckduckgo-injected.ts │ │ │ ├── google.content.ts │ │ │ ├── duckduckgo.content.ts │ │ │ └── popup │ │ │ ├── index.html │ │ │ └── main.ts │ ├── tsconfig.json │ ├── package.json │ ├── wxt.config.ts │ └── README.md ├── proxy-service-demo │ ├── src │ │ ├── public │ │ │ └── .keep │ │ ├── entrypoints │ │ │ ├── background.ts │ │ │ └── popup │ │ │ │ ├── index.html │ │ │ │ └── main.ts │ │ └── utils │ │ │ └── math-service.ts │ ├── tsconfig.json │ ├── wxt.config.ts │ ├── package.json │ └── README.md ├── isolated-element-demo │ ├── isolated-style.css │ ├── global-style.css │ ├── index.html │ ├── .gitignore │ ├── package.json │ └── main.js ├── messaging │ ├── src │ │ ├── index.ts │ │ ├── page.ts │ │ ├── __mocks__ │ │ │ └── webextension-polyfill.ts │ │ ├── utils.ts │ │ ├── extension.ts │ │ ├── types.ts │ │ ├── extension.test-d.ts │ │ └── window.ts │ ├── tsconfig.json │ ├── vitest.config.node.ts │ ├── vitest.config.browser.ts │ ├── README.md │ └── package.json ├── storage │ ├── tsconfig.json │ ├── src │ │ ├── __mocks__ │ │ │ └── webextension-polyfill.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── defineExtensionStorage.ts │ ├── README.md │ └── package.json ├── tsconfig │ ├── README.md │ ├── package.json │ ├── ts-library.json │ └── base.json ├── job-scheduler │ ├── src │ │ └── __mocks__ │ │ │ └── webextension-polyfill.ts │ ├── tsconfig.json │ ├── README.md │ └── package.json ├── proxy-service │ ├── src │ │ ├── __mocks__ │ │ │ └── webextension-polyfill.ts │ │ ├── index.ts │ │ ├── isBackground.ts │ │ ├── types.ts │ │ ├── flattenPromise.test.ts │ │ ├── flattenPromise.ts │ │ ├── defineProxyService.test.ts │ │ ├── defineProxyService.ts │ │ └── types.test-d.ts │ ├── tsconfig.json │ ├── README.md │ └── package.json ├── fake-browser │ ├── tsconfig.json │ ├── scripts │ │ ├── code-writer.ts │ │ └── generate-base.ts │ ├── README.md │ ├── src │ │ ├── apis │ │ │ ├── webNavigation.ts │ │ │ ├── alarms.ts │ │ │ ├── tabs.test.ts │ │ │ ├── webNavigation.test.ts │ │ │ ├── notifications.ts │ │ │ ├── runtime.ts │ │ │ ├── windows.test.ts │ │ │ ├── alarms.test.ts │ │ │ ├── notifications.test.ts │ │ │ ├── windows.ts │ │ │ ├── runtime.test.ts │ │ │ └── storage.ts │ │ ├── index.ts │ │ ├── utils │ │ │ └── defineEventWithTrigger.ts │ │ └── types.ts │ └── package.json ├── match-patterns │ ├── tsconfig.json │ ├── README.md │ ├── package.json │ └── src │ │ └── index.test.ts └── isolated-element │ ├── tsconfig.json │ ├── README.md │ ├── src │ ├── options.ts │ ├── index.ts │ └── index.test.ts │ └── package.json ├── vercel.json ├── docs ├── public │ ├── favicon.ico │ └── logo-with-shadow.png ├── tsconfig.json ├── nuxt.config.ts ├── .gitignore ├── package.json ├── app.config.ts ├── content │ ├── fake-browser │ │ ├── api.md │ │ ├── 0.installation.md │ │ ├── 3.reseting-state.md │ │ ├── 2.triggering-events.md │ │ ├── 1.testing-frameworks.md │ │ └── 4.implemented-apis.md │ ├── match-patterns │ │ ├── api.md │ │ └── 0.installation.md │ ├── 0.get-started │ │ ├── 1.browser-support.md │ │ └── 0.introduction.md │ ├── messaging │ │ └── 1.protocol-maps.md │ ├── storage │ │ ├── 0.installation.md │ │ ├── 1.typescript.md │ │ └── api.md │ ├── isolated-element │ │ ├── 0.installation.md │ │ └── api.md │ ├── index.md │ ├── proxy-service │ │ ├── api.md │ │ └── 1.defining-services.md │ └── job-scheduler │ │ ├── api.md │ │ └── 0.installation.md ├── plugins │ └── redirects.ts └── _redirects.txt ├── .gitattributes ├── patches ├── simple-git-hooks@2.13.1.md └── simple-git-hooks@2.13.1.patch ├── .prettierrc.yml ├── .prettierignore ├── .gitignore ├── .github ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── validate.yml │ └── publish-packages.yml ├── LICENSE ├── package.json └── README.md /packages/messaging-demo/src/public/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/proxy-service-demo/src/public/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/isolated-element-demo/isolated-style.css: -------------------------------------------------------------------------------- 1 | a { 2 | font-size: large; 3 | } 4 | -------------------------------------------------------------------------------- /packages/messaging-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/messaging/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './extension'; 3 | -------------------------------------------------------------------------------- /packages/proxy-service-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/messaging/src/page.ts: -------------------------------------------------------------------------------- 1 | export * from './window'; 2 | export * from './custom-event'; 3 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aklinker1/webext-core/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /packages/storage/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/ts-library.json", 3 | "exclude": ["lib"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/public/logo-with-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aklinker1/webext-core/HEAD/docs/public/logo-with-shadow.png -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /packages/storage/src/__mocks__/webextension-polyfill.ts: -------------------------------------------------------------------------------- 1 | export { fakeBrowser as default } from '@webext-core/fake-browser'; 2 | -------------------------------------------------------------------------------- /packages/tsconfig/README.md: -------------------------------------------------------------------------------- 1 | # tsconfig 2 | 3 | Base TypeScript configuration for different types of packages in the repo. 4 | -------------------------------------------------------------------------------- /docs/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | llms: { 3 | domain: 'webext-core.aklinker1.io', 4 | }, 5 | }); 6 | -------------------------------------------------------------------------------- /packages/job-scheduler/src/__mocks__/webextension-polyfill.ts: -------------------------------------------------------------------------------- 1 | export { fakeBrowser as default } from '@webext-core/fake-browser'; 2 | -------------------------------------------------------------------------------- /packages/messaging/src/__mocks__/webextension-polyfill.ts: -------------------------------------------------------------------------------- 1 | export { fakeBrowser as default } from '@webext-core/fake-browser'; 2 | -------------------------------------------------------------------------------- /packages/proxy-service/src/__mocks__/webextension-polyfill.ts: -------------------------------------------------------------------------------- 1 | export { fakeBrowser as default } from '@webext-core/fake-browser'; 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Generated code 2 | *.gen.ts linguist-generated 3 | *.gen.js linguist-generated 4 | pnpm-lock.yaml linguist-generated 5 | -------------------------------------------------------------------------------- /packages/proxy-service-demo/src/entrypoints/background.ts: -------------------------------------------------------------------------------- 1 | export default defineBackground(() => { 2 | registerMathService(); 3 | }); 4 | -------------------------------------------------------------------------------- /packages/proxy-service-demo/wxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'wxt'; 2 | 3 | export default defineConfig({ 4 | srcDir: 'src', 5 | }); 6 | -------------------------------------------------------------------------------- /packages/isolated-element-demo/global-style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | } 6 | 7 | a { 8 | color: red; 9 | } 10 | -------------------------------------------------------------------------------- /patches/simple-git-hooks@2.13.1.md: -------------------------------------------------------------------------------- 1 | See: 2 | 3 | - https://github.com/oven-sh/bun/issues/21754 4 | - https://github.com/toplenboren/simple-git-hooks/pull/136 5 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: all 3 | endOfLine: lf 4 | printWidth: 100 5 | tabWidth: 2 6 | vueIndentScriptAndStyle: false 7 | arrowParens: avoid 8 | -------------------------------------------------------------------------------- /packages/messaging/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/ts-library.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM"] 5 | }, 6 | "exclude": ["lib"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/fake-browser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/ts-library.json", 3 | "compilerOptions": { 4 | "lib": ["DOM", "ESNext"] 5 | }, 6 | "exclude": ["lib"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/job-scheduler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/ts-library.json", 3 | "compilerOptions": { 4 | "lib": ["DOM", "ESNext"] 5 | }, 6 | "exclude": ["lib"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/match-patterns/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/ts-library.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM"] 5 | }, 6 | "exclude": ["lib"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/proxy-service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/ts-library.json", 3 | "compilerOptions": { 4 | "lib": ["DOM", "ESNext"] 5 | }, 6 | "exclude": ["lib"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/isolated-element/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/ts-library.json", 3 | "compilerOptions": { 4 | "lib": ["DOM", "ESNext"] 5 | }, 6 | "exclude": ["lib"] 7 | } 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | pnpm-lock.yaml 5 | /docs/.vitepress/cache 6 | /docs/api/* 7 | .nuxt 8 | .wxt.output 9 | docs/content/index.md 10 | docs/content/*/api.md 11 | -------------------------------------------------------------------------------- /packages/proxy-service/src/index.ts: -------------------------------------------------------------------------------- 1 | export { defineProxyService } from './defineProxyService'; 2 | export { flattenPromise } from './flattenPromise'; 3 | export type { ProxyServiceConfig, ProxyService, DeepAsync } from './types'; 4 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "scripts": { 7 | "check": "echo 'noop'", 8 | "build": "echo 'noop'" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | lib 4 | *.log 5 | coverage 6 | .env 7 | .env.* 8 | .turbo 9 | .web-extrc.yml 10 | /docs/.vitepress/cache 11 | .DS_Store 12 | tsconfig.vitest-temp.json 13 | /.cache 14 | .output 15 | .wxt 16 | .idea 17 | -------------------------------------------------------------------------------- /packages/tsconfig/ts-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "TypeScript Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "lib": ["ESNext"], 7 | "module": "ESNext", 8 | "target": "es6" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/job-scheduler/README.md: -------------------------------------------------------------------------------- 1 | # `@webext-core/job-scheduler` 2 | 3 | Simple job scheduler for web extension background scripts. 4 | 5 | ```bash 6 | pnpm i @webext-core/job-scheduler 7 | ``` 8 | 9 | See [documentation](https://webext-core.aklinker1.io/guide/job-scheduler/) to get started! 10 | -------------------------------------------------------------------------------- /packages/fake-browser/scripts/code-writer.ts: -------------------------------------------------------------------------------- 1 | import { CodeBlockWriter } from 'ts-morph'; 2 | 3 | export function writeJsdoc(w: CodeBlockWriter, comment: string): CodeBlockWriter { 4 | const lines = ['/**', ...comment.split('\n').map(line => ` * ${line}`), ' */']; 5 | w.writeLine(lines.join('\n')); 6 | return w; 7 | } 8 | -------------------------------------------------------------------------------- /packages/fake-browser/README.md: -------------------------------------------------------------------------------- 1 | # `@webext-core/fake-browser` 2 | 3 | An in-memory implementation of `webextension-polyfill` for testing. 4 | 5 | ```bash 6 | pnpm i -D @webext-core/fake-browser 7 | ``` 8 | 9 | ## Get Started 10 | 11 | See [documentation](https://webext-core.aklinker1.io/guide/fake-browser/) to get started! 12 | -------------------------------------------------------------------------------- /packages/proxy-service/README.md: -------------------------------------------------------------------------------- 1 | # `@webext-core/proxy-service` 2 | 3 | A type-safe wrapper around the web extension messaging APIs that lets you call a function from anywhere, but execute it in the background. Supports all browsers (Chrome, Firefox, Safari, etc). 4 | 5 | ## Get Started 6 | 7 | See [documentation](https://webext-core.aklinker1.io/guide/proxy-service/) to get started! 8 | -------------------------------------------------------------------------------- /packages/isolated-element-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/isolated-element-demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Basic Setup 2 | description: Install Bun and other dependencies 3 | runs: 4 | using: composite 5 | steps: 6 | - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 7 | with: 8 | bun-version-file: package.json 9 | no-cache: true 10 | - name: Install Dependencies 11 | shell: bash 12 | run: bun install --frozen-lockfile 13 | -------------------------------------------------------------------------------- /packages/messaging-demo/src/utils/google-messaging.ts: -------------------------------------------------------------------------------- 1 | import { defineWindowMessaging } from '@webext-core/messaging/page'; 2 | 3 | export interface GoogleMessagingProtocol { 4 | ping(): string; 5 | ping2(): string; 6 | fromInjected(): string; 7 | fromInjected2(): string; 8 | } 9 | 10 | export const googleMessaging = defineWindowMessaging({ 11 | namespace: '@webext-core/messaging-demo/google', 12 | }); 13 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | .eslintcache 21 | 22 | # Local env files 23 | .env 24 | .env.* 25 | !.env.example 26 | 27 | # npm pack 28 | *.tgz 29 | 30 | # Temp files 31 | .tmp 32 | .profile 33 | *.0x 34 | 35 | #VSC 36 | .history 37 | .wrangler 38 | -------------------------------------------------------------------------------- /packages/messaging-demo/src/utils/duckduckgo-messaging.ts: -------------------------------------------------------------------------------- 1 | import { defineCustomEventMessaging } from '@webext-core/messaging/page'; 2 | 3 | export interface DuckduckgoMessagingProtocol { 4 | ping(): string; 5 | ping2(): string; 6 | fromInjected(): string; 7 | fromInjected2(): string; 8 | } 9 | 10 | export const duckduckgoMessaging = defineCustomEventMessaging({ 11 | namespace: '@webext-core/messaging-demo/duckduckgo', 12 | }); 13 | -------------------------------------------------------------------------------- /packages/messaging-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "messaging-demo", 3 | "type": "module", 4 | "private": true, 5 | "scripts": { 6 | "dev": "buildc --deps-only -- wxt", 7 | "build": "buildc -- wxt build", 8 | "check": "buildc --deps-only -- tsc --noEmit" 9 | }, 10 | "dependencies": { 11 | "@webext-core/messaging": "workspace:*" 12 | }, 13 | "devDependencies": { 14 | "wxt": "^0.19.5" 15 | }, 16 | "buildc": { 17 | "cachable": false 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/isolated-element-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isolated-element-demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "buildc --deps-only -- vite", 8 | "build": "buildc -- vite build", 9 | "check": "echo 'noop'" 10 | }, 11 | "dependencies": { 12 | "@webext-core/isolated-element": "workspace:*" 13 | }, 14 | "devDependencies": { 15 | "vite": "^4.0.0" 16 | }, 17 | "buildc": { 18 | "cachable": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/storage/README.md: -------------------------------------------------------------------------------- 1 | # `@webext-core/storage` 2 | 3 | A type-safe, localStorage-esk wrapper around the web extension storage APIs. Supports all browsers (Chrome, Firefox, Safari, etc). 4 | 5 | ```bash 6 | pnpm i @webext-core/storage 7 | ``` 8 | 9 | ```ts 10 | import { localExtStorage } from '@webext-core/storage'; 11 | 12 | const value = await localExtStorage.getItem('some-key'); 13 | ``` 14 | 15 | ## Get Started 16 | 17 | See [documentation](https://webext-core.aklinker1.io/guide/storage/) to get started! 18 | -------------------------------------------------------------------------------- /packages/messaging/vitest.config.node.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, defaultInclude, defaultExclude, UserConfig } from 'vitest/config'; 2 | 3 | const config = { 4 | node: { 5 | test: { 6 | name: 'node', 7 | include: [...defaultInclude], 8 | exclude: [ 9 | ...defaultExclude, 10 | '**/*.browser.{test,spec}.ts', 11 | '**/__tests__/browser/**/*.{test,spec}.ts', 12 | ], 13 | }, 14 | } as const satisfies UserConfig, 15 | }; 16 | 17 | export default defineConfig(config.node); 18 | -------------------------------------------------------------------------------- /packages/proxy-service-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proxy-service-demo", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "private": true, 6 | "scripts": { 7 | "dev": "buildc --deps-only -- wxt", 8 | "build": "buildc -- wxt build", 9 | "check": "buildc --deps-only -- tsc --noEmit" 10 | }, 11 | "dependencies": { 12 | "@webext-core/proxy-service": "workspace:*" 13 | }, 14 | "devDependencies": { 15 | "wxt": "^0.19.5" 16 | }, 17 | "buildc": { 18 | "cachable": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/messaging-demo/wxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'wxt'; 2 | 3 | export default defineConfig({ 4 | srcDir: 'src', 5 | manifest: { 6 | web_accessible_resources: [ 7 | { 8 | resources: ['google-injected.js'], 9 | matches: ['*://*.google.com/*'], 10 | }, 11 | { 12 | resources: ['duckduckgo-injected.js'], 13 | matches: ['*://*.duckduckgo.com/*'], 14 | }, 15 | ], 16 | }, 17 | runner: { startUrls: ['https://google.com/', 'https://duckduckgo.com/'] }, 18 | }); 19 | -------------------------------------------------------------------------------- /packages/messaging-demo/src/entrypoints/background.ts: -------------------------------------------------------------------------------- 1 | export default defineBackground(() => { 2 | const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); 3 | 4 | onMessage1('sleep', async ({ data }) => new Promise(res => setTimeout(res, data))); 5 | onMessage1('ping', async () => { 6 | await sleep(1000); 7 | return 'pong' as const; 8 | }); 9 | onMessage2('ping2', async ({ data }) => { 10 | await sleep(1000); 11 | return data; 12 | }); 13 | onMessage2('throw', () => { 14 | throw Error('Example error'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "noEmit": true, 16 | "allowSyntheticDefaultImports": true 17 | }, 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/messaging/src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @description In firefox, when dispatching events externally from web-extension, it's necessary to clone all properties of the dictionary. ref: https://github.com/aklinker1/webext-core/pull/70#discussion_r1775031410 3 | */ 4 | export function prepareCustomEventDict( 5 | data: T, 6 | options: { targetScope?: object } = { targetScope: window ?? undefined }, 7 | ): T { 8 | // @ts-expect-error not exist cloneInto types because implemented only in Firefox. 9 | return typeof cloneInto !== 'undefined' ? cloneInto(data, options.targetScope) : data; 10 | } 11 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "bun gen && nuxt dev --extends docus", 7 | "build": "bun gen && nuxt build --extends docus", 8 | "gen": "bun generate-api-references.ts", 9 | "postinstall": "nuxt prepare" 10 | }, 11 | "dependencies": { 12 | "better-sqlite3": "^12.2.0", 13 | "docus": "latest", 14 | "nuxt": "^4.1.2" 15 | }, 16 | "devDependencies": { 17 | "listr2": "^9.0.5", 18 | "prettier": "^3.6.2", 19 | "ts-morph": "^23.0.0" 20 | }, 21 | "buildc": { 22 | "cachable": false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/messaging-demo/README.md: -------------------------------------------------------------------------------- 1 | # `@webext-core/messaging` Demo Extension 2 | 3 | To startup the demo extension: 4 | 5 | ```bash 6 | cd webext-core 7 | pnpm i 8 | pnpm build 9 | cd packages/messaging-demo 10 | pnpm dev 11 | ``` 12 | 13 | This will build the extension in watch mode, and open a browser. 14 | 15 | > If a browser fails to open, create a file called `.web-extrc.yml` with the following contents: 16 | > 17 | > ```yml 18 | > chromiumBinary: /path/to/your/chrome 19 | > ``` 20 | > 21 | > See [`web-ext` docs](https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#chromium-binary) for more details. 22 | -------------------------------------------------------------------------------- /packages/proxy-service-demo/README.md: -------------------------------------------------------------------------------- 1 | # `@webext-core/messaging` Demo Extension 2 | 3 | To startup the demo extension: 4 | 5 | ```bash 6 | cd webext-core 7 | pnpm i 8 | pnpm build 9 | cd packages/messaging-demo 10 | pnpm dev 11 | ``` 12 | 13 | This will build the extension in watch mode, and open a browser. 14 | 15 | > If a browser fails to open, create a file called `.web-extrc.yml` with the following contents: 16 | > 17 | > ```yml 18 | > chromiumBinary: /path/to/your/chrome 19 | > ``` 20 | > 21 | > See [`web-ext` docs](https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#chromium-binary) for more details. 22 | -------------------------------------------------------------------------------- /packages/messaging/vitest.config.browser.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, defaultExclude, UserConfig } from 'vitest/config'; 2 | 3 | const config = { 4 | browser: { 5 | test: { 6 | name: 'browser mode', 7 | include: ['**/*.browser.{test,spec}.ts', '**/__tests__/browser/**/*.{test,spec}.ts'], 8 | exclude: [...defaultExclude], 9 | browser: { 10 | provider: 'playwright', 11 | enabled: true, 12 | name: 'chromium', 13 | headless: true, 14 | isolate: true, 15 | screenshotFailures: false, 16 | }, 17 | }, 18 | } as const satisfies UserConfig, 19 | }; 20 | 21 | export default defineConfig(config.browser); 22 | -------------------------------------------------------------------------------- /packages/messaging-demo/src/utils/messaging.ts: -------------------------------------------------------------------------------- 1 | import { defineExtensionMessaging } from '@webext-core/messaging'; 2 | 3 | interface MessageProtocol1 { 4 | ping: () => 'pong'; 5 | sleep: number; 6 | } 7 | 8 | export const { sendMessage: sendMessage1, onMessage: onMessage1 } = 9 | defineExtensionMessaging({ logger: console }); 10 | 11 | // Define another protocol to make sure the library supports multiple 12 | 13 | interface MessageProtocol2 { 14 | ping2: (arg: string) => string; 15 | throw: undefined; 16 | } 17 | 18 | export const { sendMessage: sendMessage2, onMessage: onMessage2 } = 19 | defineExtensionMessaging({ logger: console }); 20 | -------------------------------------------------------------------------------- /docs/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | seo: { 3 | title: 'Web Ext Core', 4 | description: 5 | 'Web extension development made easy. A collection of easy-to-use utilities for writing and testing web extensions that work on all browsers.', 6 | }, 7 | github: { 8 | url: 'https://github.com/aklinker1/webext-core', 9 | rootDir: 'docs', 10 | }, 11 | socials: { 12 | github: 'https://github.com/aklinker1/webext-core', 13 | }, 14 | header: { 15 | title: 'WebExt Core', 16 | logo: { light: '/logo-with-shadow.png', dark: '/logo-with-shadow.png' }, 17 | }, 18 | ui: { 19 | colors: { 20 | primary: 'cyan', 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/webNavigation.ts: -------------------------------------------------------------------------------- 1 | import { BrowserOverrides } from '../types'; 2 | import { defineEventWithTrigger } from '../utils/defineEventWithTrigger'; 3 | 4 | export const webNavigation: BrowserOverrides['webNavigation'] = { 5 | onBeforeNavigate: defineEventWithTrigger(), 6 | onCommitted: defineEventWithTrigger(), 7 | onCompleted: defineEventWithTrigger(), 8 | onCreatedNavigationTarget: defineEventWithTrigger(), 9 | onDOMContentLoaded: defineEventWithTrigger(), 10 | onErrorOccurred: defineEventWithTrigger(), 11 | onHistoryStateUpdated: defineEventWithTrigger(), 12 | onReferenceFragmentUpdated: defineEventWithTrigger(), 13 | onTabReplaced: defineEventWithTrigger(), 14 | }; 15 | -------------------------------------------------------------------------------- /patches/simple-git-hooks@2.13.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/simple-git-hooks.js b/simple-git-hooks.js 2 | index 093890d21800c9feb34cb6da5b997b947da812cd..3b11e62bbfcbdf7d2dc8f553237750a220ecae6f 100644 3 | --- a/simple-git-hooks.js 4 | +++ b/simple-git-hooks.js 5 | @@ -100,6 +100,10 @@ function getProjectRootDirectoryFromNodeModules(projectPath) { 6 | if (indexOfDenoDir > -1) { 7 | return projDir.slice(0, indexOfDenoDir - 1).join('/'); 8 | } 9 | + const indexOfBunDir = projDir.indexOf('.bun') 10 | + if (indexOfBunDir > -1) { 11 | + return projDir.slice(0, indexOfBunDir - 1).join('/'); 12 | + } 13 | 14 | const indexOfStoreDir = projDir.indexOf('.store') 15 | if (indexOfStoreDir > -1) { 16 | -------------------------------------------------------------------------------- /packages/messaging-demo/src/entrypoints/google-injected.ts: -------------------------------------------------------------------------------- 1 | export default defineUnlistedScript(async () => { 2 | console.log('[google-injected.ts] Injected script loaded'); 3 | 4 | googleMessaging.onMessage('ping', event => { 5 | console.log('[google-injected.ts] Received', event); 6 | return 'pong'; 7 | }); 8 | 9 | googleMessaging.onMessage('ping2', event => { 10 | console.log('[google-injected.ts] Received2', event); 11 | return 'pong2'; 12 | }); 13 | 14 | googleMessaging.sendMessage('fromInjected', undefined).then(res => { 15 | console.log('[google-injected.ts] Response:', res); 16 | }); 17 | 18 | const res2 = await googleMessaging.sendMessage('fromInjected2', undefined); 19 | console.log('[google-injected.ts] Response2:', res2); 20 | }); 21 | -------------------------------------------------------------------------------- /docs/content/fake-browser/api.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | --- 4 | description: "" 5 | --- 6 | 7 | ::alert 8 | 9 | See [`@webext-core/fake-browser`](/fake-browser/installation/) 10 | 11 | :: 12 | 13 | ## `FakeBrowser` 14 | 15 | ```ts 16 | type FakeBrowser = BrowserOverrides & Browser; 17 | ``` 18 | 19 | The standard `Browser` interface from `webextension-polyfill`, but with additional functions for triggering events and reseting state. 20 | 21 | ## `fakeBrowser` 22 | 23 | ```ts 24 | const fakeBrowser: FakeBrowser; 25 | ``` 26 | 27 | An in-memory implementation of the `browser` global. 28 | 29 |

30 | 31 | --- 32 | 33 | _API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts)_ -------------------------------------------------------------------------------- /packages/messaging-demo/src/entrypoints/duckduckgo-injected.ts: -------------------------------------------------------------------------------- 1 | export default defineUnlistedScript(async () => { 2 | console.log('[duckduckgo-injected.ts] Injected script loaded'); 3 | 4 | duckduckgoMessaging.onMessage('ping', event => { 5 | console.log('[duckduckgo-injected.ts] Received', event); 6 | return 'pong'; 7 | }); 8 | 9 | duckduckgoMessaging.onMessage('ping2', event => { 10 | console.log('[duckduckgo-injected.ts] Received2', event); 11 | return 'pong2'; 12 | }); 13 | 14 | duckduckgoMessaging.sendMessage('fromInjected', undefined).then(res => { 15 | console.log('[duckduckgo-injected.ts] Response:', res); 16 | }); 17 | 18 | const res2 = await duckduckgoMessaging.sendMessage('fromInjected2', undefined); 19 | console.log('[duckduckgo-injected.ts] Response2:', res2); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/isolated-element-demo/main.js: -------------------------------------------------------------------------------- 1 | import { createIsolatedElement } from '@webext-core/isolated-element'; 2 | import isolatedStyles from './isolated-style.css?raw'; 3 | 4 | const createLink = () => { 5 | const a = document.createElement('a'); 6 | a.href = 'https://github.com/aklinker1/webext-core'; 7 | a.textContent = 'Link'; 8 | return a; 9 | }; 10 | 11 | // Create a link that uses the global red color style 12 | document.body.appendChild(createLink()); 13 | 14 | // Create an isolated link that doesn't use the red color style 15 | createIsolatedElement({ 16 | name: 'isolated-style', 17 | css: { textContent: isolatedStyles }, 18 | }).then(({ isolatedElement, parentElement }) => { 19 | // Add link inside isolation 20 | isolatedElement.appendChild(createLink()); 21 | // Add isolated element to dom 22 | document.body.appendChild(parentElement); 23 | }); 24 | -------------------------------------------------------------------------------- /docs/content/fake-browser/0.installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | toc: true 3 | description: '' 4 | --- 5 | 6 | :badge[Vitest]{type="success"} :badge[Jest]{type="success"} :badge[Bun]{type="success"} :badge[Mocha]{type="success"} 7 | 8 | ## Overview 9 | 10 | An in-memory implementation of [`webextension-polyfill`](https://www.npmjs.com/package/webextension-polyfill) for testing. Supports all test frameworks (Vitest, Jest, etc). 11 | 12 | ```bash 13 | pnpm i -D @webext-core/fake-browser 14 | ``` 15 | 16 | ::alrt{type=warning} 17 | This package only really works with projects using node, so only the NPM install steps are shown. 18 | :: 19 | 20 | See [Testing Frameworks](/fake-browser/testing-frameworks) to setup mocks for your testing framework of choice. 21 | 22 | ## Examples 23 | 24 | See [Implemented APIs](/fake-browser/implemented-apis) for example tests and details on how to use each API. 25 | -------------------------------------------------------------------------------- /docs/plugins/redirects.ts: -------------------------------------------------------------------------------- 1 | import redirects from '../_redirects.txt?raw'; 2 | 3 | const parsedRedirects = redirects 4 | .split('\n') 5 | .map(line => line.trim()) 6 | .filter(line => !!line && !line.startsWith('#')) 7 | .map(line => { 8 | const [from, to] = line.trim().split(/\s+/); 9 | return { 10 | from, 11 | to, 12 | }; 13 | }) 14 | .filter(redirect => redirect.from && redirect.to); 15 | 16 | export default defineNuxtPlugin(() => { 17 | // Client side redirects only since it is hosted as a SSG app 18 | if (process.server) return; 19 | 20 | const router = useRouter(); 21 | router.beforeEach(route => { 22 | const redirect = parsedRedirects.find(({ from }) => route.path === from); 23 | if (redirect == null) return; 24 | 25 | const newUrl = new URL(location.href); 26 | newUrl.pathname = redirect.to; 27 | location.href = newUrl.href; 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/match-patterns/README.md: -------------------------------------------------------------------------------- 1 | # `@webext-core/match-patterns` 2 | 3 | Utilities for working with [match patterns](https://developer.chrome.com/docs/extensions/mv3/match_patterns/). 4 | 5 | ```bash 6 | pnpm i @webext-core/match-patterns 7 | ``` 8 | 9 | ```ts 10 | import { MatchPattern } from '@webext-core/match-patterns'; 11 | 12 | const pattern = MatchPattern('*://*.google.com/*'); 13 | 14 | pattern.includes('http://google.com/search?q=test'); // true 15 | pattern.includes('https://accounts.google.com'); // true 16 | pattern.includes('https://youtube.com/watch'); // false 17 | ``` 18 | 19 | ## Get Started 20 | 21 | See [documentation](https://webext-core.aklinker1.io/guide/match-patterns/) to get started! 22 | 23 | ### Supported Protocols 24 | 25 | Not all protocols are supported. Open a PR to add support. 26 | 27 | - [x] `` 28 | - [x] `https` protocol 29 | - [x] `http` protocol 30 | - [ ] `file` protocol 31 | - [ ] `ftp` protocol 32 | - [ ] `urn` protocol 33 | -------------------------------------------------------------------------------- /docs/content/fake-browser/3.reseting-state.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Resetting State 3 | description: '' 4 | --- 5 | 6 | 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: 7 | 8 | 1. Reset everything: `fakeBrowser.reset()` 9 | 2. Reset just one API: `fakeBrowser.{api}.resetState()` 10 | 3. Call `fakeBrowser.{api}.on{Event}.removeAllListeners()` to remove all the listeners setup for an event 11 | 12 | ::alert 13 | All the reset methods are synchronous 14 | :: 15 | 16 | For example, to clear the in-memory stored values for `browser.storage.local`, you could call any of the following: 17 | 18 | - `fakeBrowser.reset()` 19 | - `fakeBrowser.storage.resetState()` 20 | 21 | All these reset methods should show up in your editor's intellisense. 22 | 23 | ::alert 24 | Generally, you should put a call to `fakeBrowser.reset()` in a `beforeEach` block to cleanup the state before every test. 25 | :: 26 | -------------------------------------------------------------------------------- /packages/messaging/README.md: -------------------------------------------------------------------------------- 1 | # `@webext-core/messaging` 2 | 3 | A light-weight, type-safe wrapper around the `browser.runtime` messaging APIs. Supports all browsers (Chrome, Firefox, Safari). 4 | 5 | ```ts 6 | // ./messaging.ts 7 | import { defineExtensionMessaging } from '@webext-core/messaging'; 8 | 9 | interface ProtocolMap { 10 | getStringLength(s: string): number; 11 | } 12 | 13 | export const { sendMessage, onMessage } = defineExtensionMessaging(); 14 | ``` 15 | 16 | ```ts 17 | // ./background.ts 18 | import { onMessage } from './messaging'; 19 | 20 | onMessage('getStringLength', message => { 21 | return message.data.length; 22 | }); 23 | ``` 24 | 25 | ```ts 26 | // ./content-script.js or anywhere else 27 | import { sendMessage } from './messaging'; 28 | 29 | const length = await sendMessage('getStringLength', 'hello world'); 30 | 31 | console.log(length); // 11 32 | ``` 33 | 34 | ## Get Started 35 | 36 | See [documentation](https://webext-core.aklinker1.io/guide/messaging/) to get started! 37 | -------------------------------------------------------------------------------- /packages/fake-browser/src/index.ts: -------------------------------------------------------------------------------- 1 | import { alarms } from './apis/alarms'; 2 | import { notifications } from './apis/notifications'; 3 | import { runtime } from './apis/runtime'; 4 | import { storage } from './apis/storage'; 5 | import { tabs } from './apis/tabs'; 6 | import { webNavigation } from './apis/webNavigation'; 7 | import { windows } from './apis/windows'; 8 | import { BrowserOverrides, FakeBrowser } from './types'; 9 | import { GeneratedBrowser } from './base.gen'; 10 | import merge from 'lodash.merge'; 11 | 12 | export type { FakeBrowser }; 13 | 14 | const overrides: BrowserOverrides = { 15 | reset() { 16 | for (const [name, api] of Object.entries(fakeBrowser)) { 17 | if (name !== 'reset') (api as any).resetState?.(); 18 | } 19 | }, 20 | 21 | // Implemented 22 | alarms, 23 | notifications, 24 | runtime, 25 | storage, 26 | tabs, 27 | webNavigation, 28 | windows, 29 | }; 30 | 31 | /** 32 | * An in-memory implementation of the `browser` global. 33 | */ 34 | export const fakeBrowser: FakeBrowser = merge(GeneratedBrowser, overrides); 35 | -------------------------------------------------------------------------------- /packages/proxy-service/src/isBackground.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill'; 2 | 3 | /** 4 | * Detect whether the code is running in the background (service worker or page) 5 | */ 6 | export function isBackground() { 7 | // Not in an extension context 8 | if (!canAccessExtensionApi()) return false; 9 | 10 | const manifest = Browser.runtime.getManifest(); 11 | // There is no background page in the manifest 12 | if (!manifest.background) return false; 13 | 14 | return manifest.manifest_version === 3 ? isBackgroundServiceWorker() : isBackgroundPage(); 15 | } 16 | 17 | function canAccessExtensionApi(): boolean { 18 | return !!Browser.runtime?.id; 19 | } 20 | 21 | const KNOWN_BACKGROUND_PAGE_PATHNAMES = [ 22 | // Firefox 23 | '/_generated_background_page.html', 24 | ]; 25 | 26 | function isBackgroundPage(): boolean { 27 | return ( 28 | typeof window !== 'undefined' && KNOWN_BACKGROUND_PAGE_PATHNAMES.includes(location.pathname) 29 | ); 30 | } 31 | 32 | function isBackgroundServiceWorker(): boolean { 33 | return typeof window === 'undefined'; 34 | } 35 | -------------------------------------------------------------------------------- /packages/messaging-demo/src/entrypoints/google.content.ts: -------------------------------------------------------------------------------- 1 | export default defineContentScript({ 2 | matches: ['*://*.google.com/*'], 3 | 4 | main(ctx) { 5 | console.log('[google.content.ts] Content script loaded'); 6 | 7 | googleMessaging.onMessage('fromInjected', event => { 8 | console.log('[google.content.ts] Received:', event); 9 | return 'hello injected'; 10 | }); 11 | 12 | googleMessaging.onMessage('fromInjected2', event => { 13 | console.log('[google.content.ts] Received:', event); 14 | return 'hello injected2'; 15 | }); 16 | 17 | const script = document.createElement('script'); 18 | script.src = browser.runtime.getURL('/google-injected.js'); 19 | script.onload = async () => { 20 | const res = await googleMessaging.sendMessage('ping', undefined); 21 | console.log('[google.content.ts] Response:', res); 22 | 23 | const res2 = await googleMessaging.sendMessage('ping2', undefined); 24 | console.log('[google.content.ts] Response2:', res2); 25 | }; 26 | document.head.appendChild(script); 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /packages/proxy-service-demo/src/utils/math-service.ts: -------------------------------------------------------------------------------- 1 | import { defineProxyService } from '@webext-core/proxy-service'; 2 | 3 | class MathService { 4 | add(x: number, y: number): number { 5 | console.log(`MathService.add(${x}, ${y})`); 6 | return x + y; 7 | } 8 | subtract(x: number, y: number): number { 9 | console.log(`MathService.subtract(${x}, ${y})`); 10 | return x - y; 11 | } 12 | multiply(x: number, y: number): number { 13 | console.log(`MathService.multiply(${x}, ${y})`); 14 | return x * y; 15 | } 16 | divide(x: number, y: number): number { 17 | console.log(`MathService.divide(${x}, ${y})`); 18 | if (y === 0) throw Error('Cannot divide by zero'); 19 | return x / y; 20 | } 21 | async factorial(x: number): Promise { 22 | console.log(`MathService.factorial(${x})`); 23 | await new Promise(res => setTimeout(res, 0)); 24 | return x === 1 ? 1 : x * (await this.factorial(x - 1)); 25 | } 26 | } 27 | 28 | export const [registerMathService, getMathService] = defineProxyService( 29 | 'MathService', 30 | () => new MathService(), 31 | ); 32 | -------------------------------------------------------------------------------- /packages/isolated-element/README.md: -------------------------------------------------------------------------------- 1 | # `@webext-core/isolated-element` 2 | 3 | Isolate content script UI's styles from the parent page and control event bubbling to the host page. Supports all browsers (Chrome, Firefox, Safari). 4 | 5 | ```bash 6 | pnpm i @webext-core/isolated-element 7 | ``` 8 | 9 | ```ts 10 | import { createIsolatedElement } from '@webext-core/isolated-element'; 11 | import browser from 'webextension-polyfill'; 12 | 13 | function mountUI(root: HtmlElement) { 14 | const text = document.createElement('p'); 15 | text.textContent = 'Isolated text'; 16 | root.appendChild(text); 17 | } 18 | 19 | const { parentElement, isolatedElement } = await createIsolatedElement({ 20 | name: 'some-name', 21 | css: { 22 | url: browser.runtime.getURL('/path/to/styles.css'), 23 | }, 24 | isolateEvents: true, // or array of event names to isolate, e.g., ['click', 'keydown'] 25 | }); 26 | 27 | mountUi(isolatedElement); 28 | document.body.appendChild(parentElement); 29 | ``` 30 | 31 | ## Get Started 32 | 33 | See [documentation](https://webext-core.aklinker1.io/guide/isolated-element/) to get started! 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Aaron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/content/match-patterns/api.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | --- 4 | description: "" 5 | --- 6 | 7 | ::alert 8 | 9 | See [`@webext-core/match-patterns`](/match-patterns/installation/) 10 | 11 | :: 12 | 13 | ## `InvalidMatchPattern` 14 | 15 | ```ts 16 | class InvalidMatchPattern extends Error { 17 | constructor(matchPattern: string, reason: string) { 18 | // ... 19 | } 20 | } 21 | ``` 22 | 23 | ## `MatchPattern` 24 | 25 | ```ts 26 | class MatchPattern { 27 | constructor(matchPattern: string) { 28 | // ... 29 | } 30 | includes(url: string | URL | Location): boolean { 31 | // ... 32 | } 33 | } 34 | ``` 35 | 36 | Class for parsing and performing operations on match patterns. 37 | 38 | ### Examples 39 | 40 | ```ts 41 | const pattern = new MatchPattern("*://google.com/*"); 42 | 43 | pattern.includes("https://google.com"); // true 44 | pattern.includes("http://youtube.com/watch?v=123") // false 45 | ``` 46 | 47 |

48 | 49 | --- 50 | 51 | _API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts)_ -------------------------------------------------------------------------------- /packages/messaging-demo/src/entrypoints/duckduckgo.content.ts: -------------------------------------------------------------------------------- 1 | export default defineContentScript({ 2 | matches: ['*://*.duckduckgo.com/*'], 3 | 4 | main(ctx) { 5 | console.log('[duckduckgo.content.ts] Content script loaded'); 6 | 7 | duckduckgoMessaging.onMessage('fromInjected', event => { 8 | console.log('[duckduckgo.content.ts] Received:', event); 9 | return 'hello injected'; 10 | }); 11 | 12 | duckduckgoMessaging.onMessage('fromInjected2', event => { 13 | console.log('[duckduckgo.content.ts] Received:', event); 14 | return 'hello injected2'; 15 | }); 16 | 17 | const script = document.createElement('script'); 18 | script.src = browser.runtime.getURL('/duckduckgo-injected.js'); 19 | script.onload = async () => { 20 | const res = await duckduckgoMessaging.sendMessage('ping', undefined); 21 | console.log('[duckduckgo.content.ts] Response:', res); 22 | 23 | const res2 = await duckduckgoMessaging.sendMessage('ping2', undefined); 24 | console.log('[duckduckgo.content.ts] Response2:', res2); 25 | }; 26 | document.head.appendChild(script); 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /packages/storage/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { ExtensionStorage } from './types'; 2 | import { defineExtensionStorage } from './defineExtensionStorage'; 3 | import Browser from 'webextension-polyfill'; 4 | 5 | export { defineExtensionStorage }; 6 | 7 | /** 8 | * An implementation of `ExtensionStorage` based on the `browser.storage.local` storage area. 9 | */ 10 | export const localExtStorage = defineExtensionStorage(Browser.storage.local); 11 | /** 12 | * An implementation of `ExtensionStorage` based on the `browser.storage.local` storage area. 13 | * 14 | * - Added to Chrome 102 as of May 24th, 2022. 15 | * - Added to Safari 16.4 as of March 27th, 2023. 16 | * - Added to Firefox 115 as of July 4th, 2023. 17 | */ 18 | export const sessionExtStorage = defineExtensionStorage(Browser.storage.session); 19 | /** 20 | * An implementation of `ExtensionStorage` based on the `browser.storage.sync` storage area. 21 | */ 22 | export const syncExtStorage = defineExtensionStorage(Browser.storage.sync); 23 | /** 24 | * An implementation of `ExtensionStorage` based on the `browser.storage.managed` storage area. 25 | */ 26 | export const managedExtStorage = defineExtensionStorage(Browser.storage.managed); 27 | -------------------------------------------------------------------------------- /packages/fake-browser/src/utils/defineEventWithTrigger.ts: -------------------------------------------------------------------------------- 1 | import { Events } from 'webextension-polyfill'; 2 | 3 | type EventCallback = (...args: any[]) => any; 4 | 5 | type EventWithTrigger = Events.Event & { 6 | /** 7 | * Manually trigger the event and return the results from all the active listeners. 8 | */ 9 | trigger(...args: Parameters): Promise[]>; 10 | /** 11 | * Remove all listeners from the event. 12 | */ 13 | removeAllListeners(): void; 14 | }; 15 | 16 | export function defineEventWithTrigger(): EventWithTrigger { 17 | const listeners: T[] = []; 18 | 19 | return { 20 | hasListener(callback) { 21 | return listeners.includes(callback); 22 | }, 23 | hasListeners() { 24 | return listeners.length > 0; 25 | }, 26 | addListener(callback) { 27 | listeners.push(callback); 28 | }, 29 | removeListener(callback) { 30 | const index = listeners.indexOf(callback); 31 | if (index >= 0) listeners.splice(index, 1); 32 | }, 33 | removeAllListeners() { 34 | listeners.length = 0; 35 | }, 36 | async trigger(...args) { 37 | return await Promise.all(listeners.map(l => l(...args))); 38 | }, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webext-core", 3 | "packageManager": "bun@1.3.1", 4 | "private": true, 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "scripts": { 9 | "format": "prettier --write .", 10 | "format:check": "prettier --check .", 11 | "build": "buildc all", 12 | "prepare": "simple-git-hooks" 13 | }, 14 | "devDependencies": { 15 | "@aklinker1/buildc": "^1.1.5", 16 | "@aklinker1/generate-changelog": "*", 17 | "@algolia/client-search": "^4.17.0", 18 | "@types/prettier": "^2.7.2", 19 | "@types/webextension-polyfill": "^0.9.1", 20 | "@vitest/browser": "^2.1.1", 21 | "@vitest/coverage-v8": "^2.1.1", 22 | "@webext-core/fake-browser": "workspace:*", 23 | "lint-staged": "^16.2.6", 24 | "parse-path": "^7.1.0", 25 | "playwright": "^1.47.0", 26 | "prettier": "^3.3.3", 27 | "simple-git-hooks": "^2.13.1", 28 | "tsup": "^6.4.0", 29 | "turbo": "^1.6.3", 30 | "typescript": "^5.5.4", 31 | "vitest": "^2.1.1", 32 | "webextension-polyfill": "^0.10.0" 33 | }, 34 | "simple-git-hooks": { 35 | "pre-commit": "bun lint-staged" 36 | }, 37 | "lint-staged": { 38 | "*": "prettier --ignore-unknown --write" 39 | }, 40 | "patchedDependencies": { 41 | "simple-git-hooks@2.13.1": "patches/simple-git-hooks@2.13.1.patch" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/content/0.get-started/1.browser-support.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: What browsers do WebExt Core's packages support? All of them. 3 | --- 4 | 5 | ## Overview 6 | 7 | The `@webext-core` packages are simple wrappers around [`webextension-polyfill`](https://www.npmjs.com/package/webextension-polyfill) by Mozilla. As such, they will work on: 8 | 9 | | Browser | Supported Versions | 10 | | --------------------- | ------------------ | 11 | | Chrome | >= 87 | 12 | | Firefox | >= 78 | 13 | | Safari _1_ | >= 14 | 14 | | Edge | >= 88 | 15 | 16 | Other Chromium-based browsers are not officially supported, and may not work_2_. See Mozilla's [supported browsers documentation](https://github.com/mozilla/webextension-polyfill#supported-browsers) for more details. 17 | 18 | > _1_ `webextension-polyfill` works on Safari, however Safari does not implement the complete web extension standard. See the [browser compatiblity chart](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Browser_support_for_JavaScript_APIs) for more details. 19 | > 20 | > _2_ In practice, the browsers are close enough to chrome that they work 99% of the time. But make sure to test your extension before assuming it will work. 21 | -------------------------------------------------------------------------------- /packages/proxy-service/src/types.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionMessagingConfig } from '@webext-core/messaging'; 2 | 3 | export type Proimsify = T extends Promise ? T : Promise; 4 | 5 | export type Service = ((...args: any[]) => Promise) | { [key: string]: any | Service }; 6 | 7 | /** 8 | * A type that ensures a service has only async methods. 9 | * - ***If all methods are async***, it returns the original type. 10 | * - ***If the service has non-async methods***, it returns a `DeepAsync` of the service. 11 | */ 12 | export type ProxyService = 13 | TService extends DeepAsync ? TService : DeepAsync; 14 | 15 | /** 16 | * A recursive type that deeply converts all methods in `TService` to be async. 17 | */ 18 | export type DeepAsync = TService extends (...args: any) => any 19 | ? ToAsyncFunction 20 | : TService extends { [key: string]: any } 21 | ? { 22 | [fn in keyof TService]: DeepAsync; 23 | } 24 | : never; 25 | 26 | type ToAsyncFunction any> = ( 27 | ...args: Parameters 28 | ) => Proimsify>; 29 | 30 | /** 31 | * Configure a proxy service's behavior. It uses `@webext-core/messaging` internally, so any 32 | * config from `ExtensionMessagingConfig` can be passed as well. 33 | */ 34 | export interface ProxyServiceConfig extends ExtensionMessagingConfig {} 35 | -------------------------------------------------------------------------------- /packages/messaging-demo/src/entrypoints/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 22 | 23 | 24 |

Messaging Test

25 | 26 |
27 | 28 |

29 |     
30 | 31 |
32 | 33 |

34 |     
35 | 36 |
37 | 38 |

39 |     
40 | 41 |
42 | 43 |

44 |     
45 | 46 |
47 | 48 |

49 |     
50 | 51 | 52 | -------------------------------------------------------------------------------- /packages/proxy-service-demo/src/entrypoints/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 22 | 23 | 24 |

Proxy Service Test

25 | 26 |
27 | 28 |

29 |     
30 | 31 |
32 | 33 |

34 |     
35 | 36 |
37 | 38 |

39 |     
40 | 41 |
42 | 43 |

44 |     
45 | 46 |
47 | 48 |

49 |     
50 | 51 | 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webext-core 2 | 3 | [![Validate](https://github.com/aklinker1/webext-core/actions/workflows/validate.yml/badge.svg)](https://github.com/aklinker1/webext-core/actions/workflows/validate.yml) 4 | 5 | A set of core libraries and tools for building web extensions. These libraries are built on top of [`webextension-polyfill`](https://www.npmjs.com/package/webextension-polyfill) and support all browsers. 6 | 7 | - [`@webext-core/messaging`](https://webext-core.aklinker1.io/guide/messaging/): Light weight, type-safe wrapper around the web extension messaging APIs 8 | - [`@webext-core/storage`](https://webext-core.aklinker1.io/guide/storage/): Local storage based, **type-safe** wrappers around the storage API 9 | - [`@webext-core/job-scheduler`](https://webext-core.aklinker1.io/guide/job-scheduler/): Schedule reoccuring jobs using the Alarms API 10 | - [`@webext-core/fake-browser`](https://webext-core.aklinker1.io/guide/fake-browser/): An in-memory implementation of `webextension-polyfill` for testing 11 | - [`@webext-core/proxy-service`](https://webext-core.aklinker1.io/guide/proxy-service/): Write services that can be called from any JS context, but run in the background service worker 12 | - [`@webext-core/isolated-element`](https://webext-core.aklinker1.io/guide/isolated-element/): Isolate content script UIs from the page's styles 13 | 14 | ## Documentation 15 | 16 | To get started, checkout the [documentation website](https://webext-core.aklinker1.io). 17 | -------------------------------------------------------------------------------- /docs/content/fake-browser/2.triggering-events.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | When possible, events are triggered based on other calls to other browser APIs. For example: 6 | 7 | - Calling `fakeBrowser.runtime.sendMessage()` will trigger the `fakeBrowser.runtime.onMessage` listeners 8 | - Calling `fakeBrowser.tabs.create()` will trigger the `fakeBrowser.tabs.onCreated` listeners 9 | 10 | Some events, like `runtime.onInstalled` or `alarms.onAlarm`, can't be triggered as they would be in a real extension. 11 | 12 | ::alert 13 | In the case of `onInstalled`, when is an extension "installed" during tests? Never? Or when the tests start? Either way, not useful for testing. 14 | :: 15 | 16 | ::alert 17 | 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. 18 | :: 19 | 20 | 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: 21 | 22 | ```ts 23 | await fakeBrowser.runtime.onInstalled.trigger({ reason: 'install' }); 24 | await fakeBrowser.alarms.onAlarm.trigger({ 25 | name: 'alarm-name', 26 | periodInMinutes: 5, 27 | scheduledTime: Date.now(), 28 | }); 29 | await fakeBrowser.tab.onCreated.trigger({ ... }); 30 | ``` 31 | 32 | :::info 33 | If you await the call to `trigger`, it will wait for all the listener to finish running. 34 | ::: 35 | -------------------------------------------------------------------------------- /packages/isolated-element/src/options.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Options that can be passed into `createIsolatedElement`. 3 | */ 4 | export interface CreateIsolatedElementOptions { 5 | /** 6 | * An HTML tag name used for the shadow root container. 7 | * 8 | * 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 ). 9 | * 10 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#elements_you_can_attach_a_shadow_to 11 | * @example "sticky-note" 12 | * @example "anime-skip-player" 13 | * @example "github-better-line-count-diff" 14 | * @example "div" 15 | */ 16 | name: string; 17 | /** 18 | * See [`ShadowRoot.mode`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode). 19 | * 20 | * @default 'closed' 21 | */ 22 | mode?: 'open' | 'closed'; 23 | /** 24 | * 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. 25 | */ 26 | css?: { url: string } | { textContent: string }; 27 | /** 28 | * When enabled, `event.stopPropagation` will be called on events trying to bubble out of the shadow root. 29 | * 30 | * - Set to `true` to stop the propagation of a default set of events, `["keyup", "keydown", "keypress"]` 31 | * - Set to an array of event names to stop the propagation of a custom list of events 32 | */ 33 | isolateEvents?: boolean | string[]; 34 | } 35 | -------------------------------------------------------------------------------- /packages/match-patterns/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webext-core/match-patterns", 3 | "version": "1.0.3", 4 | "description": "Utilities for working with match patterns.", 5 | "license": "MIT", 6 | "keywords": [ 7 | "web-extension", 8 | "browser-extension", 9 | "chrome-extension", 10 | "webext", 11 | "web-ext", 12 | "chrome", 13 | "firefox", 14 | "safari", 15 | "browser", 16 | "extension", 17 | "match", 18 | "pattern", 19 | "content", 20 | "script" 21 | ], 22 | "homepage": "https://github.com/aklinker1/webext-core/tree/main/packages/match-patterns", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/aklinker1/webext-core", 26 | "directory": "packages/match-patterns" 27 | }, 28 | "type": "module", 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "files": [ 33 | "lib" 34 | ], 35 | "main": "lib/index.cjs", 36 | "module": "lib/index.js", 37 | "types": "lib/index.d.ts", 38 | "exports": { 39 | ".": { 40 | "import": "./lib/index.js", 41 | "require": "./lib/index.cjs" 42 | } 43 | }, 44 | "scripts": { 45 | "build": "buildc -- tsup src/index.ts --clean --out-dir lib --dts --format esm,cjs,iife --global-name webExtCoreMatchPatterns", 46 | "test": "buildc --deps-only -- vitest", 47 | "test:coverage": "buildc --deps-only -- vitest run --coverage", 48 | "check": "buildc --deps-only -- tsc --noEmit" 49 | }, 50 | "devDependencies": { 51 | "tsconfig": "workspace:*" 52 | }, 53 | "buildc": { 54 | "outDir": "lib" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/content/match-patterns/0.installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} 6 | 7 | ## Overview 8 | 9 | `@webext-core/match-patterns` provides utilities for working with match patterns. 10 | 11 | ## Installation 12 | 13 | ###### NPM 14 | 15 | ```bash 16 | pnpm i @webext-core/match-patterns 17 | ``` 18 | 19 | ```ts 20 | import { MatchPattern } from '@webext-core/match-patterns'; 21 | ``` 22 | 23 | ###### CDN 24 | 25 | ```bash 26 | curl -o match-patterns.js https://cdn.jsdelivr.net/npm/@webext-core/match-patterns/lib/index.global.js 27 | ``` 28 | 29 | ```html 30 | 31 | 34 | ``` 35 | 36 | ## Usage 37 | 38 | `MatchPattern` includes one function: `includes`. It can be used to check if a URL is included (or matches) the match pattern. 39 | 40 | ```ts 41 | import { MatchPattern } from '@webext-core/match-patterns'; 42 | 43 | const google = new MatchPattern('*://*.google.com'); 44 | google.includes('https://accounts.google.com'); // true 45 | google.includes('https://google.com/search?q=test'); // true 46 | 47 | const youtube = new MatchPattern('*://youtube.com/watch'); 48 | youtube.includes('https://youtube.com/watch'); // true 49 | youtube.includes('https://youtube.com/mrbeast'); // false 50 | youtube.includes('https://accounts.google.com'); // false 51 | ``` 52 | 53 | `includes` also accepts URLs and `window.location` 54 | 55 | ```ts 56 | google.includes(new URL('https://google.com')); 57 | google.includes(window.location); 58 | ``` 59 | -------------------------------------------------------------------------------- /packages/storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webext-core/storage", 3 | "version": "1.2.0", 4 | "description": "A type-safe, localStorage-esk wrapper around the web extension storage APIs. Supports all browsers (Chrome, Firefox, Safari, etc)", 5 | "license": "MIT", 6 | "keywords": [ 7 | "web-extension", 8 | "browser-extension", 9 | "chrome-extension", 10 | "webext", 11 | "web-ext", 12 | "chrome", 13 | "firefox", 14 | "safari", 15 | "browser", 16 | "extension", 17 | "storage" 18 | ], 19 | "homepage": "https://github.com/aklinker1/webext-core/tree/main/packages/storage", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/aklinker1/webext-core", 23 | "directory": "packages/storage" 24 | }, 25 | "type": "module", 26 | "publishConfig": { 27 | "access": "public" 28 | }, 29 | "files": [ 30 | "lib" 31 | ], 32 | "main": "lib/index.cjs", 33 | "module": "lib/index.js", 34 | "types": "lib/index.d.ts", 35 | "exports": { 36 | ".": { 37 | "import": "./lib/index.js", 38 | "require": "./lib/index.cjs" 39 | } 40 | }, 41 | "scripts": { 42 | "build": "buildc -- tsup src/index.ts --clean --out-dir lib --dts --format esm,cjs,iife --global-name webExtCoreStorage", 43 | "test": "buildc --deps-only -- vitest -r src", 44 | "test:coverage": "buildc --deps-only -- vitest run -r src --coverage", 45 | "check": "buildc --deps-only -- tsc --noEmit" 46 | }, 47 | "dependencies": { 48 | "webextension-polyfill": "^0.10.0" 49 | }, 50 | "devDependencies": { 51 | "@types/webextension-polyfill": "^0.10.5", 52 | "@webext-core/fake-browser": "workspace:*", 53 | "tsconfig": "workspace:*" 54 | }, 55 | "buildc": { 56 | "outDir": "lib" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /docs/_redirects.txt: -------------------------------------------------------------------------------- 1 | # Vitepress -> Nuxt 2 | 3 | /guide/ /get-started/introduction 4 | /guide/browser-support.html /get-started/browser-support 5 | /guide/contributing.html /get-started/contributing 6 | /guide/fake-browser/ /fake-browser/installation 7 | /guide/fake-browser/testing-frameworks.html /fake-browser/testing-frameworks 8 | /guide/fake-browser/reseting-state.html /fake-browser/triggering-events 9 | /guide/fake-browser/triggering-events.html /fake-browser/reseting-state 10 | /guide/fake-browser/implemented-apis.html /fake-browser/implemented-apis 11 | /guide/isolated-element/ /isolated-element/installation 12 | /guide/job-scheduler/ /job-scheduler/installation 13 | /guide/match-patterns/ /match-patterns/installation 14 | /guide/messaging/ /messaging/installation 15 | /guide/messaging/protocol-maps.html /messaging/protocol-maps 16 | /guide/proxy-service/ /proxy-service/installation 17 | /guide/proxy-service/defining-services.html /proxy-service/defining-services 18 | /guide/storage/ /storage/installation 19 | /guide/storage/typescript.html /storage/typescript 20 | /api/fake-browser.html /fake-browser/api 21 | /api/isolated-element.html /isolated-element/api 22 | /api/job-scheduler.html /job-scheduler/api 23 | /api/match-patterns.html /match-patterns/api 24 | /api/messaging.html /messaging/api 25 | /api/proxy-service.html /proxy-service/api 26 | /api/storage.html /storage/api 27 | -------------------------------------------------------------------------------- /packages/job-scheduler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webext-core/job-scheduler", 3 | "version": "1.0.0", 4 | "description": "Schedule and run jobs in your background script", 5 | "license": "MIT", 6 | "keywords": [ 7 | "web-extension", 8 | "browser-extension", 9 | "chrome-extension", 10 | "webext", 11 | "web-ext", 12 | "chrome", 13 | "firefox", 14 | "safari", 15 | "browser", 16 | "extension", 17 | "job", 18 | "scheduler", 19 | "cron", 20 | "period", 21 | "periodic" 22 | ], 23 | "homepage": "https://github.com/aklinker1/webext-core/tree/main/packages/job-scheduler", 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/aklinker1/webext-core", 27 | "directory": "packages/job-scheduler" 28 | }, 29 | "type": "module", 30 | "publishConfig": { 31 | "access": "public" 32 | }, 33 | "files": [ 34 | "lib" 35 | ], 36 | "main": "lib/index.cjs", 37 | "module": "lib/index.js", 38 | "types": "lib/index.d.ts", 39 | "exports": { 40 | ".": { 41 | "import": "./lib/index.js", 42 | "require": "./lib/index.cjs" 43 | } 44 | }, 45 | "scripts": { 46 | "build": "buildc -- tsup src/index.ts --clean --out-dir lib --dts --format esm,cjs,iife --global-name webExtCoreJobScheduler", 47 | "test": "buildc --deps-only -- vitest -r src", 48 | "test:coverage": "buildc --deps-only -- vitest run -r src --coverage", 49 | "check": "buildc --deps-only -- tsc --noEmit" 50 | }, 51 | "dependencies": { 52 | "cron-parser": "^4.8.1", 53 | "webextension-polyfill": "^0.10.0", 54 | "format-duration": "^3.0.2" 55 | }, 56 | "devDependencies": { 57 | "@webext-core/fake-browser": "workspace:*", 58 | "tsconfig": "workspace:*" 59 | }, 60 | "buildc": { 61 | "outDir": "lib" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/isolated-element/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webext-core/isolated-element", 3 | "version": "1.1.3", 4 | "description": "Isolate content script UI's styles from the parent page. Supports all browsers (Chrome, Firefox, Safari)", 5 | "license": "MIT", 6 | "keywords": [ 7 | "web-extension", 8 | "browser-extension", 9 | "chrome-extension", 10 | "webext", 11 | "web-ext", 12 | "chrome", 13 | "firefox", 14 | "safari", 15 | "browser", 16 | "extension", 17 | "islolate", 18 | "styles", 19 | "css", 20 | "content-script" 21 | ], 22 | "homepage": "https://github.com/aklinker1/webext-core/tree/main/packages/isolated-element", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/aklinker1/webext-core", 26 | "directory": "packages/isolated-element" 27 | }, 28 | "type": "module", 29 | "publishConfig": { 30 | "access": "public" 31 | }, 32 | "files": [ 33 | "lib" 34 | ], 35 | "main": "lib/index.cjs", 36 | "module": "lib/index.js", 37 | "types": "lib/index.d.ts", 38 | "exports": { 39 | ".": { 40 | "import": "./lib/index.js", 41 | "require": "./lib/index.cjs" 42 | } 43 | }, 44 | "scripts": { 45 | "build": "buildc -- tsup src/index.ts --clean --out-dir lib --dts --format esm,cjs,iife --global-name webExtCoreIsolatedElement", 46 | "test": "buildc --deps-only -- vitest -r src", 47 | "test:coverage": "buildc --deps-only -- vitest run -r src --coverage", 48 | "check": "buildc --deps-only -- tsc --noEmit" 49 | }, 50 | "devDependencies": { 51 | "@types/is-potential-custom-element-name": "^1.0.0", 52 | "jsdom": "^20.0.3", 53 | "tsconfig": "workspace:*" 54 | }, 55 | "dependencies": { 56 | "is-potential-custom-element-name": "^1.0.1" 57 | }, 58 | "buildc": { 59 | "outDir": "lib" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | workflow_call: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | checks: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 16 | - uses: ./.github/actions/setup 17 | - run: | 18 | bun format:check 19 | bun run --cwd packages/fake-browser check 20 | bun run --cwd packages/isolated-element check 21 | bun run --cwd packages/isolated-element-demo check 22 | bun run --cwd packages/job-scheduler check 23 | bun run --cwd packages/match-patterns check 24 | bun run --cwd packages/messaging check 25 | bun run --cwd packages/proxy-service check 26 | bun run --cwd packages/storage check 27 | 28 | build: 29 | runs-on: ubuntu-22.04 30 | steps: 31 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 32 | - uses: ./.github/actions/setup 33 | - run: node --version 34 | - run: bun run build 35 | 36 | tests: 37 | runs-on: ubuntu-22.04 38 | steps: 39 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 40 | - uses: ./.github/actions/setup 41 | - name: Install Playwright Browsers 42 | run: bun x playwright install --with-deps 43 | - run: | 44 | bun run --cwd packages/fake-browser test:coverage 45 | bun run --cwd packages/isolated-element test:coverage 46 | bun run --cwd packages/job-scheduler test:coverage 47 | bun run --cwd packages/match-patterns test:coverage 48 | bun run --cwd packages/messaging test:coverage 49 | bun run --cwd packages/proxy-service test:coverage 50 | bun run --cwd packages/storage test:coverage 51 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/alarms.ts: -------------------------------------------------------------------------------- 1 | import { Alarms } from 'webextension-polyfill'; 2 | import { BrowserOverrides } from '../types'; 3 | import { defineEventWithTrigger } from '../utils/defineEventWithTrigger'; 4 | 5 | const alarmList: Alarms.Alarm[] = []; 6 | const onAlarm = defineEventWithTrigger<(name: Alarms.Alarm) => void>(); 7 | 8 | export const alarms: BrowserOverrides['alarms'] = { 9 | resetState() { 10 | alarmList.length = 0; 11 | onAlarm.removeAllListeners(); 12 | }, 13 | async clear(name) { 14 | name ??= ''; 15 | const index = alarmList.findIndex(alarm => alarm.name === name); 16 | if (index >= 0) { 17 | alarmList.splice(index, 1); 18 | return true; 19 | } 20 | return false; 21 | }, 22 | async clearAll() { 23 | const hasAlarms = alarmList.length > 0; 24 | alarmList.length = 0; 25 | return hasAlarms; 26 | }, 27 | // @ts-expect-error: multiple implementations 28 | create( 29 | arg0: string | undefined | Alarms.CreateAlarmInfoType, 30 | arg1: Alarms.CreateAlarmInfoType | undefined, 31 | ) { 32 | let name: string; 33 | let alarmInfo: Alarms.CreateAlarmInfoType; 34 | if (typeof arg0 === 'object') { 35 | name = ''; 36 | alarmInfo = arg0; 37 | } else { 38 | name = arg0 ?? ''; 39 | alarmInfo = arg1 as Alarms.CreateAlarmInfoType; 40 | } 41 | const i = alarmList.findIndex(alarm => alarm.name === name); 42 | if (i >= 0) alarmList.splice(i, 1); 43 | 44 | alarmList.push({ 45 | name, 46 | scheduledTime: alarmInfo.when ?? Date.now() + (alarmInfo.delayInMinutes ?? 0) * 60e3, 47 | periodInMinutes: alarmInfo.periodInMinutes, 48 | }); 49 | }, 50 | async get(name) { 51 | name ??= ''; 52 | return alarmList.find(alarm => alarm.name === name)!; 53 | }, 54 | async getAll() { 55 | return alarmList; 56 | }, 57 | onAlarm, 58 | }; 59 | -------------------------------------------------------------------------------- /packages/storage/src/types.ts: -------------------------------------------------------------------------------- 1 | export type AnySchema = Record; 2 | 3 | /** 4 | * Call this method to remove the listener that was added. 5 | */ 6 | export type RemoveListenerCallback = () => void; 7 | 8 | export type OnChangeCallback< 9 | TSchema extends AnySchema, 10 | TKey extends keyof TSchema = keyof TSchema, 11 | > = (newValue: TSchema[TKey], oldValue: TSchema[TKey] | null) => void; 12 | 13 | /** 14 | * This is the interface for the storage objects exported from the package. It is similar to `localStorage`, except for a few differences: 15 | * 16 | * - ***It's async*** since the web extension storage APIs are async. 17 | * - It can store any data type, ***not just strings***. 18 | */ 19 | export interface ExtensionStorage { 20 | /** 21 | * Clear all values from storage. 22 | */ 23 | clear(): Promise; 24 | 25 | /** 26 | * Return the value in storage or `null` if the item is missing. 27 | */ 28 | getItem(key: TKey): Promise[TKey] | null>; 29 | 30 | /** 31 | * Set the key and value in storage. Unlike with `localStorage`, passing `null` or `undefined` 32 | * will result in `null` being stored for the value. 33 | */ 34 | setItem(key: TKey, value: TSchema[TKey]): Promise; 35 | 36 | /** 37 | * Remove the value from storage at a key. 38 | */ 39 | removeItem(key: TKey): Promise; 40 | 41 | /** 42 | * Add a callback that is executed when a key is changed in the storage. Listeners are executed in 43 | * parallel, but the first listeners added are always started first. 44 | * 45 | * Returns a method that, when called, removes the listener that was added. 46 | */ 47 | onChange( 48 | key: TKey, 49 | cb: OnChangeCallback, 50 | ): RemoveListenerCallback; 51 | } 52 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/tabs.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { fakeBrowser } from '../index'; 3 | 4 | describe('tabs', () => { 5 | beforeEach(fakeBrowser.reset); 6 | 7 | describe('create', () => { 8 | it('should create a new tab and add it to tabList', async () => { 9 | const newTab = await fakeBrowser.tabs.create({ url: 'https://example.com' }); 10 | 11 | expect(newTab).toBeDefined(); 12 | expect(newTab.id).toBe(1); 13 | expect(newTab.url).toBe('https://example.com'); 14 | 15 | const tabs = await fakeBrowser.tabs.query({}); 16 | expect(tabs).toHaveLength(2); // default tab + new tab 17 | }); 18 | 19 | it('should trigger onCreated event', async () => { 20 | const listener = vi.fn(); 21 | fakeBrowser.tabs.onCreated.addListener(listener); 22 | 23 | const newTab = await fakeBrowser.tabs.create({ url: 'https://example.com' }); 24 | 25 | expect(listener).toHaveBeenCalledWith( 26 | expect.objectContaining({ 27 | id: newTab.id, 28 | url: 'https://example.com', 29 | }), 30 | ); 31 | }); 32 | }); 33 | 34 | describe('query', () => { 35 | it('should filter tabs by windowId', async () => { 36 | const window2 = await fakeBrowser.windows.create(); 37 | const tab1 = await fakeBrowser.tabs.create({ url: 'https://window1.com' }); 38 | const tab2 = await fakeBrowser.tabs.create({ 39 | url: 'https://window2.com', 40 | windowId: window2.id, 41 | }); 42 | 43 | const window1Tabs = await fakeBrowser.tabs.query({ windowId: 0 }); 44 | expect(window1Tabs).toHaveLength(2); // default + tab1 45 | 46 | const window2Tabs = await fakeBrowser.tabs.query({ windowId: window2.id }); 47 | expect(window2Tabs).toHaveLength(1); 48 | expect(window2Tabs[0].url).toBe('https://window2.com'); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/webNavigation.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { fakeBrowser } from '..'; 3 | 4 | describe('webNavigation', () => { 5 | beforeEach(() => { 6 | fakeBrowser.reset(); 7 | }); 8 | 9 | it('should properly overwrite onBeforeNavigate, adding the trigger method', () => { 10 | expect(fakeBrowser.webNavigation.onBeforeNavigate.trigger).toBeDefined(); 11 | }); 12 | 13 | it('should properly overwrite onCommitted, adding the trigger method', () => { 14 | expect(fakeBrowser.webNavigation.onCommitted.trigger).toBeDefined(); 15 | }); 16 | 17 | it('should properly overwrite onCompleted, adding the trigger method', () => { 18 | expect(fakeBrowser.webNavigation.onCompleted.trigger).toBeDefined(); 19 | }); 20 | 21 | it('should properly overwrite onCreatedNavigationTarget, adding the trigger method', () => { 22 | expect(fakeBrowser.webNavigation.onCreatedNavigationTarget.trigger).toBeDefined(); 23 | }); 24 | 25 | it('should properly overwrite onDOMContentLoaded, adding the trigger method', () => { 26 | expect(fakeBrowser.webNavigation.onDOMContentLoaded.trigger).toBeDefined(); 27 | }); 28 | 29 | it('should properly overwrite onErrorOccurred, adding the trigger method', () => { 30 | expect(fakeBrowser.webNavigation.onErrorOccurred.trigger).toBeDefined(); 31 | }); 32 | 33 | it('should properly overwrite onHistoryStateUpdated, adding the trigger method', () => { 34 | expect(fakeBrowser.webNavigation.onHistoryStateUpdated.trigger).toBeDefined(); 35 | }); 36 | 37 | it('should properly overwrite onReferenceFragmentUpdated, adding the trigger method', () => { 38 | expect(fakeBrowser.webNavigation.onReferenceFragmentUpdated.trigger).toBeDefined(); 39 | }); 40 | 41 | it('should properly overwrite onTabReplaced, adding the trigger method', () => { 42 | expect(fakeBrowser.webNavigation.onTabReplaced.trigger).toBeDefined(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/proxy-service/src/flattenPromise.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { flattenPromise } from './flattenPromise'; 3 | 4 | describe('flattenPromise', () => { 5 | it('should convert Promise to DeepAsync', async () => { 6 | const fnPromise = Promise.resolve((x: number, y: number) => x + y); 7 | 8 | const fn = flattenPromise(fnPromise); 9 | const actual = await fn(1, 2); 10 | 11 | expect(actual).toBe(3); 12 | }); 13 | 14 | it('should convert shallow Promise to DeepAsync', async () => { 15 | const Object = { 16 | additionalIncrement: 1, 17 | add(x: number, y: number): number { 18 | return x + y + this.additionalIncrement; 19 | }, 20 | }; 21 | const objectPromise = Promise.resolve(Object); 22 | 23 | const object = flattenPromise(objectPromise); 24 | const actual = await object.add(1, 2); 25 | 26 | expect(actual).toBe(4); 27 | }); 28 | 29 | it('should convert nested Promise to DeepAsync', async () => { 30 | const objectPromise = Promise.resolve({ 31 | math: { 32 | additionalIncrement: 1, 33 | add(x: number, y: number): number { 34 | return x + y + this.additionalIncrement; 35 | }, 36 | }, 37 | }); 38 | 39 | const object = flattenPromise(objectPromise); 40 | const actual = await object.math.add(1, 2); 41 | 42 | expect(actual).toBe(4); 43 | }); 44 | 45 | it('should convert Promise to DeepAsync', async () => { 46 | const instancePromise = Promise.resolve( 47 | new (class { 48 | additionalIncrement = 1; 49 | add(x: number, y: number): number { 50 | return x + y + this.additionalIncrement; 51 | } 52 | })(), 53 | ); 54 | 55 | const instance = flattenPromise(instancePromise); 56 | const actual = await instance.add(1, 2); 57 | 58 | expect(actual).toBe(4); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/fake-browser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webext-core/fake-browser", 3 | "version": "1.3.4", 4 | "description": "An in-memory implementation of webextension-polyfill for testing. Supports all test frameworks (Vitest, Jest, etc)", 5 | "license": "MIT", 6 | "keywords": [ 7 | "web-extension", 8 | "browser-extension", 9 | "chrome-extension", 10 | "webext", 11 | "web-ext", 12 | "chrome", 13 | "firefox", 14 | "safari", 15 | "browser", 16 | "extension", 17 | "testing", 18 | "vite", 19 | "jest", 20 | "mock", 21 | "fake", 22 | "webextension-polyfill" 23 | ], 24 | "homepage": "https://github.com/aklinker1/webext-core/tree/main/packages/fake-browser", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/aklinker1/webext-core", 28 | "directory": "packages/fake-browser" 29 | }, 30 | "type": "module", 31 | "publishConfig": { 32 | "access": "public" 33 | }, 34 | "files": [ 35 | "lib" 36 | ], 37 | "main": "lib/index.cjs", 38 | "module": "lib/index.js", 39 | "types": "lib/index.d.ts", 40 | "exports": { 41 | ".": { 42 | "import": "./lib/index.js", 43 | "require": "./lib/index.cjs" 44 | } 45 | }, 46 | "scripts": { 47 | "build": "buildc -- bun gen && tsup src/index.ts --clean --out-dir lib --dts --format esm,cjs,iife --global-name webExtCoreFakeBrowser", 48 | "test": "buildc --deps-only -- vitest", 49 | "test:coverage": "buildc --deps-only -- vitest run --coverage", 50 | "check": "buildc --deps-only -- tsc --noEmit", 51 | "gen": "bun scripts/generate-base.ts && prettier --write src/base.gen.ts" 52 | }, 53 | "devDependencies": { 54 | "@types/lodash.merge": "^4.6.7", 55 | "@types/webextension-polyfill": "^0.10.5", 56 | "ts-morph": "^23.0.0", 57 | "tsconfig": "workspace:*" 58 | }, 59 | "dependencies": { 60 | "lodash.merge": "^4.6.2" 61 | }, 62 | "buildc": { 63 | "outDir": "lib" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/messaging-demo/src/entrypoints/popup/main.ts: -------------------------------------------------------------------------------- 1 | function getMessageElements(id: string) { 2 | const parent = document.getElementById(id)!; 3 | return [ 4 | parent.querySelector('button')!, 5 | parent.querySelector('pre')!, 6 | ] as const; 7 | } 8 | 9 | const [sleepButton, sleepPre] = getMessageElements('sleep'); 10 | const [pingButton, pingPre] = getMessageElements('ping'); 11 | const [ping2Button, ping2Pre] = getMessageElements('ping2'); 12 | const [throwButton, throwPre] = getMessageElements('throw'); 13 | const [unknownButton, unknownPre] = getMessageElements('unknown'); 14 | 15 | sleepButton.addEventListener('click', async () => { 16 | sleepPre.innerText = 'Loading...'; 17 | const res = await sendMessage1('sleep', 1000).catch(err => ({ err: err.message })); 18 | sleepPre.innerText = JSON.stringify(res, null, 2); 19 | }); 20 | 21 | pingButton.addEventListener('click', async () => { 22 | pingPre.innerText = 'Loading...'; 23 | const res = await sendMessage1('ping', undefined).catch(err => ({ err: err.message })); 24 | pingPre.innerText = JSON.stringify(res, null, 2); 25 | }); 26 | 27 | ping2Button.addEventListener('click', async () => { 28 | ping2Pre.innerText = 'Loading...'; 29 | const res = await sendMessage2('ping2', 'pong2').catch(err => ({ err: err.message })); 30 | ping2Pre.innerText = JSON.stringify(res, null, 2); 31 | }); 32 | 33 | throwButton.addEventListener('click', async () => { 34 | throwPre.innerText = 'Loading...'; 35 | const res = await sendMessage2('throw', undefined).catch(err => ({ err: err.message })); 36 | throwPre.innerText = JSON.stringify(res, null, 2); 37 | }); 38 | 39 | unknownButton.addEventListener('click', async () => { 40 | unknownPre.innerText = 'Loading...'; 41 | // @ts-expect-error: Testing what happens when an unknown key is passed in 42 | const res = await sendMessage2('unknown', undefined).catch(err => ({ err: err.message })); 43 | unknownPre.innerText = JSON.stringify(res, null, 2); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/proxy-service-demo/src/entrypoints/popup/main.ts: -------------------------------------------------------------------------------- 1 | function getMathServiceElements(id: string) { 2 | const parent = document.getElementById(id)!; 3 | return [ 4 | parent.querySelector('button')!, 5 | parent.querySelector('pre')!, 6 | ] as const; 7 | } 8 | 9 | const [addButton, addPre] = getMathServiceElements('add'); 10 | const [subtractButton, subtractPre] = getMathServiceElements('subtract'); 11 | const [multiplyButton, multiplyPre] = getMathServiceElements('multiply'); 12 | const [divideButton, dividePre] = getMathServiceElements('divide'); 13 | const [factorialButton, factorialPre] = getMathServiceElements('factorial'); 14 | 15 | const MathService = getMathService(); 16 | 17 | addButton.addEventListener('click', async () => { 18 | addPre.innerText = 'Loading...'; 19 | const res = await MathService.add(1, 2).catch(err => ({ err: err.message })); 20 | addPre.innerText = JSON.stringify(res, null, 2); 21 | }); 22 | 23 | subtractButton.addEventListener('click', async () => { 24 | subtractPre.innerText = 'Loading...'; 25 | const res = await MathService.subtract(2, 1).catch(err => ({ err: err.message })); 26 | subtractPre.innerText = JSON.stringify(res, null, 2); 27 | }); 28 | 29 | multiplyButton.addEventListener('click', async () => { 30 | multiplyPre.innerText = 'Loading...'; 31 | const res = await MathService.multiply(2, 3).catch(err => ({ err: err.message })); 32 | multiplyPre.innerText = JSON.stringify(res, null, 2); 33 | }); 34 | 35 | divideButton.addEventListener('click', async () => { 36 | dividePre.innerText = 'Loading...'; 37 | const res = await MathService.divide(1, 0).catch(err => ({ err: err.message })); 38 | dividePre.innerText = JSON.stringify(res, null, 2); 39 | }); 40 | 41 | factorialButton.addEventListener('click', async () => { 42 | factorialPre.innerText = 'Loading...'; 43 | const res = await MathService.factorial(100).catch(err => ({ err: err.message })); 44 | factorialPre.innerText = JSON.stringify(res, null, 2); 45 | }); 46 | -------------------------------------------------------------------------------- /packages/proxy-service/src/flattenPromise.ts: -------------------------------------------------------------------------------- 1 | import get from 'get-value'; 2 | import { DeepAsync } from './types'; 3 | 4 | /** 5 | * Given a promise of a variable, return a proxy to that awaits the promise internally so you don't 6 | * have to call `await` twice. 7 | * 8 | * > This can be used to simplify handling `Promise` passed in your services. 9 | * 10 | * @example 11 | * function createService(dependencyPromise: Promise) { 12 | * const dependency = flattenPromise(dependencyPromise); 13 | * 14 | * return { 15 | * doSomething() { 16 | * await dependency.someAsyncWork(); 17 | * // Instead of `await (await dependencyPromise).someAsyncWork();` 18 | * } 19 | * } 20 | * } 21 | */ 22 | export function flattenPromise(promise: Promise): DeepAsync { 23 | function createProxy(location?: { propertyPath: string; parentPath?: string }): DeepAsync { 24 | const wrapped = (() => {}) as DeepAsync; 25 | const proxy = new Proxy(wrapped, { 26 | async apply(_target, _thisArg, args) { 27 | const t = (await promise) as any; 28 | const thisArg = (location?.parentPath ? get(t, location.parentPath) : t) as any | undefined; 29 | const fn = (location ? get(t, location.propertyPath) : t) as (...args: any[]) => any; 30 | return fn.apply(thisArg, args); 31 | }, 32 | 33 | // Executed when accessing a property on an object 34 | get(target, propertyName, receiver) { 35 | if (propertyName === '__proxy' || typeof propertyName === 'symbol') { 36 | return Reflect.get(target, propertyName, receiver); 37 | } 38 | return createProxy({ 39 | propertyPath: 40 | location == null ? propertyName : `${location.propertyPath}.${propertyName}`, 41 | parentPath: location?.propertyPath, 42 | }); 43 | }, 44 | }); 45 | // @ts-expect-error: Adding a hidden property 46 | proxy.__proxy = true; 47 | return proxy; 48 | } 49 | 50 | return createProxy(); 51 | } 52 | -------------------------------------------------------------------------------- /docs/content/messaging/1.protocol-maps.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | ::alert 6 | Only relevant to TypeScript projects. 7 | :: 8 | 9 | ## Overview 10 | 11 | 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! 12 | 13 | ## Syntax 14 | 15 | 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. 16 | 17 | 18 | ```ts 19 | interface ProtocolMap { 20 | message1(): void; // No data and no return type 21 | message2(data: string): void; // Only data 22 | message3(): boolean; // Only a return type 23 | message4(data: string): boolean; // Data and return type 24 | } 25 | 26 | export const { sendMessage, onMessage } = defineExtensionMessaging(); 27 | ``` 28 | 29 | When calling `sendMessage` or `onMessage`, all the types will be inferred: 30 | 31 | ```ts 32 | onMessage('message2', ({ data /* string */ }) /* : void */ => {}); 33 | onMessage('message3', (message) /* : boolean */ => true); 34 | 35 | const res /* : boolean */ = await sendMessage('message3', undefined); 36 | const res /* : boolean */ = await sendMessage('message4', 'text'); 37 | ``` 38 | 39 | ## Async Messages 40 | 41 | All messages are async. In your protocol map, you don't need to make the return type `Promise`, `T` will work just fine. 42 | 43 | ```diff 44 | interface ProtocolMap { 45 | - someMessage(): Promise; 46 | + someMessage(): string; 47 | } 48 | ``` 49 | 50 | ## Multiple Arguments 51 | 52 | Protocol map functions should be defined with a single parameter, `data`. To pass more than one argument, make the `data` parameter an object instead! 53 | 54 | ```diff 55 | interface ProtocolMap { 56 | - someMessage(arg1: string, arg2: boolean): void; 57 | + someMessage(data: { arg1: string; arg2: boolean }): void; 58 | } 59 | ``` 60 | 61 | ```ts 62 | await sendMessage('someMessage', { arg1: ..., arg2: ... }); 63 | ``` 64 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Notifications } from 'webextension-polyfill'; 2 | import { BrowserOverrides } from '../types'; 3 | import { defineEventWithTrigger } from '../utils/defineEventWithTrigger'; 4 | 5 | let notificationMap: { [id: string]: Notifications.CreateNotificationOptions } = {}; 6 | const onClosed = defineEventWithTrigger<(notificationId: string, byUser: boolean) => void>(); 7 | const onClicked = defineEventWithTrigger<(notificationId: string) => void>(); 8 | const onButtonClicked = 9 | defineEventWithTrigger<(notificationId: string, buttonIndex: number) => void>(); 10 | const onShown = defineEventWithTrigger<(notificationId: string) => void>(); 11 | 12 | function create(options: Notifications.CreateNotificationOptions): Promise; 13 | function create( 14 | notificationId: string | undefined, 15 | options: Notifications.CreateNotificationOptions, 16 | ): Promise; 17 | async function create(arg1: any, arg2?: any): Promise { 18 | let id: string; 19 | let options: Notifications.CreateNotificationOptions; 20 | if (arg2 == null) { 21 | id = String(Math.random()); 22 | options = arg1; 23 | } else { 24 | id = arg1; 25 | options = arg2; 26 | } 27 | 28 | if (notificationExists(id)) await notifications.clear(id); 29 | 30 | notificationMap[id] = options; 31 | 32 | return id; 33 | } 34 | 35 | function notificationExists(id: string): boolean { 36 | return !!notificationMap[id]; 37 | } 38 | 39 | export const notifications: BrowserOverrides['notifications'] = { 40 | resetState() { 41 | notificationMap = {}; 42 | onClosed.removeAllListeners(); 43 | onClicked.removeAllListeners(); 44 | onButtonClicked.removeAllListeners(); 45 | onShown.removeAllListeners(); 46 | }, 47 | create, 48 | async clear(notificationId) { 49 | const wasCleared = notificationExists(notificationId); 50 | delete notificationMap[notificationId]; 51 | return wasCleared; 52 | }, 53 | async getAll() { 54 | return notificationMap; 55 | }, 56 | onClosed, 57 | onClicked, 58 | onButtonClicked, 59 | onShown, 60 | }; 61 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/runtime.ts: -------------------------------------------------------------------------------- 1 | import { BrowserOverrides } from '../types'; 2 | import { defineEventWithTrigger } from '../utils/defineEventWithTrigger'; 3 | import { Runtime } from 'webextension-polyfill'; 4 | 5 | const onMessage = 6 | defineEventWithTrigger<(message: any, sender: Runtime.MessageSender) => void | Promise>(); 7 | const onInstalled = defineEventWithTrigger<(details: Runtime.OnInstalledDetailsType) => void>(); 8 | const onStartup = defineEventWithTrigger<() => void>(); 9 | const onSuspend = defineEventWithTrigger<() => void>(); 10 | const onSuspendCanceled = defineEventWithTrigger<() => void>(); 11 | const onUpdateAvailable = 12 | defineEventWithTrigger<(details: Runtime.OnUpdateAvailableDetailsType) => void>(); 13 | 14 | const TEST_ID = 'test-extension-id'; 15 | 16 | export const runtime: BrowserOverrides['runtime'] = { 17 | resetState() { 18 | onMessage.removeAllListeners(); 19 | onInstalled.removeAllListeners(); 20 | onStartup.removeAllListeners(); 21 | onSuspend.removeAllListeners(); 22 | onSuspendCanceled.removeAllListeners(); 23 | onUpdateAvailable.removeAllListeners(); 24 | runtime.id = TEST_ID; 25 | }, 26 | id: TEST_ID, 27 | getURL(path: string) { 28 | return `chrome-extension://${runtime.id}/${path.replace(/^\//, '')}`; 29 | }, 30 | onInstalled, 31 | onMessage, 32 | onStartup, 33 | onSuspend, 34 | onSuspendCanceled, 35 | onUpdateAvailable, 36 | // @ts-expect-error: Method has overrides :/ 37 | async sendMessage(arg0, arg1, arg2) { 38 | let extensionId: string | undefined; 39 | let message: any; 40 | let options: Runtime.SendMessageOptionsType | undefined; 41 | 42 | if (arguments.length === 1 || (arguments.length === 2 && typeof arg1 === 'object')) { 43 | extensionId = undefined; 44 | message = arg0; 45 | options = arg2; 46 | } else { 47 | extensionId = arg0; 48 | message = arg1; 49 | options = arg2; 50 | } 51 | 52 | if (!onMessage.hasListeners()) throw Error('No listeners available'); 53 | const sender: Runtime.MessageSender = {}; 54 | const res = await onMessage.trigger(message, sender); 55 | 56 | // Return first response 57 | return res.find(r => !!r); 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /packages/proxy-service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webext-core/proxy-service", 3 | "version": "1.2.2", 4 | "description": "A type-safe wrapper around the web extension messaging APIs that lets you call a function from anywhere, but execute it in the background. Supports all browsers (Chrome, Firefox, Safari, etc)", 5 | "license": "MIT", 6 | "keywords": [ 7 | "web-extension", 8 | "browser-extension", 9 | "chrome-extension", 10 | "webext", 11 | "web-ext", 12 | "chrome", 13 | "firefox", 14 | "safari", 15 | "browser", 16 | "extension", 17 | "rpc", 18 | "service" 19 | ], 20 | "homepage": "https://github.com/aklinker1/webext-core/tree/main/packages/proxy-service", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/aklinker1/webext-core", 24 | "directory": "packages/proxy-service" 25 | }, 26 | "type": "module", 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "files": [ 31 | "lib" 32 | ], 33 | "main": "lib/index.cjs", 34 | "module": "lib/index.js", 35 | "types": "lib/index.d.ts", 36 | "exports": { 37 | ".": { 38 | "types": "lib/index.d.ts", 39 | "import": "./lib/index.js", 40 | "require": "./lib/index.cjs" 41 | } 42 | }, 43 | "scripts": { 44 | "build": "buildc -- tsup src/index.ts --clean --out-dir lib --dts --format esm,cjs,iife --global-name webExtCoreProxyService", 45 | "test": "buildc --deps-only -- vitest -r src", 46 | "test:coverage": "buildc --deps-only -- vitest run -r src --coverage", 47 | "check": "buildc --deps-only -- bun check:typescript && bun check:type-tests", 48 | "check:typescript": "tsc --noEmit", 49 | "check:type-tests": "vitest run --typecheck types" 50 | }, 51 | "dependencies": { 52 | "get-value": "^3.0.1", 53 | "webextension-polyfill": "^0.10.0" 54 | }, 55 | "devDependencies": { 56 | "@types/get-value": "^3.0.3", 57 | "@types/webextension-polyfill": "^0.9.1", 58 | "@webext-core/fake-browser": "workspace:*", 59 | "@webext-core/messaging": "workspace:*", 60 | "tsconfig": "workspace:*" 61 | }, 62 | "peerDependencies": { 63 | "webextension-polyfill": ">=0.10.0", 64 | "@webext-core/messaging": ">=1.3.1" 65 | }, 66 | "buildc": { 67 | "outDir": "lib" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docs/content/storage/0.installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} 6 | 7 | ## Overview 8 | 9 | `@webext-core/storage` provides a type-safe, `localStorage`-like API for interacting with extension storage. 10 | 11 | ```ts 12 | const { key: value } = await browser.storage.local.get('key'); 13 | // VS 14 | const value = await localExtStorage.getItem('key'); 15 | ``` 16 | 17 | ::alert{type=warning} 18 | Requires the `storage` permission. 19 | :: 20 | 21 | ## Installation 22 | 23 | ###### NPM 24 | 25 | ```bash 26 | pnpm i @webext-core/storage 27 | ``` 28 | 29 | ```ts 30 | import { localExtStorage } from '@webext-core/storage'; 31 | 32 | const value = await localExtStorage.getItem('key'); 33 | await localExtStorage.setItem('key', 123); 34 | ``` 35 | 36 | ###### CDN 37 | 38 | ```bash 39 | curl -o storage.js https://cdn.jsdelivr.net/npm/@webext-core/storage/lib/index.global.js 40 | ``` 41 | 42 | ```html 43 | 44 | 50 | ``` 51 | 52 | ## Differences with `localStorage` and `browser.storage` 53 | 54 | | | @webext-core/storage | `localStorage` | `browser.storage` | 55 | | ---------------------------------------- | :-----------------------------------------------------------: | :------------: | :---------------: | 56 | | **Set value to `undefined` removes it?** | ✅ | ✅ | ❌ | 57 | | **Returns `null` for missing values?** | ✅ | ✅ | ❌ | 58 | | **Stores non-string values?** | ✅ | ❌ | ✅ | 59 | | **Async?** | ✅ | ❌ | ✅ | 60 | 61 | Otherwise, the storage behaves the same as `localStorage` / `sessionStorage`. 62 | -------------------------------------------------------------------------------- /packages/match-patterns/src/index.test.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment jsdom 2 | import { describe, it, expect } from 'vitest'; 3 | import { InvalidMatchPattern, MatchPattern } from './index'; 4 | 5 | describe('MatchPattern', () => { 6 | it.each(['', '', '*://*', '*', 'test://*/*'])( 7 | 'should throw an error for invalid pattern "%s"', 8 | pattern => { 9 | expect(() => new MatchPattern(pattern)).toThrowError(InvalidMatchPattern); 10 | }, 11 | ); 12 | 13 | describe('includes', () => { 14 | describe('', () => { 15 | it.each([ 16 | [true, 'http://google.com'], 17 | [true, new URL('https://youtube.com')], 18 | [true, new URL('file:///home/aklinker1')], 19 | [true, { hostname: 'test.com', pathname: '/', protocol: 'http:' } as Location], 20 | ])('should parse "%s", when "%s" is checked, return %s', (exepcted, url) => { 21 | expect(new MatchPattern('').includes(url)).toBe(exepcted); 22 | }); 23 | }); 24 | 25 | describe('* protocol', () => { 26 | it.each([ 27 | ['*://google.com/search', 'http://google.com/search', true], 28 | ['*://google.com/search', 'https://google.com/search', true], 29 | ['*://google.com/search', 'file://google.com/search', false], 30 | ['*://google.com/search', 'ftp://google.com/search', false], 31 | ['*://google.com/search', 'urn://google.com/search', false], 32 | ])('should parse "%s", when "%s" is checked, return %s', (pattern, url, exepcted) => { 33 | expect(new MatchPattern(pattern).includes(url)).toBe(exepcted); 34 | }); 35 | }); 36 | 37 | describe.each(['http', 'https'])('%s protocol', protocol => { 38 | it.each([ 39 | [`${protocol}://google.com/*`, `${protocol}://google.com/search1`, true], 40 | [`${protocol}://google.com/*`, `${protocol}://google.com/search2`, true], 41 | [`${protocol}://google.com/*`, `${protocol}://www.google.com/search`, false], 42 | [`${protocol}://*.google.com/search`, `${protocol}://google.com/search`, true], 43 | [`${protocol}://*.google.com/search`, `${protocol}://www.google.com/search`, true], 44 | [`${protocol}://*.google.com/search`, `${protocol}://images.google.com/search`, true], 45 | ])('should parse "%s", when "%s" is checked, return %s', (pattern, url, exepcted) => { 46 | expect(new MatchPattern(pattern).includes(url)).toBe(exepcted); 47 | }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/windows.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { fakeBrowser } from '../index'; 3 | 4 | describe('windows', () => { 5 | beforeEach(fakeBrowser.reset); 6 | 7 | describe('remove', () => { 8 | it('should trigger onRemoved event when removing a window', async () => { 9 | const listener = vi.fn(); 10 | fakeBrowser.windows.onRemoved.addListener(listener); 11 | 12 | const newWindow = await fakeBrowser.windows.create(); 13 | await fakeBrowser.windows.remove(newWindow.id!); 14 | 15 | expect(listener).toHaveBeenCalledWith(newWindow.id); 16 | }); 17 | 18 | it('should remove window from windowList', async () => { 19 | const window1 = await fakeBrowser.windows.create(); 20 | const window2 = await fakeBrowser.windows.create(); 21 | 22 | const allWindowsBefore = await fakeBrowser.windows.getAll(); 23 | expect(allWindowsBefore).toHaveLength(3); // default + 2 new 24 | 25 | await fakeBrowser.windows.remove(window1.id!); 26 | 27 | const allWindowsAfter = await fakeBrowser.windows.getAll(); 28 | expect(allWindowsAfter).toHaveLength(2); 29 | expect(allWindowsAfter.find(w => w.id === window1.id)).toBeUndefined(); 30 | }); 31 | }); 32 | 33 | describe('create', () => { 34 | it('should trigger onCreated event', async () => { 35 | const listener = vi.fn(); 36 | fakeBrowser.windows.onCreated.addListener(listener); 37 | 38 | const newWindow = await fakeBrowser.windows.create(); 39 | 40 | expect(listener).toHaveBeenCalledWith( 41 | expect.objectContaining({ 42 | id: newWindow.id, 43 | focused: false, 44 | }), 45 | ); 46 | }); 47 | }); 48 | 49 | describe('tabs and windows interaction', () => { 50 | it('should remove window when last tab is removed', async () => { 51 | const windowListener = vi.fn(); 52 | fakeBrowser.windows.onRemoved.addListener(windowListener); 53 | 54 | const window = await fakeBrowser.windows.create(); 55 | const tab = await fakeBrowser.tabs.create({ windowId: window.id }); 56 | 57 | // Remove the only tab in the window 58 | await fakeBrowser.tabs.remove(tab.id!); 59 | 60 | // Window should be removed automatically 61 | expect(windowListener).toHaveBeenCalledWith(window.id); 62 | 63 | const allWindows = await fakeBrowser.windows.getAll(); 64 | expect(allWindows.find(w => w.id === window.id)).toBeUndefined(); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/messaging/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webext-core/messaging", 3 | "version": "2.3.0", 4 | "description": "Light weight, type-safe wrapper around the web extension messaging APIs. Supports all browsers (Chrome, Firefox, Safari)", 5 | "license": "MIT", 6 | "keywords": [ 7 | "web-extension", 8 | "browser-extension", 9 | "chrome-extension", 10 | "webext", 11 | "web-ext", 12 | "chrome", 13 | "firefox", 14 | "safari", 15 | "browser", 16 | "extension", 17 | "message", 18 | "messaging" 19 | ], 20 | "homepage": "https://github.com/aklinker1/webext-core/tree/main/packages/messaging", 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/aklinker1/webext-core.git", 24 | "directory": "packages/messaging" 25 | }, 26 | "type": "module", 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "files": [ 31 | "lib" 32 | ], 33 | "main": "./lib/index.cjs", 34 | "module": "./lib/index.js", 35 | "types": "./lib/index.d.ts", 36 | "exports": { 37 | ".": { 38 | "import": { 39 | "types": "./lib/index.d.ts", 40 | "default": "./lib/index.js" 41 | }, 42 | "require": { 43 | "types": "./lib/index.d.ts", 44 | "default": "./lib/index.cjs" 45 | } 46 | }, 47 | "./page": { 48 | "import": { 49 | "types": "./lib/page.d.ts", 50 | "default": "./lib/page.js" 51 | }, 52 | "require": { 53 | "types": "./lib/page.d.ts", 54 | "default": "./lib/page.cjs" 55 | } 56 | } 57 | }, 58 | "scripts": { 59 | "build": "buildc -- tsup src/index.ts src/page.ts --clean --out-dir lib --dts --format esm,cjs,iife --global-name webExtCoreMessaging", 60 | "test": "buildc --deps-only -- bun run test:node && bun run test:browser", 61 | "test:node": "buildc --deps-only -- vitest -r src --config ../vitest.config.node.ts", 62 | "test:browser": "buildc --deps-only -- vitest -r src --config ../vitest.config.browser.ts", 63 | "test:coverage": "buildc --deps-only -- bun run test:node --coverage && bun run test:browser --coverage", 64 | "check": "buildc --deps-only -- tsc --noEmit" 65 | }, 66 | "dependencies": { 67 | "serialize-error": "^11.0.0", 68 | "uid": "^2.0.2", 69 | "webextension-polyfill": "^0.10.0" 70 | }, 71 | "devDependencies": { 72 | "@types/webextension-polyfill": "^0.9.1", 73 | "@webext-core/fake-browser": "workspace:*", 74 | "publint": "^0.2.11", 75 | "tsconfig": "workspace:*" 76 | }, 77 | "buildc": { 78 | "outDir": "lib" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /docs/content/isolated-element/0.installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} 6 | 7 | ## Overview 8 | 9 | `@webext-core/isolated-element` uses the [`ShadowRoot` API](https://developer.mozilla.org/en-US/docs/Web/API/Element/shadowRoot) 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. 10 | 11 | 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! 12 | 13 | ## Installation 14 | 15 | ###### NPM 16 | 17 | ```bash 18 | pnpm i @webext-core/isolated-element 19 | ``` 20 | 21 | ```ts 22 | import { createIsolatedElement } from '@webext-core/isolated-element'; 23 | ``` 24 | 25 | ###### CDN 26 | 27 | ```bash 28 | curl -o isolated-element.js https://cdn.jsdelivr.net/npm/@webext-core/isolated-element/lib/index.global.js 29 | ``` 30 | 31 | ```html 32 | 33 | 36 | ``` 37 | 38 | ## Usage 39 | 40 | `createIsolatedElement` returns two elements: 41 | 42 | - `parentElement` needs to be added to the DOM where you want your UI to show up. 43 | - `isolatedElement` is where you should mount your UI. 44 | 45 | Here, we're creating the UI using vanilla JS. 46 | 47 | ```ts 48 | // content-script.ts 49 | import { createIsolatedElement } from '@webext-core/isolated-element'; 50 | import browser from 'webextension-polyfill'; 51 | 52 | const { parentElement, isolatedElement } = await createIsolatedElement({ 53 | name: 'some-name', 54 | css: { 55 | url: browser.runtime.getURL('/path/to/styles.css'), 56 | }, 57 | isolateEvents: true, // or array of event names to isolate, e.g., ['click', 'keydown'] 58 | }); 59 | 60 | // Mount our UI inside the isolated element 61 | const ui = document.createElement('div'); 62 | ui.textContent = 'Isolated text'; 63 | isolatedElement.appendChild(ui); 64 | 65 | // Add the UI to the DOM 66 | document.body.append(parentElement); 67 | ``` 68 | 69 | Here's a couple of other ways to mount your UI inside the `isolatedElement`: 70 | 71 | ### Vue 72 | 73 | ```ts 74 | import { createApp } from 'vue'; 75 | import App from './App.vue'; 76 | 77 | createApp(App).mount(isolatedElement); 78 | ``` 79 | 80 | ### React 81 | 82 | ```ts 83 | import ReactDOM from 'react-dom'; 84 | import App from './App.tsx'; 85 | 86 | ReactDOM.createRoot(isolatedElement).render(); 87 | ``` 88 | -------------------------------------------------------------------------------- /docs/content/isolated-element/api.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | --- 4 | description: "" 5 | --- 6 | 7 | ::alert 8 | 9 | See [`@webext-core/isolated-element`](/isolated-element/installation/) 10 | 11 | :: 12 | 13 | ## `createIsolatedElement` 14 | 15 | ```ts 16 | async function createIsolatedElement( 17 | options: CreateIsolatedElementOptions, 18 | ): Promise<{ 19 | parentElement: HTMLElement; 20 | isolatedElement: HTMLElement; 21 | shadow: ShadowRoot; 22 | }> { 23 | // ... 24 | } 25 | ``` 26 | 27 | Create an HTML element that has isolated styles from the rest of the page. 28 | 29 | ### Parameters 30 | 31 | - ***`options: CreateIsolatedElementOptions`*** 32 | 33 | ### Returns 34 | 35 | - A `parentElement` that can be added to the DOM 36 | - The `shadow` root 37 | - An `isolatedElement` that you should mount your UI to. 38 | 39 | ### Examples 40 | 41 | ```ts 42 | const { isolatedElement, parentElement } = createIsolatedElement({ 43 | name: 'example-ui', 44 | css: { textContent: "p { color: red }" }, 45 | isolateEvents: true // or ['keydown', 'keyup', 'keypress'] 46 | }); 47 | 48 | // Create and mount your app inside the isolation 49 | const ui = document.createElement("p"); 50 | ui.textContent = "Example UI"; 51 | isolatedElement.appendChild(ui); 52 | 53 | // Add the UI to the DOM 54 | document.body.appendChild(parentElement); 55 | ``` 56 | 57 | ## `CreateIsolatedElementOptions` 58 | 59 | ```ts 60 | interface CreateIsolatedElementOptions { 61 | name: string; 62 | mode?: "open" | "closed"; 63 | css?: { url: string } | { textContent: string }; 64 | isolateEvents?: boolean | string[]; 65 | } 66 | ``` 67 | 68 | Options that can be passed into `createIsolatedElement`. 69 | 70 | ### Properties 71 | 72 | - ***`name: string`***
A unique HTML tag name (two words, kebab case - [see spec](https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name)) used when defining the web component used internally. Don't use the same name twice for different UIs. 73 | 74 | - ***`mode?: 'open' | 'closed'`*** (default: `'closed'`)
See [`ShadowRoot.mode`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/mode). 75 | 76 | - ***`css?: { url: string } | { textContent: string }`***
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. 77 | 78 | - ***`isolateEvents?: boolean | string[]`***
When enabled, `event.stopPropagation` will be called on events trying to bubble out of the shadow root. 79 | 80 | - Set to `true` to stop the propagation of a default set of events, `["keyup", "keydown", "keypress"]` 81 | - Set to an array of event names to stop the propagation of a custom list of events 82 | 83 |

84 | 85 | --- 86 | 87 | _API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts)_ -------------------------------------------------------------------------------- /packages/fake-browser/scripts/generate-base.ts: -------------------------------------------------------------------------------- 1 | import { printNode, Project, Symbol, ts, Type, TypeAliasDeclaration } from 'ts-morph'; 2 | import { fileURLToPath } from 'url'; 3 | import { dirname, resolve } from 'path'; 4 | import { writeJsdoc } from './code-writer'; 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)); 7 | const outputPath = resolve(__dirname, '..', 'src', 'base.gen.ts'); 8 | const project = new Project(); 9 | const w = project.createWriter(); 10 | writeJsdoc(w, 'DO NOT EDIT. THIS IS A GENERATED FILE.\n\n```bash\npnpm gen\n```'); 11 | 12 | const TS_FIELDS_WITH_ERRORS = ['Browser.declarativeContent.ShowAction']; 13 | 14 | function writeToFile() { 15 | project.getFileSystem().writeFileSync(outputPath, w.toString()); 16 | } 17 | 18 | const src = project.createSourceFile( 19 | 'webextension-polyfill.ts', 20 | ` 21 | import type { Browser } from 'webextension-polyfill'; 22 | type BrowserToGenerate = Browser; 23 | `, 24 | ); 25 | 26 | const Browser = src.getTypeAlias('BrowserToGenerate')?.getType(); 27 | 28 | function generateType(parents: string[], name: string, type: Type | undefined) { 29 | if (type == null) return; 30 | const propertyChain = [...parents, name].join('.'); 31 | 32 | if (type.getCallSignatures().length > 0) { 33 | // Functions need mocked 34 | w.write(`() => `).block(() => { 35 | w.writeLine( 36 | `throw Error(\`${propertyChain} not implemented.\n\nMock the function yourself using your testing framework, or submit a PR with an in-memory implementation.\`)`, 37 | ); 38 | }); 39 | } else if (type.isAnonymous()) { 40 | // Anonymous classes need mocked 41 | w.write(`class ${name} {}`); 42 | } else if (type.isClass()) { 43 | // Classes need mocked 44 | w.write(`class ${name} {}`); 45 | } else if (type.isObject()) { 46 | // Interfaces have properties that need generated 47 | w.inlineBlock(() => { 48 | type.getProperties().forEach(prop => { 49 | const nextPropertyChain = [propertyChain, prop.getName()].join('.'); 50 | if (TS_FIELDS_WITH_ERRORS.includes(nextPropertyChain)) { 51 | w.writeLine('// @ts-expect-error: Generated type is known to be wrong'); 52 | } 53 | w.write(`${prop.getName()}: `); 54 | generateType([...parents, name], prop.getName(), prop.getValueDeclaration()?.getType()); 55 | w.write(',').newLineIfLastNot(); 56 | }); 57 | }); 58 | } else if (type.isLiteral()) { 59 | w.write(type.getText()); 60 | } else if (type.isString()) { 61 | w.write(`""`); 62 | } else if (type.isBoolean()) { 63 | w.write(`false`); 64 | } else if (type.isNumber()) { 65 | w.write(`0`); 66 | } else { 67 | console.warn(`[${propertyChain}] Unknown type: ${type.getText()}`); 68 | } 69 | } 70 | 71 | w.write(` 72 | import type { Browser } from 'webextension-polyfill'; 73 | 74 | export const GeneratedBrowser: Browser = `); 75 | 76 | generateType([], 'Browser', Browser); 77 | 78 | writeToFile(); 79 | -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | seo: 3 | title: Web extension development made easy 4 | description: A collection of easy-to-use utilities for writing and testing web extensions that work on all browsers. 5 | --- 6 | 7 | ::u-page-hero 8 | #title 9 | Web extension development made easy 10 | 11 | #description 12 | A collection of easy-to-use utilities for writing and testing web extensions that work on all browsers. 13 | 14 | #links 15 | :::u-button 16 | --- 17 | color: neutral 18 | size: xl 19 | to: /get-started/introduction 20 | trailing-icon: i-lucide-arrow-right 21 | --- 22 | Get started 23 | ::: 24 | 25 | :::u-button 26 | --- 27 | color: neutral 28 | icon: simple-icons-github 29 | size: xl 30 | to: https://github.com/aklinker1/webext-core 31 | variant: outline 32 | --- 33 | Star on GitHub 34 | ::: 35 | :: 36 | 37 | ::u-page-section 38 | #title 39 | Packages 40 | 41 | #features 42 | :::u-page-feature 43 | --- 44 | icon: i-noto-optical-disk 45 | --- 46 | #title 47 | `@webext-core/storage` 48 | 49 | #description 50 | An alternative, type-safe API similar to local storage for accessing extension storage. 51 | 52 | [Go to docs →](/storage/installation) 53 | ::: 54 | 55 | :::u-page-feature 56 | --- 57 | icon: i-noto-left-speech-bubble 58 | --- 59 | #title 60 | `@webext-core/messaging` 61 | 62 | #description 63 | A simpler, type-safe API for sending and receiving messages. 64 | 65 | [Go to docs →](/messaging/installation) 66 | ::: 67 | 68 | :::u-page-feature 69 | --- 70 | icon: i-noto-construction-worker 71 | --- 72 | #title 73 | `@webext-core/job-scheduler` 74 | 75 | #description 76 | Easily schedule and manage reoccurring jobs. 77 | 78 | [Go to docs →](/job-scheduler/installation) 79 | ::: 80 | 81 | :::u-page-feature 82 | --- 83 | icon: i-noto-thumbs-up 84 | --- 85 | #title 86 | `@webext-core/match-patterns` 87 | 88 | #description 89 | Utilities for working with match patterns. 90 | 91 | [Go to docs →](/match-patterns/installation) 92 | ::: 93 | 94 | :::u-page-feature 95 | --- 96 | icon: i-noto-oncoming-bus 97 | --- 98 | #title 99 | `@webext-core/proxy-service` 100 | 101 | #description 102 | Call a function, but execute in a different JS context, like the background. 103 | 104 | [Go to docs →](/proxy-service/installation) 105 | ::: 106 | 107 | :::u-page-feature 108 | --- 109 | icon: i-noto-puzzle-piece 110 | --- 111 | #title 112 | `@webext-core/isolated-element` 113 | 114 | #description 115 | Create a container who's styles are isolated from the page's styles. 116 | 117 | [Go to docs →](/isolated-element/installation) 118 | ::: 119 | 120 | :::u-page-feature 121 | --- 122 | icon: i-noto-rocket 123 | --- 124 | #title 125 | `@webext-core/fake-browser` 126 | 127 | #description 128 | An in-memory implementation of webextension-polyfill for testing. 129 | 130 | [Go to docs →](/fake-browser/installation) 131 | ::: 132 | :: 133 | -------------------------------------------------------------------------------- /docs/content/storage/1.typescript.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | ## Adding Type Safety 6 | 7 | If your project uses TypeScript, you can make your own type-safe storage by passing a schema into the first type argument of `defineExtensionStorage`. 8 | 9 | ```ts 10 | import { defineExtensionStorage } from '@webext-core/storage'; 11 | import browser from 'webextension-polyfill'; 12 | 13 | export interface ExtensionStorageSchema { 14 | installDate: number; 15 | notificationsEnabled: boolean; 16 | favoriteUrls: string[]; 17 | } 18 | 19 | export const extensionStorage = defineExtensionStorage( 20 | browser.storage.local, 21 | ); 22 | ``` 23 | 24 | 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: 25 | 26 | ```ts 27 | extensionStorage.getItem('unknownKey'); 28 | // ~~~~~~~~~~~~ Error: 'unknownKey' does not match `keyof LocalExtStorageSchema` 29 | 30 | const installDate: Date = await extensionStorage.getItem('installDate'); 31 | // ~~~~~~~~~~~~~~~~~ Error: value of type 'number' cannot be assigned to type 'Date' 32 | 33 | await extensionStorage.setItem('favoriteUrls', 'not-an-array'); 34 | // ~~~~~~~~~~~~~~ Error: type 'string' is not assignable to 'string[]' 35 | ``` 36 | 37 | When used correctly, types will be automatically inferred without having to specify the type anywhere: 38 | 39 | ```ts 40 | const installDate /*: number | null */ = await extensionStorage.getItem('installDate'); 41 | await extensionStorage.setItem('installDate', 123); 42 | 43 | const notificationsEnabled /*: boolean | null */ = 44 | await extensionStorage.getItem('notificationsEnabled'); 45 | 46 | const favorites /*: string[] | null */ = await extensionStorage.getItem('favoriteUrls'); 47 | favorites ??= []; 48 | favorites.push('https://github.com'); 49 | await localExtSTorage.setItem('favoriteUrls', favorites); 50 | ``` 51 | 52 | ## Handling `null` Correctly 53 | 54 | When using a schema, you'll notice that `getItem` returns `T | null`, but `setItem` requires a non-null value. 55 | 56 | 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. 57 | 58 | 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`. 59 | 60 | ```diff 61 | export interface LocalExtStorageSchema { 62 | installDate: number; 63 | + notificationsEnabled: boolean; 64 | - notificationsEnabled: boolean | null; 65 | favoriteUrls: string[]; 66 | } 67 | ``` 68 | 69 | ### Never Use `undefined` 70 | 71 | 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. 72 | 73 | ```diff 74 | export interface LocalExtStorageSchema { 75 | - key1?: number; 76 | - key2: string | undefined; 77 | + key1: number | null; 78 | + key2: string | null; 79 | } 80 | ``` 81 | -------------------------------------------------------------------------------- /docs/content/storage/api.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | --- 4 | description: "" 5 | --- 6 | 7 | ::alert 8 | 9 | See [`@webext-core/storage`](/storage/installation/) 10 | 11 | :: 12 | 13 | ## `defineExtensionStorage` 14 | 15 | ```ts 16 | function defineExtensionStorage( 17 | storage: Storage.StorageArea, 18 | ): ExtensionStorage { 19 | // ... 20 | } 21 | ``` 22 | 23 | Create a storage instance with an optional schema, `TSchema`, for type safety. 24 | 25 | ### Parameters 26 | 27 | - ***`storage: Storage.StorageArea`***
The storage to to use. Either `Browser.storage.local`, `Browser.storage.sync`, or `Browser.storage.managed`. 28 | 29 | ### Examples 30 | 31 | ```ts 32 | import browser from 'webextension-polyfill'; 33 | 34 | interface Schema { 35 | installDate: number; 36 | } 37 | const extensionStorage = defineExtensionStorage(browser.storage.local); 38 | 39 | const date = await extensionStorage.getItem("installDate"); 40 | ``` 41 | 42 | ## `ExtensionStorage` 43 | 44 | ```ts 45 | interface ExtensionStorage { 46 | clear(): Promise; 47 | getItem( 48 | key: TKey, 49 | ): Promise[TKey] | null>; 50 | setItem( 51 | key: TKey, 52 | value: TSchema[TKey], 53 | ): Promise; 54 | removeItem(key: TKey): Promise; 55 | onChange( 56 | key: TKey, 57 | cb: OnChangeCallback, 58 | ): RemoveListenerCallback; 59 | } 60 | ``` 61 | 62 | This is the interface for the storage objects exported from the package. It is similar to `localStorage`, except for a few differences: 63 | 64 | - ***It's async*** since the web extension storage APIs are async. 65 | - It can store any data type, ***not just strings***. 66 | 67 | ## `localExtStorage` 68 | 69 | ```ts 70 | const localExtStorage: ExtensionStorage; 71 | ``` 72 | 73 | An implementation of `ExtensionStorage` based on the `browser.storage.local` storage area. 74 | 75 | ## `managedExtStorage` 76 | 77 | ```ts 78 | const managedExtStorage: ExtensionStorage; 79 | ``` 80 | 81 | An implementation of `ExtensionStorage` based on the `browser.storage.managed` storage area. 82 | 83 | ## `sessionExtStorage` 84 | 85 | ```ts 86 | const sessionExtStorage: ExtensionStorage; 87 | ``` 88 | 89 | An implementation of `ExtensionStorage` based on the `browser.storage.local` storage area. 90 | 91 | - Added to Chrome 102 as of May 24th, 2022. 92 | - Added to Safari 16.4 as of March 27th, 2023. 93 | - Added to Firefox 115 as of July 4th, 2023. 94 | 95 | ## `syncExtStorage` 96 | 97 | ```ts 98 | const syncExtStorage: ExtensionStorage; 99 | ``` 100 | 101 | An implementation of `ExtensionStorage` based on the `browser.storage.sync` storage area. 102 | 103 |

104 | 105 | --- 106 | 107 | _API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts)_ -------------------------------------------------------------------------------- /packages/messaging/src/extension.ts: -------------------------------------------------------------------------------- 1 | import Browser, { Runtime } from 'webextension-polyfill'; 2 | import { GenericMessenger, defineGenericMessanging } from './generic'; 3 | import { BaseMessagingConfig } from './types'; 4 | 5 | /** 6 | * Configuration passed into `defineExtensionMessaging`. 7 | */ 8 | export interface ExtensionMessagingConfig extends BaseMessagingConfig {} 9 | 10 | /** 11 | * Additional fields available on the `Message` from an `ExtensionMessenger`. 12 | */ 13 | export interface ExtensionMessage { 14 | /** 15 | * Information about where the message came from. See 16 | * [`Runtime.MessageSender`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/MessageSender). 17 | */ 18 | sender: Runtime.MessageSender; 19 | } 20 | 21 | /** 22 | * Options for sending a message to a specific tab/frame 23 | */ 24 | export interface SendMessageOptions { 25 | /** 26 | * The tab to send a message to 27 | */ 28 | tabId: number; 29 | /** 30 | * The frame to send a message to. 0 represents the main frame. 31 | */ 32 | frameId?: number; 33 | } 34 | 35 | /** 36 | * Send message accepts either: 37 | * - No arguments to send to background 38 | * - A tabId number to send to a specific tab 39 | * - A SendMessageOptions object to target a specific tab and frame 40 | * 41 | * You cannot message between tabs directly. It must go through the background script. 42 | */ 43 | export type ExtensionSendMessageArgs = [arg?: number | SendMessageOptions]; 44 | 45 | /** 46 | * Messenger returned by `defineExtensionMessaging`. 47 | */ 48 | export type ExtensionMessenger> = GenericMessenger< 49 | TProtocolMap, 50 | ExtensionMessage, 51 | ExtensionSendMessageArgs 52 | >; 53 | 54 | /** 55 | * Returns an `ExtensionMessenger` that is backed by the `browser.runtime.sendMessage` and 56 | * `browser.tabs.sendMessage` APIs. 57 | * 58 | * It can be used to send messages to and from the background page/service worker. 59 | */ 60 | export function defineExtensionMessaging< 61 | TProtocolMap extends Record = Record, 62 | >(config?: ExtensionMessagingConfig): ExtensionMessenger { 63 | return defineGenericMessanging({ 64 | ...config, 65 | sendMessage(message, arg) { 66 | // No args - send to background 67 | if (arg == null) { 68 | return Browser.runtime.sendMessage(message); 69 | } 70 | 71 | // Handle both number and options object 72 | const options: SendMessageOptions = typeof arg === 'number' ? { tabId: arg } : arg; 73 | 74 | return Browser.tabs.sendMessage( 75 | options.tabId, 76 | message, 77 | // Pass frameId if specified 78 | options.frameId != null ? { frameId: options.frameId } : undefined, 79 | ); 80 | }, 81 | addRootListener(processMessage) { 82 | const listener = (message: any, sender: Runtime.MessageSender) => { 83 | if (typeof message === 'object') return processMessage({ ...message, sender }); 84 | else return processMessage(message); 85 | }; 86 | 87 | Browser.runtime.onMessage.addListener(listener); 88 | return () => Browser.runtime.onMessage.removeListener(listener); 89 | }, 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /packages/storage/src/defineExtensionStorage.ts: -------------------------------------------------------------------------------- 1 | import { AnySchema, ExtensionStorage, OnChangeCallback } from './types'; 2 | import browser, { Storage } from 'webextension-polyfill'; 3 | 4 | interface RegisteredChangeListener { 5 | key: keyof TSchema; 6 | cb: OnChangeCallback; 7 | } 8 | 9 | /** 10 | * Create a storage instance with an optional schema, `TSchema`, for type safety. 11 | * 12 | * @param storage The storage to to use. Either `Browser.storage.local`, `Browser.storage.sync`, or `Browser.storage.managed`. 13 | * 14 | * @example 15 | * import browser from 'webextension-polyfill'; 16 | * 17 | * interface Schema { 18 | * installDate: number; 19 | * } 20 | * const extensionStorage = defineExtensionStorage(browser.storage.local); 21 | * 22 | * const date = await extensionStorage.getItem("installDate"); 23 | */ 24 | export function defineExtensionStorage( 25 | storage: Storage.StorageArea, 26 | ): ExtensionStorage { 27 | /** 28 | * The singleton callback added and removed from the `browser.storage.onChanged` event. It calls 29 | * all the listeners added to this storage instance. 30 | */ 31 | const onStorageChanged = async (changes: browser.Storage.StorageAreaOnChangedChangesType) => { 32 | const work = listeners.map(({ key, cb }) => { 33 | if (!(key in changes)) return; 34 | 35 | const { newValue, oldValue } = changes[ 36 | key as string 37 | ] as browser.Storage.StorageAreaOnChangedChangesType; 38 | if (newValue === oldValue) return; 39 | 40 | return cb(newValue, oldValue); 41 | }); 42 | 43 | await Promise.all(work); 44 | }; 45 | 46 | let listeners: RegisteredChangeListener[] = []; 47 | 48 | /** 49 | * Add the listener to the list of listeners, but also create the singleton listener if this is 50 | * the first listener. 51 | */ 52 | function addListener(listener: RegisteredChangeListener) { 53 | if (listeners.length === 0) { 54 | storage.onChanged.addListener(onStorageChanged); 55 | } 56 | 57 | listeners.push(listener); 58 | } 59 | 60 | /** 61 | * Remove the listener from the list, but also unset the singleton listener if no listeners are 62 | * active. 63 | */ 64 | function removeListener(listener: RegisteredChangeListener) { 65 | const i = listeners.indexOf(listener); 66 | if (i >= 0) listeners.splice(i, 1); 67 | 68 | if (listeners.length === 0) { 69 | browser.storage.onChanged.removeListener(onStorageChanged); 70 | } 71 | } 72 | 73 | return { 74 | clear() { 75 | return storage.clear(); 76 | }, 77 | getItem(key) { 78 | return storage.get(key as string).then(res => res[key as string] ?? null); 79 | }, 80 | setItem(key, value) { 81 | return storage.set({ [key]: value ?? null }); 82 | }, 83 | removeItem(key) { 84 | return storage.remove(key as string); 85 | }, 86 | onChange(key, cb) { 87 | const listener: RegisteredChangeListener = { 88 | key, 89 | // @ts-expect-error: We don't need this type to fit internally. 90 | cb, 91 | }; 92 | addListener(listener); 93 | 94 | return () => removeListener(listener); 95 | }, 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /.github/workflows/publish-packages.yml: -------------------------------------------------------------------------------- 1 | name: Publish Packages 2 | on: [workflow_dispatch] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | validate: 9 | uses: ./.github/workflows/validate.yml 10 | 11 | publish: 12 | name: Publish 13 | needs: [validate] 14 | permissions: 15 | contents: write # Push version changes 16 | id-token: write # OIDC for NPM publishing 17 | strategy: 18 | max-parallel: 1 19 | matrix: 20 | package: 21 | - fake-browser 22 | - messaging 23 | - storage 24 | - proxy-service 25 | - isolated-element 26 | - job-scheduler 27 | - match-patterns 28 | runs-on: ubuntu-22.04 29 | steps: 30 | - name: Checkout Repo 31 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 32 | with: 33 | ref: ${{ github.ref }} 34 | fetch-depth: 0 35 | 36 | - name: Pull Latest Releases 37 | run: git pull 38 | 39 | - uses: actions/setup-node@v3 40 | 41 | - uses: ./.github/actions/setup 42 | 43 | - id: changelog 44 | name: Generate changelog 45 | uses: aklinker1/generate-changelog/.github/actions/generate-changelog@main 46 | with: 47 | module: ${{ matrix.package }} 48 | scopes: ${{ matrix.package }} 49 | changeTemplate: '- {{ message }} ({{ commit.hash }})' 50 | 51 | - name: Bump Version 52 | if: ${{ steps.changelog.outputs.skipped == 'false' }} 53 | run: | 54 | node -p -e " 55 | const pkg = JSON.parse(\`$(cat package.json)\`); 56 | pkg.version = '${{ steps.changelog.outputs.nextVersion }}'; 57 | JSON.stringify(pkg, null, 2); 58 | " > package.json 59 | echo "Updated package.json:" 60 | cat package.json 61 | git config --global user.email "changelog.action@github.com" 62 | git config --global user.name "Changelog Action" 63 | git add package.json 64 | git commit -m "chore(release): ${{ matrix.package }}-v${{ steps.changelog.outputs.nextVersion }}" 65 | git push 66 | working-directory: packages/${{ matrix.package }} 67 | 68 | - name: Create Tag 69 | if: ${{ steps.changelog.outputs.skipped == 'false' }} 70 | run: | 71 | git tag "${{ matrix.package }}-v${{ steps.changelog.outputs.nextVersion }}" 72 | git push --tags 73 | 74 | - id: create_release 75 | name: Create GitHub Release 76 | uses: actions/create-release@v1 77 | if: ${{ steps.changelog.outputs.skipped == 'false' }} 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.github_token }} 80 | with: 81 | tag_name: ${{ matrix.package }}-v${{ steps.changelog.outputs.nextVersion }} 82 | release_name: '@webext-core/${{ matrix.package }} v${{ steps.changelog.outputs.nextVersion }}' 83 | body: ${{ steps.changelog.outputs.changelog }} 84 | 85 | - name: Publish to NPM 86 | if: ${{ steps.changelog.outputs.skipped == 'false' }} 87 | run: | 88 | sudo npm i -g npm@latest 89 | bun run build 90 | bun pm pack 91 | /usr/local/bin/npm publish *.tgz 92 | working-directory: packages/${{ matrix.package }} 93 | -------------------------------------------------------------------------------- /docs/content/proxy-service/api.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | --- 4 | description: "" 5 | --- 6 | 7 | ::alert 8 | 9 | See [`@webext-core/proxy-service`](/proxy-service/installation/) 10 | 11 | :: 12 | 13 | ## `DeepAsync` 14 | 15 | ```ts 16 | type DeepAsync = TService extends (...args: any) => any 17 | ? ToAsyncFunction 18 | : TService extends { [key: string]: any } 19 | ? { 20 | [fn in keyof TService]: DeepAsync; 21 | } 22 | : never; 23 | ``` 24 | 25 | A recursive type that deeply converts all methods in `TService` to be async. 26 | 27 | ## `defineProxyService` 28 | 29 | ```ts 30 | function defineProxyService( 31 | name: string, 32 | init: (...args: TArgs) => TService, 33 | config?: ProxyServiceConfig, 34 | ): [ 35 | registerService: (...args: TArgs) => TService, 36 | getService: () => ProxyService, 37 | ] { 38 | // ... 39 | } 40 | ``` 41 | 42 | Utility for creating a service whose functions are executed in the background script regardless 43 | of the JS context the they are called from. 44 | 45 | ### Parameters 46 | 47 | - ***`name: string`***
A unique name for the service. Used to identify which service is being executed. 48 | 49 | - ***`init: (...args: TArgs) => TService`***
A function that returns your real service implementation. If args are listed, 50 | `registerService` will require the same set of arguments. 51 | 52 | - ***`config?: ProxyServiceConfig`*** 53 | 54 | ### Returns 55 | 56 | - `registerService`: Used to register your service in the background 57 | - `getService`: Used to get an instance of the service anywhere in the extension. 58 | 59 | ## `flattenPromise` 60 | 61 | ```ts 62 | function flattenPromise(promise: Promise): DeepAsync { 63 | // ... 64 | } 65 | ``` 66 | 67 | Given a promise of a variable, return a proxy to that awaits the promise internally so you don't 68 | have to call `await` twice. 69 | 70 | > This can be used to simplify handling `Promise` passed in your services. 71 | 72 | ### Examples 73 | 74 | ```ts 75 | function createService(dependencyPromise: Promise) { 76 | const dependency = flattenPromise(dependencyPromise); 77 | 78 | return { 79 | doSomething() { 80 | await dependency.someAsyncWork(); 81 | // Instead of `await (await dependencyPromise).someAsyncWork();` 82 | } 83 | } 84 | } 85 | ``` 86 | 87 | ## `ProxyService` 88 | 89 | ```ts 90 | type ProxyService = 91 | TService extends DeepAsync ? TService : DeepAsync; 92 | ``` 93 | 94 | A type that ensures a service has only async methods. 95 | - ***If all methods are async***, it returns the original type. 96 | - ***If the service has non-async methods***, it returns a `DeepAsync` of the service. 97 | 98 | ## `ProxyServiceConfig` 99 | 100 | ```ts 101 | interface ProxyServiceConfig extends ExtensionMessagingConfig {} 102 | ``` 103 | 104 | Configure a proxy service's behavior. It uses `@webext-core/messaging` internally, so any 105 | config from `ExtensionMessagingConfig` can be passed as well. 106 | 107 |

108 | 109 | --- 110 | 111 | _API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts)_ -------------------------------------------------------------------------------- /packages/proxy-service/src/defineProxyService.test.ts: -------------------------------------------------------------------------------- 1 | import { defineProxyService } from './defineProxyService'; 2 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import { isBackground } from './isBackground'; 4 | import { fakeBrowser } from '@webext-core/fake-browser'; 5 | 6 | vi.mock('webextension-polyfill'); 7 | 8 | vi.mock('./isBackground', () => ({ 9 | isBackground: vi.fn(), 10 | })); 11 | const isBackgroundMock = vi.mocked(isBackground); 12 | 13 | const defineTestService = () => 14 | defineProxyService('TestService', (version: number) => ({ 15 | getVersion: () => version, 16 | getNextVersion: () => Promise.resolve(version + 1), 17 | })); 18 | 19 | describe('defineProxyService', () => { 20 | beforeEach(() => { 21 | vi.resetAllMocks(); 22 | fakeBrowser.reset(); 23 | }); 24 | 25 | it("getService should fail to get the service in the background if one hasn't been registered", () => { 26 | const [_, getTestService] = defineTestService(); 27 | isBackgroundMock.mockReturnValue(true); 28 | 29 | expect(getTestService).toThrowError(); 30 | }); 31 | 32 | it('getService should return a proxy in other contexts', () => { 33 | const [registerTestService, getTestService] = defineTestService(); 34 | registerTestService(1); 35 | isBackgroundMock.mockReturnValue(false); 36 | 37 | // @ts-expect-error: __proxy is not apart of the type, but it's there 38 | expect(getTestService().__proxy).toEqual(true); 39 | }); 40 | 41 | it('should defer execution of the proxy service methods to the real service methods', async () => { 42 | const version = 10; 43 | const [registerTestService, getTestService] = defineTestService(); 44 | registerTestService(version); 45 | 46 | isBackgroundMock.mockReturnValue(true); 47 | const real = getTestService(); 48 | isBackgroundMock.mockReturnValue(false); 49 | const proxy = getTestService(); 50 | const realGetVersionSpy = vi.spyOn(real, 'getVersion'); 51 | 52 | const actual = await proxy.getVersion(); 53 | 54 | expect(actual).toEqual(version); 55 | expect(realGetVersionSpy).toBeCalledTimes(1); 56 | }); 57 | 58 | it('should support executing functions directly', async () => { 59 | const expected = 5; 60 | const fn: () => Promise = vi.fn().mockResolvedValue(expected); 61 | const [registerFn, getFn] = defineProxyService('fn', () => fn); 62 | registerFn(); 63 | 64 | isBackgroundMock.mockReturnValue(false); 65 | const proxyFn = getFn(); 66 | 67 | const actual = await proxyFn(); 68 | 69 | expect(actual).toBe(expected); 70 | expect(fn).toBeCalledTimes(1); 71 | }); 72 | 73 | it('should support executing deeply nested functions at multiple depths', async () => { 74 | const expected1 = 5; 75 | const expected2 = 6; 76 | const fn1 = vi.fn<() => Promise>().mockResolvedValue(expected1); 77 | const fn2 = vi.fn<() => Promise>().mockResolvedValue(expected2); 78 | const [registerDeepObject, getDeepObject] = defineProxyService('DeepObject', () => ({ 79 | fn1, 80 | path: { 81 | to: { 82 | fn2, 83 | }, 84 | }, 85 | })); 86 | registerDeepObject(); 87 | 88 | isBackgroundMock.mockReturnValue(false); 89 | const deepObject = getDeepObject(); 90 | 91 | await expect(deepObject.fn1()).resolves.toBe(expected1); 92 | expect(fn1).toBeCalledTimes(1); 93 | await expect(deepObject.path.to.fn2()).resolves.toBe(expected2); 94 | expect(fn2).toBeCalledTimes(1); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /packages/proxy-service/src/defineProxyService.ts: -------------------------------------------------------------------------------- 1 | import { isBackground } from './isBackground'; 2 | import { ProxyService, ProxyServiceConfig, Service } from './types'; 3 | import { defineExtensionMessaging, ProtocolWithReturn } from '@webext-core/messaging'; 4 | import get from 'get-value'; 5 | 6 | /** 7 | * Utility for creating a service whose functions are executed in the background script regardless 8 | * of the JS context the they are called from. 9 | * 10 | * @param name A unique name for the service. Used to identify which service is being executed. 11 | * @param init A function that returns your real service implementation. If args are listed, 12 | * `registerService` will require the same set of arguments. 13 | * @param config 14 | * @returns 15 | * - `registerService`: Used to register your service in the background 16 | * - `getService`: Used to get an instance of the service anywhere in the extension. 17 | */ 18 | export function defineProxyService( 19 | name: string, 20 | init: (...args: TArgs) => TService, 21 | config?: ProxyServiceConfig, 22 | ): [registerService: (...args: TArgs) => TService, getService: () => ProxyService] { 23 | let service: TService | undefined; 24 | 25 | const messageKey = `proxy-service.${name}`; 26 | const { onMessage, sendMessage } = defineExtensionMessaging<{ 27 | [key: string]: ProtocolWithReturn<{ path?: string; args: any[] }, any>; 28 | }>(config); 29 | 30 | /** 31 | * Create and returns a "deep" proxy. Every property that is accessed returns another proxy, and 32 | * when a function is called at any depth (0 to infinity), a message is sent to the background. 33 | */ 34 | function createProxy(path?: string): ProxyService { 35 | const wrapped = (() => {}) as ProxyService; 36 | const proxy = new Proxy(wrapped, { 37 | // Executed when the object is called as a function 38 | async apply(_target, _thisArg, args) { 39 | const res = await sendMessage(messageKey, { 40 | path, 41 | args: args, 42 | }); 43 | return res; 44 | }, 45 | 46 | // Executed when accessing a property on an object 47 | get(target, propertyName, receiver) { 48 | if (propertyName === '__proxy' || typeof propertyName === 'symbol') { 49 | return Reflect.get(target, propertyName, receiver); 50 | } 51 | return createProxy(path == null ? propertyName : `${path}.${propertyName}`); 52 | }, 53 | }); 54 | // @ts-expect-error: Adding a hidden property 55 | proxy.__proxy = true; 56 | return proxy; 57 | } 58 | 59 | return [ 60 | function registerService(...args) { 61 | service = init(...args); 62 | onMessage(messageKey, ({ data }) => { 63 | const method = data.path == null ? service : get(service ?? {}, data.path); 64 | if (method) return Promise.resolve(method.bind(service)(...data.args)); 65 | }); 66 | return service; 67 | }, 68 | 69 | function getService() { 70 | // Create proxy for non-background 71 | if (!isBackground()) return createProxy(); 72 | 73 | // Register the service if it hasn't been registered yet 74 | if (service == null) { 75 | throw Error( 76 | `Failed to get an instance of ${name}: in background, but registerService has not been called. Did you forget to call registerService?`, 77 | ); 78 | } 79 | return service as ProxyService; 80 | }, 81 | ]; 82 | } 83 | -------------------------------------------------------------------------------- /docs/content/0.get-started/0.introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Use packages from NPM or download them from a CDN. 3 | --- 4 | 5 | ## Overview 6 | 7 | All of `@webext-core`'s packages are provided via NPM. Depending on your project's setup, you can consume them in 2 different ways: 8 | 9 | 1. If your project uses a bundler or framework (like Vite, Webpack, WXT, or Plasmo), see [Bundler Setup](#bundler-setup). 10 | 2. If your project does not use a bundler, see [Non-bundler Setup](#non-bundler-setup) 11 | 12 | ## Bundler Setup 13 | 14 | If you haven't setup a bundler yet, I recommend using [WXT](https://wxt.dev/) for the best DX and to support all browsers. 15 | 16 | ```bash 17 | pnpm dlx wxt@latest init 18 | ``` 19 | 20 | Install any of the packages and use them normally. Everything will just work :+1: 21 | 22 | ```bash 23 | pnpm i @webext-core/storage 24 | ``` 25 | 26 | ```ts 27 | import { localExtStorage } from '@webext-core/storage'; 28 | 29 | const value = await localExtStorage.getItem('some-key'); 30 | ``` 31 | 32 | ## Non-bundler Setup 33 | 34 | If you're not using a bundler, you'll have to download each package and put it inside your project. 35 | 36 | ::note 37 | **Why download them?** 38 |
39 |
40 | 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), considering it "remotely hosted code" and a security risk. So you will need to download each package and ship them with your extension. 41 |
42 |
43 | If you're not on MV3 yet, you could use the CDN, but it's still recommended to download it so it loads faster. 44 | :: 45 | 46 | All of `@webext-core` NPM packages include a minified, `lib/index.global.js` file that will create a global variable you can use to access the package's APIs. 47 | 48 | Lets say you've put all your third-party JS files inside a `vendor/` directory, and want to install the `@webext-core/storage` package. 49 | 50 | ``` 51 | . 52 | ├─ vendor 53 | │ └─ jquery.min.js 54 | └─ manifest.json 55 | ``` 56 | 57 | You can download the package like so: 58 | 59 | ```bash 60 | mkdir -p vendor/webext-core 61 | curl -o vendor/webext-core/storage.js https://cdn.jsdelivr.net/npm/@webext-core/storage/lib/index.global.js 62 | ``` 63 | 64 | You project should now look like this: 65 | 66 | ``` 67 | . 68 | ├─ vendor 69 | │ ├─ jquery.min.js 70 | │ └─ webext-core 71 | │ └─ storage.js 72 | └─ manifest.json 73 | ``` 74 | 75 | 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`. 76 | 77 | ###### HTML Files 78 | 79 | ```html 80 | 81 | 82 | 87 | 88 | ``` 89 | 90 | ###### Content Scripts 91 | 92 | ```json 93 | "content_scripts": [{ 94 | "matches": [...], 95 | "js": ["vendor/webext-core/storage.js", "your-content-script.js"] 96 | }] 97 | ``` 98 | 99 | ###### MV2 Background 100 | 101 | ```json 102 | "background": { 103 | "scripts": ["vendor/webext-core/storage.js", "your-background-script.js"] 104 | } 105 | ``` 106 | 107 | ###### MV3 Background 108 | 109 | For MV3 background scripts, you need to use a bundler since `background.service_worker` only accepts a single script. 110 | -------------------------------------------------------------------------------- /packages/messaging/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interface used to log text to the console when sending and receiving messages. 3 | */ 4 | export interface Logger { 5 | debug(...args: any[]): void; 6 | log(...args: any[]): void; 7 | warn(...args: any[]): void; 8 | error(...args: any[]): void; 9 | } 10 | 11 | /** 12 | * Either a Promise of a type, or that type directly. Used to indicate that a method can by sync or 13 | * async. 14 | */ 15 | export type MaybePromise = Promise | T; 16 | 17 | /** 18 | * Used to add a return type to a message in the protocol map. 19 | * 20 | * > Internally, this is just an object with random keys for the data and return types. 21 | * 22 | * @deprecated Use the function syntax instead: 23 | * 24 | * @example 25 | * interface ProtocolMap { 26 | * // data is a string, returns undefined 27 | * type1: string; 28 | * // data is a string, returns a number 29 | * type2: ProtocolWithReturn; 30 | * } 31 | */ 32 | export interface ProtocolWithReturn { 33 | /** 34 | * Stores the data type. Randomly named so that it isn't accidentally implemented. 35 | */ 36 | BtVgCTPYZu: TData; 37 | /** 38 | * Stores the return type. Randomly named so that it isn't accidentally implemented. 39 | */ 40 | RrhVseLgZW: TReturn; 41 | } 42 | 43 | /** 44 | * Given a function declaration, `ProtocolWithReturn`, or a value, return the message's data type. 45 | */ 46 | export type GetDataType = T extends (...args: infer Args) => any 47 | ? Args['length'] extends 0 | 1 48 | ? Args[0] 49 | : never 50 | : T extends ProtocolWithReturn 51 | ? T['BtVgCTPYZu'] 52 | : T; 53 | 54 | /** 55 | * Given a function declaration, `ProtocolWithReturn`, or a value, return the message's return type. 56 | */ 57 | export type GetReturnType = T extends (...args: any[]) => infer R 58 | ? R 59 | : T extends ProtocolWithReturn 60 | ? T['RrhVseLgZW'] 61 | : void; 62 | 63 | /** 64 | * Call to ensure an active listener has been removed. 65 | * 66 | * If the listener has already been removed with `Messenger.removeAllListeners`, this is a noop. 67 | */ 68 | export type RemoveListenerCallback = () => void; 69 | 70 | /** 71 | * Shared configuration between all the different messengers. 72 | */ 73 | export interface BaseMessagingConfig { 74 | /** 75 | * The logger to use when logging messages. Set to `null` to disable logging. 76 | * 77 | * @default console 78 | */ 79 | logger?: Logger; 80 | 81 | /** 82 | * Whether to break an error when an invalid message is received. 83 | * 84 | * @default undefined 85 | */ 86 | breakError?: boolean; 87 | } 88 | 89 | export interface NamespaceMessagingConfig extends BaseMessagingConfig { 90 | /** 91 | * A string used to ensure the messenger only sends messages to and listens for messages from 92 | * other messengers of the same type, with the same namespace. 93 | */ 94 | namespace: string; 95 | } 96 | 97 | /** 98 | * Contains information about the message received. 99 | */ 100 | export interface Message< 101 | TProtocolMap extends Record, 102 | TType extends keyof TProtocolMap, 103 | > { 104 | /** 105 | * A semi-unique, auto-incrementing number used to trace messages being sent. 106 | */ 107 | id: number; 108 | /** 109 | * The data that was passed into `sendMessage` 110 | */ 111 | data: GetDataType; 112 | type: TType; 113 | /** 114 | * The timestamp the message was sent in MS since epoch. 115 | */ 116 | timestamp: number; 117 | } 118 | -------------------------------------------------------------------------------- /packages/isolated-element/src/index.ts: -------------------------------------------------------------------------------- 1 | import isPotentialCustomElementName from 'is-potential-custom-element-name'; 2 | import { CreateIsolatedElementOptions } from './options'; 3 | 4 | /** 5 | * Built-in elements that can have a shadow root attached to them. 6 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#elements_you_can_attach_a_shadow_to 7 | */ 8 | const ALLOWED_SHADOW_ELEMENTS = [ 9 | 'article', 10 | 'aside', 11 | 'blockquote', 12 | 'body', 13 | 'div', 14 | 'footer', 15 | 'h1', 16 | 'h2', 17 | 'h3', 18 | 'h4', 19 | 'h5', 20 | 'h6', 21 | 'header', 22 | 'main', 23 | 'nav', 24 | 'p', 25 | 'section', 26 | 'span', 27 | ]; 28 | 29 | export type { CreateIsolatedElementOptions }; 30 | 31 | /** 32 | * Create an HTML element that has isolated styles from the rest of the page. 33 | * @param options 34 | * @returns 35 | * - A `parentElement` that can be added to the DOM 36 | * - The `shadow` root 37 | * - An `isolatedElement` that you should mount your UI to. 38 | * 39 | * @example 40 | * const { isolatedElement, parentElement } = createIsolatedElement({ 41 | * name: 'example-ui', 42 | * css: { textContent: "p { color: red }" }, 43 | * isolateEvents: true // or ['keydown', 'keyup', 'keypress'] 44 | * }); 45 | * 46 | * // Create and mount your app inside the isolation 47 | * const ui = document.createElement("p"); 48 | * ui.textContent = "Example UI"; 49 | * isolatedElement.appendChild(ui); 50 | * 51 | * // Add the UI to the DOM 52 | * document.body.appendChild(parentElement); 53 | */ 54 | export async function createIsolatedElement(options: CreateIsolatedElementOptions): Promise<{ 55 | parentElement: HTMLElement; 56 | isolatedElement: HTMLElement; 57 | shadow: ShadowRoot; 58 | }> { 59 | const { name, mode = 'closed', css, isolateEvents = false } = options; 60 | 61 | if (!ALLOWED_SHADOW_ELEMENTS.includes(name) && !isPotentialCustomElementName(name)) { 62 | throw Error( 63 | `"${name}" cannot have a shadow root attached to it. It must be two words and kebab-case, with a few exceptions. See https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#elements_you_can_attach_a_shadow_to`, 64 | ); 65 | } 66 | 67 | // Create the root, parent element 68 | const parentElement = document.createElement(name); 69 | 70 | // Create the shadow and isolated nodes 71 | const shadow = parentElement.attachShadow({ mode }); 72 | const isolatedElement = document.createElement('html'); 73 | const body = document.createElement('body'); 74 | const head = document.createElement('head'); 75 | 76 | // Load the UI's stylesheet 77 | if (css) { 78 | const style = document.createElement('style'); 79 | if ('url' in css) { 80 | style.textContent = await fetch(css.url).then(res => res.text()); 81 | } else { 82 | style.textContent = css.textContent; 83 | } 84 | head.appendChild(style); 85 | } 86 | 87 | // Add head and body to html element 88 | isolatedElement.appendChild(head); 89 | isolatedElement.appendChild(body); 90 | 91 | // Add the isolated element to the shadow so it shows up once the parentElement is mounted 92 | shadow.appendChild(isolatedElement); 93 | 94 | // Add logic to prevent event bubbling if isolateEvents is true or a list of events 95 | if (isolateEvents) { 96 | const eventTypes = Array.isArray(isolateEvents) 97 | ? isolateEvents 98 | : ['keydown', 'keyup', 'keypress']; 99 | eventTypes.forEach(eventType => { 100 | body.addEventListener(eventType, e => e.stopPropagation()); 101 | }); 102 | } 103 | 104 | return { 105 | parentElement, 106 | shadow, 107 | isolatedElement: body, 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /docs/content/proxy-service/1.defining-services.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | There are several different ways to define a proxy service. 6 | 7 | ## Class 8 | 9 | Define a class whose methods are available in other JS contexts: 10 | 11 | ```ts 12 | import { openDB, IDBPDatabase } from 'idb'; 13 | import { defineProxyService } from '@webext-core/proxy-service'; 14 | 15 | class TodosRepo { 16 | constructor(private db: Promise) {} 17 | 18 | async getAll(): Promise { 19 | return (await this.db).getAll('todos'); 20 | } 21 | } 22 | 23 | export const [registerTodosRepo, getTodosRepo] = defineProxyService( 24 | 'TodosRepo', 25 | (idb: Promise) => new TodosRepo(idb), 26 | ); 27 | ``` 28 | 29 | ```ts 30 | // Register 31 | const db = openDB('todos'); 32 | registerTodosRepo(db); 33 | ``` 34 | 35 | ```ts 36 | // Get an instance 37 | const todosRepo = getTodosRepo(); 38 | const todos = await todosRepo.getAll(); 39 | ``` 40 | 41 | ## Object 42 | 43 | Objects can be used as services as well. All functions defined on the object are available in other contexts. 44 | 45 | ```ts 46 | import { openDB, IDBPDatabase } from 'idb'; 47 | import { defineProxyService } from '@webext-core/proxy-service'; 48 | 49 | export const [registerTodosRepo, getTodosRepo] = defineProxyService( 50 | 'TodosRepo', 51 | (db: Promise) => ({ 52 | async getAll(): Promise { 53 | return (await this.db).getAll('todos'); 54 | }, 55 | }), 56 | ); 57 | ``` 58 | 59 | ```ts 60 | // Register 61 | const db = openDB('todos'); 62 | registerTodosRepo(db); 63 | ``` 64 | 65 | ```ts 66 | // Get an instance 67 | const todosRepo = getTodosRepo(); 68 | const todos = await todosRepo.getAll(); 69 | ``` 70 | 71 | ## Function 72 | 73 | If you only need to define a single function, you can! 74 | 75 | ```ts 76 | import { openDB, IDBPDatabase } from 'idb'; 77 | import { defineProxyService } from '@webext-core/proxy-service'; 78 | 79 | export const [registerGetAllTodos, getGetAllTodos] = defineProxyService( 80 | 'TodosRepo', 81 | (db: Promise) => 82 | function getAllTodos() { 83 | return (await this.db).getAll('todos'); 84 | }, 85 | ); 86 | ``` 87 | 88 | ```ts 89 | // Register 90 | const db = openDB('todos'); 91 | registerGetAllTodos(db); 92 | ``` 93 | 94 | ```ts 95 | // Get an instance 96 | const getAllTodos = getGetAllTodos(); 97 | const todos = await getAllTodos(); 98 | ``` 99 | 100 | ## Nested Objects 101 | 102 | 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. 103 | 104 | ```ts 105 | import { openDB, IDBPDatabase } from 'idb'; 106 | import { defineProxyService } from '@webext-core/proxy-service'; 107 | 108 | class TodosRepo { 109 | constructor(private db: Promise) {} 110 | 111 | async getAll(): Promise { 112 | return (await this.db).getAll('todos'); 113 | } 114 | } 115 | 116 | const createAuthorsRepo = (db: Promise) => ({ 117 | async getOne(id: string): Promise { 118 | return (await this.db).getAll('authors', id); 119 | }, 120 | }); 121 | 122 | function createApi(db: Promise) { 123 | return { 124 | todos: new TodosRepo(db), 125 | authors: createAuthorsRepo(db), 126 | }; 127 | } 128 | 129 | export const [registerApi, getApi] = defineProxyService('Api', createApi); 130 | ``` 131 | 132 | ```ts 133 | // Register 134 | const db = openDB('todos'); 135 | registerApi(db); 136 | ``` 137 | 138 | ```ts 139 | // Get an instance 140 | const api = getApi(); 141 | const todos = await api.todos.getAll(); 142 | const firstAuthor = await api.authors.getOne(todos.authorId); 143 | ``` 144 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/alarms.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { Alarms } from 'webextension-polyfill'; 3 | import { fakeBrowser } from '..'; 4 | 5 | const now = Date.now(); 6 | vi.setSystemTime(now); 7 | 8 | describe('Fake Alarms API', () => { 9 | beforeEach(fakeBrowser.reset); 10 | 11 | it('should allow creating an unnamed alarm', async () => { 12 | fakeBrowser.alarms.create(undefined, { 13 | delayInMinutes: 1, 14 | periodInMinutes: 5, 15 | }); 16 | const alarm = await fakeBrowser.alarms.get(); 17 | 18 | expect(alarm).toEqual({ 19 | name: '', 20 | periodInMinutes: 5, 21 | scheduledTime: now + 60e3, 22 | }); 23 | }); 24 | 25 | it('should allow creating an unnamed alarm using a single parameter', async () => { 26 | fakeBrowser.alarms.create({}); 27 | const alarm = await fakeBrowser.alarms.get(); 28 | 29 | expect(alarm).toEqual({ 30 | name: '', 31 | scheduledTime: now, 32 | }); 33 | }); 34 | 35 | it('should replace an existing alarm with the same name', async () => { 36 | const name = '1'; 37 | fakeBrowser.alarms.create(name, { when: 1 }); 38 | fakeBrowser.alarms.create(name, { when: 2 }); 39 | 40 | const alarm = await fakeBrowser.alarms.get(name); 41 | 42 | expect(alarm).toEqual({ 43 | name, 44 | scheduledTime: 2, 45 | }); 46 | }); 47 | 48 | it('should allow creating a named alarm', async () => { 49 | const name = 'test'; 50 | fakeBrowser.alarms.create(name, { 51 | delayInMinutes: 2, 52 | periodInMinutes: 10, 53 | }); 54 | const alarm = await fakeBrowser.alarms.get(name); 55 | 56 | expect(alarm).toEqual({ 57 | name, 58 | periodInMinutes: 10, 59 | scheduledTime: now + 2 * 60e3, 60 | }); 61 | }); 62 | 63 | it('should return all created alarms', async () => { 64 | fakeBrowser.alarms.create('1', {}); 65 | fakeBrowser.alarms.create('2', {}); 66 | 67 | const actual = await fakeBrowser.alarms.getAll(); 68 | 69 | expect(actual).toHaveLength(2); 70 | }); 71 | 72 | it('should remove the specified alarm', async () => { 73 | fakeBrowser.alarms.create(undefined, {}); 74 | fakeBrowser.alarms.create('1', {}); 75 | fakeBrowser.alarms.create('2', {}); 76 | const expected = [{ name: '2', scheduledTime: now }]; 77 | 78 | await fakeBrowser.alarms.clear(); 79 | await fakeBrowser.alarms.clear('1'); 80 | await fakeBrowser.alarms.clear('1'); 81 | 82 | const actual = await fakeBrowser.alarms.getAll(); 83 | 84 | expect(actual).toEqual(expected); 85 | }); 86 | 87 | it('should remove all alarms', async () => { 88 | fakeBrowser.alarms.create(undefined, {}); 89 | fakeBrowser.alarms.create('1', {}); 90 | fakeBrowser.alarms.create('2', {}); 91 | 92 | await fakeBrowser.alarms.clearAll(); 93 | 94 | const actual = await fakeBrowser.alarms.getAll(); 95 | 96 | expect(actual).toHaveLength(0); 97 | }); 98 | 99 | it('should call active onAlarm listeners when the event is triggered', async () => { 100 | const listener1 = vi.fn(); 101 | const listener2 = vi.fn(); 102 | const listener3 = vi.fn(); 103 | const alarm: Alarms.Alarm = { 104 | name: 'test', 105 | scheduledTime: now + 1000, 106 | }; 107 | 108 | fakeBrowser.alarms.onAlarm.addListener(listener1); 109 | fakeBrowser.alarms.onAlarm.addListener(listener2); 110 | fakeBrowser.alarms.onAlarm.addListener(listener3); 111 | 112 | fakeBrowser.alarms.onAlarm.removeListener(listener2); 113 | await fakeBrowser.alarms.onAlarm.trigger(alarm); 114 | 115 | expect(listener1).toBeCalledTimes(1); 116 | expect(listener2).not.toBeCalled(); 117 | expect(listener3).toBeCalledTimes(1); 118 | 119 | expect(listener1).toBeCalledWith(alarm); 120 | expect(listener3).toBeCalledWith(alarm); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/notifications.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from 'vitest'; 2 | import { Notifications } from 'webextension-polyfill'; 3 | import { fakeBrowser } from '..'; 4 | 5 | describe('Fake Notifications API', () => { 6 | beforeEach(fakeBrowser.reset); 7 | 8 | describe('create', () => { 9 | it('should create a notification and return the ID', async () => { 10 | const id = await fakeBrowser.notifications.create({ type: 'basic', message: '', title: '' }); 11 | expect(id).toBeDefined(); 12 | }); 13 | 14 | it('should create a notification and return the provided ID', async () => { 15 | const expected = 'some-id'; 16 | const actual = await fakeBrowser.notifications.create(expected, { 17 | message: '', 18 | title: '', 19 | type: 'basic', 20 | }); 21 | 22 | expect(actual).toBe(expected); 23 | }); 24 | 25 | it('should replace an existing notification with the same id', async () => { 26 | const id = 'another-id'; 27 | const originalNotification: Notifications.CreateNotificationOptions = { 28 | type: 'basic', 29 | title: 'original', 30 | message: 'original', 31 | }; 32 | const newNotification: Notifications.CreateNotificationOptions = { 33 | type: 'basic', 34 | title: 'original', 35 | message: 'original', 36 | }; 37 | 38 | await fakeBrowser.notifications.create(id, originalNotification); 39 | await fakeBrowser.notifications.create(id, newNotification); 40 | 41 | await expect(fakeBrowser.notifications.getAll()).resolves.toEqual({ 42 | [id]: newNotification, 43 | }); 44 | }); 45 | }); 46 | 47 | describe('getAll', () => { 48 | it('should return notifications created by create', async () => { 49 | const notification1: Notifications.CreateNotificationOptions = { 50 | type: 'basic', 51 | title: 'title 1', 52 | message: 'message 1', 53 | }; 54 | const notification2: Notifications.CreateNotificationOptions = { 55 | type: 'list', 56 | title: 'title 2', 57 | message: 'message 2', 58 | items: [], 59 | }; 60 | const expected = { 61 | '1': notification1, 62 | '2': notification2, 63 | }; 64 | 65 | await fakeBrowser.notifications.create('1', notification1); 66 | await fakeBrowser.notifications.create('2', notification2); 67 | 68 | await expect(fakeBrowser.notifications.getAll()).resolves.toEqual(expected); 69 | }); 70 | }); 71 | 72 | describe('clear', () => { 73 | it('should remove an existing notification and return true', async () => { 74 | const id = 'id2'; 75 | const notification: Notifications.CreateNotificationOptions = { 76 | type: 'basic', 77 | title: 'title 1', 78 | message: 'message 1', 79 | }; 80 | 81 | await fakeBrowser.notifications.create(id, notification); 82 | await expect(fakeBrowser.notifications.getAll()).resolves.toEqual({ [id]: notification }); 83 | 84 | const actual = await fakeBrowser.notifications.clear(id); 85 | 86 | await expect(fakeBrowser.notifications.getAll()).resolves.toEqual({}); 87 | expect(actual).toBe(true); 88 | }); 89 | 90 | it('should do nothing and return false when the notification does not exist', async () => { 91 | const id = 'id2'; 92 | const notification: Notifications.CreateNotificationOptions = { 93 | type: 'basic', 94 | title: 'title 1', 95 | message: 'message 1', 96 | }; 97 | 98 | await fakeBrowser.notifications.create(id, notification); 99 | await expect(fakeBrowser.notifications.getAll()).resolves.toEqual({ [id]: notification }); 100 | 101 | const actual = await fakeBrowser.notifications.clear('not' + id); 102 | 103 | await expect(fakeBrowser.notifications.getAll()).resolves.toEqual({ [id]: notification }); 104 | expect(actual).toBe(false); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/windows.ts: -------------------------------------------------------------------------------- 1 | import { Windows } from 'webextension-polyfill'; 2 | import { BrowserOverrides, FakeBrowser } from '../types'; 3 | import { defineEventWithTrigger } from '../utils/defineEventWithTrigger'; 4 | import { mapTab, tabList } from './tabs'; 5 | 6 | type InMemoryWindow = Omit; 7 | 8 | const onCreated = defineEventWithTrigger<(window: Windows.Window) => void>(); 9 | const onRemoved = defineEventWithTrigger<(windowId: number) => void>(); 10 | const onFocusChanged = defineEventWithTrigger<(windowId: number) => void>(); 11 | 12 | export const DEFAULT_WINDOW: InMemoryWindow = { 13 | id: 0, 14 | alwaysOnTop: false, 15 | incognito: false, 16 | }; 17 | const DEFAULT_NEXT_WINDOW_ID = 1; 18 | 19 | export const windowList: InMemoryWindow[] = [DEFAULT_WINDOW]; 20 | export let focusedWindowId: Windows.Window['id']; 21 | export let lastFocusedWindowId: Windows.Window['id']; 22 | let nextWindowId = DEFAULT_NEXT_WINDOW_ID; 23 | 24 | function setFocusedWindowId(id: Windows.Window['id']): void { 25 | lastFocusedWindowId = focusedWindowId; 26 | focusedWindowId = id; 27 | } 28 | function getNextWindowId(): Windows.Window['id'] { 29 | const id = nextWindowId; 30 | nextWindowId++; 31 | return id; 32 | } 33 | 34 | function mapWindow(window: InMemoryWindow, getInfo?: Windows.GetInfo): Windows.Window { 35 | return { 36 | ...window, 37 | tabs: getInfo?.populate 38 | ? tabList.filter(tab => tab.windowId === window.id).map(mapTab) 39 | : undefined, 40 | focused: window.id === focusedWindowId, 41 | }; 42 | } 43 | 44 | function mapCreateType(type: Windows.CreateType | undefined): Windows.WindowType | undefined { 45 | if (type == null) return undefined; 46 | if (type == 'detached_panel') return 'panel'; 47 | return type; 48 | } 49 | 50 | export const windows: BrowserOverrides['windows'] = { 51 | resetState() { 52 | windowList.length = 1; 53 | windowList[0] = DEFAULT_WINDOW; 54 | focusedWindowId = undefined; 55 | lastFocusedWindowId = undefined; 56 | nextWindowId = DEFAULT_NEXT_WINDOW_ID; 57 | onCreated.removeAllListeners(); 58 | onRemoved.removeAllListeners(); 59 | onFocusChanged.removeAllListeners(); 60 | }, 61 | async get(windowId, getInfo?) { 62 | const window = windowList.find(window => window.id === windowId); 63 | if (!window) return undefined!; 64 | return mapWindow(window, getInfo); 65 | }, 66 | getCurrent(getInfo?) { 67 | if (focusedWindowId == null) return undefined!; 68 | return windows.get(focusedWindowId, getInfo); 69 | }, 70 | getLastFocused(getInfo?) { 71 | if (lastFocusedWindowId == null) return undefined!; 72 | return windows.get(lastFocusedWindowId, getInfo); 73 | }, 74 | async getAll(getInfo?) { 75 | return windowList.map(window => mapWindow(window, getInfo)); 76 | }, 77 | async create(createData?) { 78 | const newWindow: InMemoryWindow = { 79 | id: getNextWindowId(), 80 | alwaysOnTop: false, 81 | incognito: createData?.incognito ?? false, 82 | height: createData?.height, 83 | left: createData?.left, 84 | state: createData?.state, 85 | top: createData?.top, 86 | type: mapCreateType(createData?.type), 87 | width: createData?.width, 88 | }; 89 | windowList.push(newWindow); 90 | if (createData?.focused) setFocusedWindowId(newWindow.id); 91 | 92 | const fullWindow = mapWindow(newWindow); 93 | await onCreated.trigger(fullWindow); 94 | if (createData?.focused) onFocusChanged.trigger(fullWindow.id!); 95 | 96 | return fullWindow; 97 | }, 98 | async update(windowId, updateInfo) { 99 | const window = windowList.find(window => window.id === windowId); 100 | // TODO: Verify this behavior 101 | if (!window) return undefined!; 102 | 103 | return mapWindow(window); 104 | }, 105 | async remove(windowId) { 106 | const index = windowList.findIndex(window => window.id === windowId); 107 | if (index < 0) return; 108 | windowList.splice(index, 1); 109 | await onRemoved.trigger(windowId); 110 | }, 111 | onCreated, 112 | onRemoved, 113 | onFocusChanged, 114 | }; 115 | -------------------------------------------------------------------------------- /docs/content/fake-browser/1.testing-frameworks.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | `@webext-core/fake-browser` does not depend on a specific testing framework, it will work with all of them. Setup for only a few of the major testing frameworks is listed below. 6 | 7 | ::alert 8 | Open a PR to add an example for your framework of choice! 9 | :: 10 | 11 | ## Vitest 12 | 13 | To tell Vitest to use `@webext-core/fake-browser` instead of `webextension-polyfill`, you need to setup a global mock: 14 | 15 | ```ts 16 | // /__mocks__/webextension-polyfill.ts 17 | export { fakeBrowser as default } from '@webext-core/fake-browser'; 18 | ``` 19 | 20 | Next, create a global setup file, `vitest.setup.ts`, where we actually tell Vitest to use our mock: 21 | 22 | ```ts 23 | import { vi } from 'vitest'; 24 | 25 | vi.mock('webextension-polyfill'); 26 | ``` 27 | 28 | Finally, update your `vitest.config.ts` file: 29 | 30 | ```ts 31 | import { defineConfig } from 'vitest/config'; 32 | 33 | export default defineConfig({ 34 | test: { 35 | // List setup file 36 | setupFiles: ['vitest.setup.ts'], 37 | 38 | // List ALL dependencies that use `webextension-polyfill` under `server.deps.include`. 39 | // Without this, Vitest can't mock `webextension-polyfill` inside the dependencies, and the 40 | // actual polyfill will be loaded in tests 41 | // 42 | // You can get a list of dependencies using your package manager: 43 | // - npm list webextension-polyfill 44 | // - yarn list webextension-polyfill 45 | // - pnpm why webextension-polyfill 46 | server: { 47 | deps: { 48 | include: ['@webext-core/storage', ...], 49 | }, 50 | }, 51 | }, 52 | }); 53 | ``` 54 | 55 | Then write your tests! 56 | 57 | ```ts 58 | import browser from 'webextension-polyfill'; 59 | import { fakeBrowser } from '@webext-core/fake-browser'; 60 | import { localExtStorage } from '@webext-core/storage'; 61 | import { test, vi } from 'vitest'; 62 | 63 | // Normally, the function being tested would be in a different file 64 | function isXyzEnabled(): Promise { 65 | return localExtStorage.getItem('xyz'); 66 | } 67 | 68 | describe('isXyzEnabled', () => { 69 | beforeEach(() => { 70 | // Reset the in-memory state before every test 71 | fakeBrowser.reset(); 72 | }); 73 | 74 | it('should return true when enabled', async () => { 75 | const expected = true; 76 | // Use either browser or fakeBrowser to setup your test case 77 | await browser.storage.local.set({ xyz: expected }); 78 | 79 | const actual = await isXyzEnabled(); 80 | 81 | expect(actual).toBe(expected); 82 | }); 83 | }); 84 | ``` 85 | 86 | ## Jest 87 | 88 | To tell Jest to use `@webext-core/fake-browser` instead of `webextension-polyfill`, you need to setup a global mock: 89 | 90 | ```ts 91 | // ./__mocks__/webextension-polyfill.js 92 | module.exports = require('@webext-core/fake-browser').default; 93 | ``` 94 | 95 | Next, we'll use the `moduleNameMapper` option to point all imports of `webextension-polyfill` to `./__mocks__/webextension-polyfill.js` instead. 96 | 97 | ```js 98 | // ./jest.config.js 99 | module.exports = { 100 | moduleNameMapper: { 101 | '^webextension-polyfill$': '/__mocks__/webextension-polyfill.js', 102 | }, 103 | }; 104 | ``` 105 | 106 | Then write your tests! 107 | 108 | ```ts 109 | import browser from 'webextension-polyfill'; 110 | import { fakeBrowser } from '@webext-core/fake-browser'; 111 | import { localExtStorage } from '@webext-core/storage'; 112 | 113 | // Normally, the function being tested would be in a different file 114 | function isXyzEnabled(): Promise { 115 | return localExtStorage.getItem('xyz'); 116 | } 117 | 118 | describe('isXyzEnabled', () => { 119 | beforeEach(() => { 120 | // Reset the in-memory state before every test 121 | fakeBrowser.reset(); 122 | }); 123 | 124 | it('should return true when enabled', async () => { 125 | const expected = true; 126 | // Use either browser or fakeBrowser to setup your test case 127 | await browser.storage.local.set({ xyz: expected }); 128 | 129 | const actual = await isXyzEnabled(); 130 | 131 | expect(actual).toBe(expected); 132 | }); 133 | }); 134 | ``` 135 | -------------------------------------------------------------------------------- /docs/content/job-scheduler/api.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | --- 4 | description: "" 5 | --- 6 | 7 | ::alert 8 | 9 | See [`@webext-core/job-scheduler`](/job-scheduler/installation/) 10 | 11 | :: 12 | 13 | ## `CronJob` 14 | 15 | ```ts 16 | interface CronJob extends cron.ParserOptions { 17 | id: string; 18 | type: "cron"; 19 | expression: string; 20 | execute: ExecuteFn; 21 | } 22 | ``` 23 | 24 | A job that is executed based on a CRON expression. Backed by `cron-parser`. 25 | 26 | [`cron.ParserOptions`](https://github.com/harrisiirak/cron-parser#options) includes options like timezone. 27 | 28 | ### Properties 29 | 30 | - ***`id: string`*** 31 | 32 | - ***`type: 'cron'`*** 33 | 34 | - ***`expression: string`***
See `cron-parser`'s [supported expressions](https://github.com/harrisiirak/cron-parser#supported-format) 35 | 36 | - ***`execute: ExecuteFn`*** 37 | 38 | ## `defineJobScheduler` 39 | 40 | ```ts 41 | function defineJobScheduler(options?: JobSchedulerConfig): JobScheduler { 42 | // ... 43 | } 44 | ``` 45 | 46 | > Requires the `alarms` permission. 47 | 48 | Creates a `JobScheduler` backed by the 49 | [alarms API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms). 50 | 51 | ### Parameters 52 | 53 | - ***`options?: JobSchedulerConfig`*** 54 | 55 | ### Returns 56 | 57 | A `JobScheduler` that can be used to schedule and manage jobs. 58 | 59 | ## `ExecuteFn` 60 | 61 | ```ts 62 | type ExecuteFn = () => Promise | any; 63 | ``` 64 | 65 | Function ran when executing the job. Errors are automatically caught and will trigger the 66 | `"error"` event. If a value is returned, the result will be available in the `"success"` event. 67 | 68 | ## `IntervalJob` 69 | 70 | ```ts 71 | interface IntervalJob { 72 | id: string; 73 | type: "interval"; 74 | duration: number; 75 | immediate?: boolean; 76 | execute: ExecuteFn; 77 | } 78 | ``` 79 | 80 | A job that executes on a set interval, starting when the job is scheduled for the first time. 81 | 82 | ### Properties 83 | 84 | - ***`id: string`*** 85 | 86 | - ***`type: 'interval'`*** 87 | 88 | - ***`duration: number`***
Interval in milliseconds. Due to limitations of the alarms API, it must be greater than 1 89 | minute. 90 | 91 | - ***`immediate?: boolean`*** (default: `false`)
Execute the job immediately when it is scheduled for the first time. If `false`, it will 92 | execute for the first time after `duration`. This has no effect when updating an existing job. 93 | 94 | - ***`execute: ExecuteFn`*** 95 | 96 | ## `Job` 97 | 98 | ```ts 99 | type Job = IntervalJob | CronJob | OnceJob; 100 | ``` 101 | 102 | ## `JobScheduler` 103 | 104 | ```ts 105 | interface JobScheduler { 106 | scheduleJob(job: Job): Promise; 107 | removeJob(jobId: string): Promise; 108 | on( 109 | event: "success", 110 | callback: (job: Job, result: any) => void, 111 | ): RemoveListenerFn; 112 | on( 113 | event: "error", 114 | callback: (job: Job, error: unknown) => void, 115 | ): RemoveListenerFn; 116 | } 117 | ``` 118 | 119 | ## `JobSchedulerConfig` 120 | 121 | ```ts 122 | interface JobSchedulerConfig { 123 | logger?: Logger | null; 124 | } 125 | ``` 126 | 127 | Configures how the job scheduler behaves. 128 | 129 | ### Properties 130 | 131 | - ***`logger?: Logger | null`*** (default: `console`)
The logger to use when logging messages. Set to `null` to disable logging. 132 | 133 | ## `Logger` 134 | 135 | ```ts 136 | interface Logger { 137 | debug(...args: any[]): void; 138 | log(...args: any[]): void; 139 | warn(...args: any[]): void; 140 | error(...args: any[]): void; 141 | } 142 | ``` 143 | 144 | Interface used to log text to the console when creating and executing jobs. 145 | 146 | ## `OnceJob` 147 | 148 | ```ts 149 | interface OnceJob { 150 | id: string; 151 | type: "once"; 152 | date: Date | string | number; 153 | execute: ExecuteFn; 154 | } 155 | ``` 156 | 157 | Runs a job once, at a specific date/time. 158 | 159 | ### Properties 160 | 161 | - ***`id: string`*** 162 | 163 | - ***`type: 'once'`*** 164 | 165 | - ***`date: Date | string | number`***
The date to run the job on. 166 | 167 | - ***`execute: ExecuteFn`*** 168 | 169 |

170 | 171 | --- 172 | 173 | _API reference generated by [`docs/generate-api-references.ts`](https://github.com/aklinker1/webext-core/blob/main/docs/generate-api-references.ts)_ -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/runtime.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { Runtime } from 'webextension-polyfill'; 3 | import { fakeBrowser } from '..'; 4 | 5 | describe('Fake Runtime API', () => { 6 | beforeEach(fakeBrowser.reset); 7 | 8 | describe('messaging', () => { 9 | it('should allow sending and receieving messages', async () => { 10 | fakeBrowser.runtime.onMessage.addListener(message => message + 1); 11 | const actual = await fakeBrowser.runtime.sendMessage('', 1); 12 | 13 | expect(actual).toEqual(2); 14 | }); 15 | 16 | it("should return the first responder's response", async () => { 17 | fakeBrowser.runtime.onMessage.addListener(message => message + 1); 18 | fakeBrowser.runtime.onMessage.addListener(message => message + 2); 19 | 20 | const actual = await fakeBrowser.runtime.sendMessage('', 1); 21 | 22 | expect(actual).toEqual(2); 23 | }); 24 | 25 | it('should call all the ', async () => { 26 | const listener1 = vi.fn().mockReturnValue(1); 27 | const listener2 = vi.fn().mockReturnValue(2); 28 | fakeBrowser.runtime.onMessage.addListener(listener1); 29 | fakeBrowser.runtime.onMessage.addListener(listener2); 30 | const sender = {}; 31 | const message = 1; 32 | 33 | const actual = await fakeBrowser.runtime.sendMessage(message); 34 | 35 | expect(actual).toEqual(1); 36 | expect(listener1).toBeCalledTimes(1); 37 | expect(listener1).toBeCalledWith(message, sender); 38 | expect(listener2).toBeCalledTimes(1); 39 | expect(listener2).toBeCalledWith(message, sender); 40 | }); 41 | 42 | it('should throw an error if there are no listeners setup', async () => { 43 | await expect(() => fakeBrowser.runtime.sendMessage('some-message')).rejects.toThrowError( 44 | 'No listeners available', 45 | ); 46 | }); 47 | }); 48 | 49 | it('should trigger onStartup listeners', async () => { 50 | const listener = vi.fn(); 51 | 52 | fakeBrowser.runtime.onStartup.addListener(listener); 53 | await fakeBrowser.runtime.onStartup.trigger(); 54 | 55 | expect(listener).toBeCalledTimes(1); 56 | expect(listener).toBeCalledWith(); 57 | }); 58 | 59 | it('should trigger onSuspend listeners', async () => { 60 | const listener = vi.fn(); 61 | 62 | fakeBrowser.runtime.onSuspend.addListener(listener); 63 | await fakeBrowser.runtime.onSuspend.trigger(); 64 | 65 | expect(listener).toBeCalledTimes(1); 66 | expect(listener).toBeCalledWith(); 67 | }); 68 | 69 | it('should trigger onSuspendCanceled listeners', async () => { 70 | const listener = vi.fn(); 71 | 72 | fakeBrowser.runtime.onSuspendCanceled.addListener(listener); 73 | await fakeBrowser.runtime.onSuspendCanceled.trigger(); 74 | 75 | expect(listener).toBeCalledTimes(1); 76 | expect(listener).toBeCalledWith(); 77 | }); 78 | 79 | it('should trigger onUpdateAvailable listeners', async () => { 80 | const listener = vi.fn(); 81 | const input: Runtime.OnUpdateAvailableDetailsType = { 82 | version: '1.0.2', 83 | }; 84 | 85 | fakeBrowser.runtime.onUpdateAvailable.addListener(listener); 86 | await fakeBrowser.runtime.onUpdateAvailable.trigger(input); 87 | 88 | expect(listener).toBeCalledTimes(1); 89 | expect(listener).toBeCalledWith(input); 90 | }); 91 | 92 | it('should trigger onInstalled listeners', async () => { 93 | const listener = vi.fn(); 94 | const input: Runtime.OnInstalledDetailsType = { 95 | reason: 'browser_update', 96 | temporary: true, 97 | previousVersion: '1.0.1', 98 | }; 99 | 100 | fakeBrowser.runtime.onInstalled.addListener(listener); 101 | await fakeBrowser.runtime.onInstalled.trigger(input); 102 | 103 | expect(listener).toBeCalledTimes(1); 104 | expect(listener).toBeCalledWith(input); 105 | }); 106 | 107 | describe('getURL', () => { 108 | it('should return an extension URL', () => { 109 | expect(fakeBrowser.runtime.getURL('options.html')).toBe( 110 | `chrome-extension://test-extension-id/options.html`, 111 | ); 112 | }); 113 | 114 | it('should return an extension URL, ignoring leading slashes', () => { 115 | expect(fakeBrowser.runtime.getURL('/options.html')).toBe( 116 | `chrome-extension://test-extension-id/options.html`, 117 | ); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /packages/fake-browser/src/apis/storage.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from 'webextension-polyfill'; 2 | import { BrowserOverrides, FakeBrowser } from '../types'; 3 | import { defineEventWithTrigger } from '../utils/defineEventWithTrigger'; 4 | 5 | const globalOnChanged = 6 | defineEventWithTrigger< 7 | (changes: Record, areaName: string) => void 8 | >(); 9 | 10 | type StorageAreaWithTrigger = Storage.StorageArea & { 11 | resetState(): void; 12 | onChanged: { 13 | trigger(changes: Storage.StorageAreaOnChangedChangesType): Promise; 14 | removeAllListeners(): void; 15 | }; 16 | }; 17 | 18 | type StorageArea = 'local' | 'managed' | 'session' | 'sync'; 19 | function defineStorageArea(area: StorageArea): StorageAreaWithTrigger { 20 | const data: Record = {}; 21 | const onChanged = 22 | defineEventWithTrigger<(changes: Storage.StorageAreaOnChangedChangesType) => void>(); 23 | 24 | function getKeyList(keys: string | string[]): string[] { 25 | return Array.isArray(keys) ? keys : [keys]; 26 | } 27 | 28 | return { 29 | resetState() { 30 | onChanged.removeAllListeners(); 31 | for (const key of Object.keys(data)) { 32 | delete data[key]; 33 | } 34 | }, 35 | async clear() { 36 | const changes: Record = {}; 37 | for (const key of Object.keys(data)) { 38 | const oldValue = data[key] ?? null; 39 | const newValue = null; 40 | changes[key] = { oldValue, newValue }; 41 | delete data[key]; 42 | } 43 | await onChanged.trigger(changes); 44 | await globalOnChanged.trigger(changes, area); 45 | }, 46 | async get(keys?) { 47 | if (keys == null) return { ...data }; 48 | const res: Record = {}; 49 | if (typeof keys === 'object' && !Array.isArray(keys)) { 50 | // Return all the keys + the values as the defaults 51 | Object.keys(keys).forEach(key => (res[key] = data[key] ?? keys[key])); 52 | } else { 53 | // return just the keys or null 54 | getKeyList(keys).forEach(key => (res[key] = data[key])); 55 | } 56 | return res; 57 | }, 58 | async remove(keys) { 59 | const changes: Record = {}; 60 | for (const key of getKeyList(keys)) { 61 | const oldValue = data[key] ?? null; 62 | const newValue = null; 63 | changes[key] = { oldValue, newValue }; 64 | delete data[key]; 65 | } 66 | await onChanged.trigger(changes); 67 | await globalOnChanged.trigger(changes, area); 68 | }, 69 | async set(items) { 70 | const changes: Record = {}; 71 | for (const [key, newValue] of Object.entries(JSON.parse(JSON.stringify(items)))) { 72 | // ignore undefined values 73 | if (newValue === undefined) continue; 74 | 75 | const oldValue = data[key] ?? null; 76 | changes[key] = { oldValue, newValue }; 77 | 78 | if (newValue == null) delete data[key]; 79 | else data[key] = newValue; 80 | } 81 | await onChanged.trigger(changes); 82 | await globalOnChanged.trigger(changes, area); 83 | }, 84 | onChanged, 85 | }; 86 | } 87 | 88 | const localStorage = { 89 | ...defineStorageArea('local'), 90 | QUOTA_BYTES: 5242880 as const, 91 | }; 92 | const managedStorage = { 93 | ...defineStorageArea('managed'), 94 | QUOTA_BYTES: 5242880 as const, 95 | }; 96 | const sessionStorage = { 97 | ...defineStorageArea('session'), 98 | QUOTA_BYTES: 10485760 as const, 99 | }; 100 | const syncStorage = { 101 | ...defineStorageArea('sync'), 102 | MAX_ITEMS: 512 as const, 103 | MAX_WRITE_OPERATIONS_PER_HOUR: 1800 as const, 104 | MAX_WRITE_OPERATIONS_PER_MINUTE: 120 as const, 105 | QUOTA_BYTES: 102400 as const, 106 | QUOTA_BYTES_PER_ITEM: 8192 as const, 107 | getBytesInUse: () => { 108 | throw Error('Browser.storage.sync.getBytesInUse not implemented.'); 109 | }, 110 | }; 111 | 112 | export const storage: BrowserOverrides['storage'] = { 113 | resetState() { 114 | localStorage.resetState(); 115 | managedStorage.resetState(); 116 | sessionStorage.resetState(); 117 | syncStorage.resetState(); 118 | globalOnChanged.removeAllListeners(); 119 | }, 120 | local: localStorage, 121 | managed: managedStorage, 122 | session: sessionStorage, 123 | sync: syncStorage, 124 | onChanged: globalOnChanged, 125 | }; 126 | -------------------------------------------------------------------------------- /packages/isolated-element/src/index.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | import { describe, beforeEach, it, expect, vi } from 'vitest'; 5 | import { createIsolatedElement } from './index'; 6 | 7 | describe('createIsolatedElement', () => { 8 | beforeEach(() => { 9 | document.querySelector('body')!.innerHTML = ''; 10 | }); 11 | 12 | it('should not allow invalid custom element names', async () => { 13 | const invalidName = 'test'; 14 | 15 | await expect(createIsolatedElement({ name: invalidName })).rejects.toThrow( 16 | `"${invalidName}" cannot have a shadow root attached to it`, 17 | ); 18 | }); 19 | 20 | it('should not allow certain built-in elements', async () => { 21 | const invalidName = 'a'; 22 | 23 | await expect(createIsolatedElement({ name: invalidName })).rejects.toThrow( 24 | `"${invalidName}" cannot have a shadow root attached to it`, 25 | ); 26 | }); 27 | 28 | it('should allow certain built-in elements', async () => { 29 | const validName = 'div'; 30 | 31 | await expect(createIsolatedElement({ name: validName })).resolves.toBeDefined(); 32 | }); 33 | 34 | it('should insert an app into the UI', async () => { 35 | const text = 'Example'; 36 | const appId = 'app'; 37 | const name = 'test-element'; 38 | 39 | const app = (element: HTMLElement) => { 40 | const p = document.createElement('p'); 41 | p.id = appId; 42 | p.textContent = text; 43 | element.append(p); 44 | }; 45 | const { isolatedElement, parentElement } = await createIsolatedElement({ name }); 46 | app(isolatedElement); 47 | document.body.append(parentElement); 48 | 49 | expect(document.querySelector(name)).toBeDefined(); 50 | expect(document.getElementById('app')).toBeDefined(); 51 | expect(isolatedElement.textContent).toEqual(text); 52 | expect(parentElement.textContent).toEqual(''); 53 | }); 54 | 55 | it('should allow event propagation when isolateEvents is not set', async () => { 56 | const name = 'event-test-element-default'; 57 | 58 | const { isolatedElement, parentElement } = await createIsolatedElement({ name }); 59 | const input = document.createElement('input'); 60 | isolatedElement.append(input); 61 | document.body.append(parentElement); 62 | 63 | const listener = vi.fn(); 64 | document.body.addEventListener('keyup', listener); 65 | 66 | const event = new KeyboardEvent('keyup', { 67 | bubbles: true, 68 | composed: true, 69 | }); 70 | input.dispatchEvent(event); 71 | 72 | expect(listener).toBeCalledTimes(1); 73 | expect(listener).toBeCalledWith(event); 74 | }); 75 | 76 | it('should not allow event propagation when isolateEvents is set to true', async () => { 77 | const name = 'event-test-element-isolated'; 78 | 79 | const { isolatedElement, parentElement } = await createIsolatedElement({ 80 | name, 81 | isolateEvents: true, 82 | }); 83 | const input = document.createElement('input'); 84 | isolatedElement.append(input); 85 | document.body.append(parentElement); 86 | 87 | const listener = vi.fn(); 88 | document.body.addEventListener('keyup', listener); 89 | 90 | const event = new KeyboardEvent('keyup', { 91 | bubbles: true, 92 | composed: true, 93 | }); 94 | input.dispatchEvent(event); 95 | 96 | expect(listener).not.toBeCalled(); 97 | }); 98 | 99 | it('should allow event propagation conditionally when isolateEvents is set to an array of events', async () => { 100 | const name = 'event-test-element-isolated'; 101 | 102 | const { isolatedElement, parentElement } = await createIsolatedElement({ 103 | name, 104 | isolateEvents: ['click'], 105 | }); 106 | const input = document.createElement('input'); 107 | isolatedElement.append(input); 108 | document.body.append(parentElement); 109 | 110 | const clickListener = vi.fn(); 111 | const keyupListener = vi.fn(); 112 | document.body.addEventListener('click', clickListener); 113 | document.body.addEventListener('keyup', keyupListener); 114 | 115 | const clickEvent = new MouseEvent('click', { 116 | bubbles: true, 117 | composed: true, 118 | }); 119 | const keyupEvent = new KeyboardEvent('keyup', { 120 | bubbles: true, 121 | composed: true, 122 | }); 123 | input.dispatchEvent(clickEvent); 124 | input.dispatchEvent(keyupEvent); 125 | 126 | expect(clickListener).not.toBeCalled(); 127 | expect(keyupListener).toBeCalledTimes(1); 128 | expect(keyupListener).toBeCalledWith(keyupEvent); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /docs/content/job-scheduler/0.installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | :badge[MV2]{type="success"} :badge[MV3]{type="success"} :badge[Chrome]{type="success"} :badge[Firefox]{type="success"} :badge[Safari]{type="success"} 6 | 7 | ## Overview 8 | 9 | `@webext-core/job-scheduler` uses the [alarms API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms) to manage different types of reoccurring jobs: 10 | 11 | - One-time jobs 12 | - Jobs that run on an interval 13 | - Cron jobs 14 | 15 | ## Installation 16 | 17 | ###### NPM 18 | 19 | ```bash 20 | pnpm i @webext-core/job-scheduler 21 | ``` 22 | 23 | ```ts 24 | import { defineJobScheduler } from '@webext-core/job-scheduler'; 25 | ``` 26 | 27 | ###### CDN 28 | 29 | ```bash 30 | curl -o job-scheduler.js https://cdn.jsdelivr.net/npm/@webext-core/job-scheduler/lib/index.global.js 31 | ``` 32 | 33 | ```html 34 | 35 | 38 | ``` 39 | 40 | ## Usage 41 | 42 | `defineJobScheduler` should to be executed once in the background. It returns an object that can be used to schedule or remove jobs. 43 | 44 | ::code-group 45 | 46 | ```ts [background.ts] 47 | import { defineJobScheduler } from '@webext-core/job-scheduler'; 48 | 49 | const jobs = defineJobScheduler(); 50 | ``` 51 | 52 | :: 53 | 54 | Once the job scheduler is created, call `scheduleJob`. To see all the options for configuring jobs, see the [API reference](/job-scheduler/api). 55 | 56 | ::code-group 57 | 58 | ```ts [One time] 59 | jobs.scheduleJob({ 60 | id: 'job1', 61 | type: 'once', 62 | date: Date.now() + 1.44e7, // In 4 hours 63 | execute: () => { 64 | console.log('Executed job once'); 65 | }, 66 | }); 67 | ``` 68 | 69 | ```ts [On an interval] 70 | jobs.scheduleJob({ 71 | id: 'job2', 72 | type: 'interval', 73 | interval: DAY, // Runs every 24 hours 74 | execute: () => { 75 | console.log('Executed job on interval'); 76 | }, 77 | }); 78 | ``` 79 | 80 | ```ts [CRON] 81 | jobs.scheduleJob({ 82 | id: 'job3', 83 | type: 'cron', 84 | expression: '0 */2 * * *', // https://crontab.guru/#0_*/2_*_*_* 85 | execute: () => { 86 | console.log('Executed CRON job'); 87 | }, 88 | }); 89 | ``` 90 | 91 | :: 92 | 93 | 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. 94 | 95 | To stop running a job, call `removeJob`. 96 | 97 | ```ts 98 | job.removeJob('some-old-job'); 99 | ``` 100 | 101 | ::warning 102 | 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. 103 | :: 104 | 105 | ## Parameterized Jobs 106 | 107 | You can't pass parameters into each individual job execution, but you can pass dependencies when scheduling a job by using higher-order functions: 108 | 109 | ::code-group 110 | 111 | ```ts [background.ts] 112 | import { someJob } from './someJob.ts'; 113 | 114 | // Create your dependency 115 | const someDependency = new SomeDependency(); 116 | 117 | const jobs = defineJobScheduler(); 118 | jobs.scheduleJob({ 119 | // ... 120 | execute: someJob(someDependency), 121 | }); 122 | ``` 123 | 124 | ```ts [someJob.ts] 125 | function someJob(someDependency: SomeDependency) { 126 | return async () => { 127 | // Use someDependency 128 | }; 129 | } 130 | ``` 131 | 132 | :: 133 | 134 | ## Other JS Contexts 135 | 136 | You should only create one scheduler, and it should be created in the background page/service worker. 137 | 138 | To schedule jobs from a UI or content script, you can use [`@webext-core/proxy-service`](/proxy-service/installation). 139 | 140 | ::code-group 141 | 142 | ```ts [job-scheduler.ts] 143 | import { defineProxyService } from '@webext-core/proxy-service'; 144 | 145 | export const [registerJobScheduler, getJobScheduler] = defineProxyService('JobScheduler', () => 146 | defineJobScheduler(), 147 | ); 148 | ``` 149 | 150 | ```ts [background.ts] 151 | import { registerJobScheduler } from './job-scheduler'; 152 | 153 | const jobs = registerJobScheduler(); 154 | 155 | // Schedule any jobs in the background 156 | jobs.scheduleJob({ 157 | // ... 158 | }); 159 | ``` 160 | 161 | ```ts [content-script.ts] 162 | import { getJobScheduler } from './job-scheduler'; 163 | 164 | // Get a proxy instance and use it to schedule more jobs 165 | const jobs = getJobScheduler(); 166 | jobs.scheduleJob({ 167 | // ... 168 | }); 169 | ``` 170 | 171 | :: 172 | -------------------------------------------------------------------------------- /packages/fake-browser/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Alarms, 3 | Browser, 4 | Notifications, 5 | Runtime, 6 | Storage, 7 | Tabs, 8 | WebNavigation, 9 | Windows, 10 | } from 'webextension-polyfill'; 11 | 12 | interface EventForTesting { 13 | /** 14 | * Trigger all listeners for an event and return all their responses. 15 | */ 16 | trigger(...args: TParams): Promise; 17 | /** 18 | * Remove all listeners for the event. 19 | */ 20 | removeAllListeners(): void; 21 | } 22 | 23 | export interface BrowserOverrides { 24 | /** 25 | * Reset the fake browser. Remove all listeners and clear all in-memort state, like storage, 26 | * windows, and tabs. 27 | * 28 | * This is often called before each test. 29 | */ 30 | reset(): void; 31 | 32 | alarms: Alarms.Static & { 33 | resetState(): void; 34 | onAlarm: EventForTesting<[name: Alarms.Alarm]>; 35 | }; 36 | notifications: Notifications.Static & { 37 | resetState(): void; 38 | onClosed: EventForTesting<[notificationId: string, byUser: boolean]>; 39 | onClicked: EventForTesting<[notificationId: string]>; 40 | onButtonClicked: EventForTesting<[notificationId: string, buttonIndex: number]>; 41 | onShown: EventForTesting<[notificationId: string]>; 42 | }; 43 | runtime: Pick & { 44 | resetState(): void; 45 | onSuspend: EventForTesting<[]>; 46 | onSuspendCanceled: EventForTesting<[]>; 47 | onStartup: EventForTesting<[]>; 48 | onInstalled: EventForTesting<[details: Runtime.OnInstalledDetailsType]>; 49 | onUpdateAvailable: EventForTesting<[details: Runtime.OnUpdateAvailableDetailsType]>; 50 | onMessage: EventForTesting<[message: any, sender: Runtime.MessageSender], void | Promise>; 51 | }; 52 | storage: { 53 | /** 54 | * Remove all listeners and clear in-memory storages. 55 | */ 56 | resetState(): void; 57 | local: { 58 | onChanged: EventForTesting<[changes: Storage.StorageAreaOnChangedChangesType]>; 59 | }; 60 | session: { 61 | onChanged: EventForTesting<[changes: Storage.StorageAreaOnChangedChangesType]>; 62 | }; 63 | sync: { 64 | onChanged: EventForTesting<[changes: Storage.StorageAreaOnChangedChangesType]>; 65 | }; 66 | managed: { 67 | onChanged: EventForTesting<[changes: Storage.StorageAreaOnChangedChangesType]>; 68 | }; 69 | onChanged: EventForTesting<[changes: Record, areaName: string]>; 70 | }; 71 | tabs: Pick< 72 | Tabs.Static, 73 | 'get' | 'getCurrent' | 'create' | 'duplicate' | 'query' | 'highlight' | 'remove' | 'update' 74 | > & { 75 | resetState(): void; 76 | onCreated: EventForTesting<[tab: Tabs.Tab]>; 77 | onUpdated: EventForTesting< 78 | [tabId: number, changeInfo: Tabs.OnUpdatedChangeInfoType, tab: Tabs.Tab] 79 | >; 80 | onHighlighted: EventForTesting<[highlightInfo: Tabs.OnHighlightedHighlightInfoType]>; 81 | onActivated: EventForTesting<[activeInfo: Tabs.OnActivatedActiveInfoType]>; 82 | onRemoved: EventForTesting<[tabId: number, removeInfo: Tabs.OnRemovedRemoveInfoType]>; 83 | }; 84 | webNavigation: { 85 | onBeforeNavigate: EventForTesting<[details: WebNavigation.OnBeforeNavigateDetailsType]>; 86 | onCommitted: EventForTesting<[details: WebNavigation.OnCommittedDetailsType]>; 87 | onDOMContentLoaded: EventForTesting<[details: WebNavigation.OnDOMContentLoadedDetailsType]>; 88 | onCompleted: EventForTesting<[details: WebNavigation.OnCompletedDetailsType]>; 89 | onErrorOccurred: EventForTesting<[details: WebNavigation.OnErrorOccurredDetailsType]>; 90 | onCreatedNavigationTarget: EventForTesting< 91 | [details: WebNavigation.OnCreatedNavigationTargetDetailsType] 92 | >; 93 | onReferenceFragmentUpdated: EventForTesting< 94 | [details: WebNavigation.OnReferenceFragmentUpdatedDetailsType] 95 | >; 96 | onTabReplaced: EventForTesting<[details: WebNavigation.OnTabReplacedDetailsType]>; 97 | onHistoryStateUpdated: EventForTesting< 98 | [details: WebNavigation.OnHistoryStateUpdatedDetailsType] 99 | >; 100 | }; 101 | windows: Pick< 102 | Windows.Static, 103 | 'get' | 'getAll' | 'create' | 'getCurrent' | 'getLastFocused' | 'remove' | 'update' 104 | > & { 105 | resetState(): void; 106 | onCreated: EventForTesting<[window: Windows.Window]>; 107 | onRemoved: EventForTesting<[windowId: number]>; 108 | onFocusChanged: EventForTesting<[windowId: number]>; 109 | }; 110 | } 111 | 112 | /** 113 | * The standard `Browser` interface from `webextension-polyfill`, but with additional functions for triggering events and reseting state. 114 | */ 115 | export type FakeBrowser = BrowserOverrides & Browser; 116 | -------------------------------------------------------------------------------- /packages/proxy-service/src/types.test-d.ts: -------------------------------------------------------------------------------- 1 | import { describe, expectTypeOf, it } from 'vitest'; 2 | import { defineProxyService } from './defineProxyService'; 3 | import { DeepAsync } from './types'; 4 | 5 | // https://vitest.dev/guide/testing-types.html 6 | 7 | describe('Types', () => { 8 | describe('getService', () => { 9 | describe('with a shallow object', () => { 10 | const realService = { 11 | property: 'a', 12 | syncFn: (arg: string): number => 1, 13 | asyncFn: async (): Promise => 0, 14 | }; 15 | const [_, getService] = defineProxyService('test', () => realService); 16 | const service = getService(); 17 | 18 | it('should make sync functions async', () => { 19 | expectTypeOf(service.syncFn).toEqualTypeOf<(arg: string) => Promise>(); 20 | }); 21 | 22 | it('should not change async functions', () => { 23 | expectTypeOf(service.asyncFn).toEqualTypeOf<() => Promise>(); 24 | }); 25 | 26 | it("should make properties never since they can't be accessed synchronously", () => { 27 | expectTypeOf(service.property).toBeNever(); 28 | }); 29 | }); 30 | 31 | describe('with a raw function', () => { 32 | it('should make sync functions async', () => { 33 | const realService = (arg: string) => {}; 34 | const [_, getService] = defineProxyService('test', () => realService); 35 | const service = getService(); 36 | 37 | expectTypeOf(service).toEqualTypeOf<(arg: string) => Promise>(); 38 | }); 39 | 40 | it('should return the same type when the function is already async', () => { 41 | const realService = async (arg: string) => 1; 42 | const [_, getService] = defineProxyService('test', () => realService); 43 | const service = getService(); 44 | 45 | expectTypeOf(service).toEqualTypeOf(realService); 46 | }); 47 | }); 48 | 49 | describe('with a class instance', () => { 50 | class RealService { 51 | property = 'a'; 52 | syncFn(arg: string) { 53 | return 1; 54 | } 55 | async asyncFn() { 56 | return 2; 57 | } 58 | } 59 | const [_, getService] = defineProxyService('test', () => new RealService()); 60 | const service = getService(); 61 | 62 | it('should make sync functions async', () => { 63 | expectTypeOf(service.syncFn).toEqualTypeOf<(arg: string) => Promise>(); 64 | }); 65 | 66 | it('should not change async functions', () => { 67 | expectTypeOf(service.asyncFn).toEqualTypeOf<() => Promise>(); 68 | }); 69 | 70 | it("should make properties never since they can't be accessed synchronously", () => { 71 | expectTypeOf(service.property).toBeNever(); 72 | }); 73 | }); 74 | 75 | describe('with a nested object', () => { 76 | const realService = { 77 | a: { 78 | b: { 79 | async fn(arg: string): Promise { 80 | return arg; 81 | }, 82 | c: { 83 | d: { 84 | e: { 85 | fn(arg: boolean): string { 86 | return ''; 87 | }, 88 | }, 89 | }, 90 | }, 91 | }, 92 | }, 93 | }; 94 | const [_, getService] = defineProxyService('test', () => realService); 95 | const service = getService(); 96 | 97 | it('should return the functions at the same depth', () => { 98 | expectTypeOf(service.a.b.fn).toEqualTypeOf<(arg: string) => Promise>(); 99 | expectTypeOf(service.a.b.c.d.e.fn).toEqualTypeOf<(arg: boolean) => Promise>(); 100 | }); 101 | }); 102 | }); 103 | 104 | describe('registerService', () => { 105 | it('should require args when the init function has them', () => { 106 | const createService = (arg1: string, arg2: number) => ({}); 107 | const [registerService] = defineProxyService('test', createService); 108 | 109 | expectTypeOf(registerService).parameters.toEqualTypeOf<[string, number]>(); 110 | }); 111 | 112 | it("should not require args when the init function doesn't have any", () => { 113 | const createService = () => ({}); 114 | const [registerService] = defineProxyService('test', createService); 115 | 116 | expectTypeOf(registerService).parameters.toEqualTypeOf<[]>(); 117 | }); 118 | 119 | it("should return the actual service, not it's deep async version", () => { 120 | const createService = () => ({ syncFn: () => {} }); 121 | const [registerService] = defineProxyService('test', createService); 122 | 123 | expectTypeOf(registerService).returns.toEqualTypeOf>(); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /docs/content/fake-browser/4.implemented-apis.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: '' 3 | --- 4 | 5 | This file lists all the implemented APIs, their caveots, limitations, and example tests. Example tests are written with vitest. 6 | 7 | ::warning 8 | **Not all APIs are implemented!** 9 |
10 |
11 | For all APIs not listed here, you will have to mock the functions behavior yourself, or you can submit a PR to add support :smile: 12 | :: 13 | 14 | ## `alarms` 15 | 16 | - All alarms APIs are implemented as in production, except for `onAlarm`. 17 | - You have to manually call `onAlarm.trigger()` for your event listeners to be executed. 18 | 19 | ## `notifications` 20 | 21 | - `create`, `clear`, and `getAll` are fully implemented 22 | - You have to manually trigger all the events (`onClosed`, `onClicked`, `onButtonClicked`, `onShown`) 23 | 24 | ### Example Tests 25 | 26 | ::code-group 27 | 28 | ```ts [ensureNotificationExists.test.ts] 29 | import { describe, it, beforeEach, vi, expect } from 'vitest'; 30 | import browser, { Notifications } from 'webextension-polyfill'; 31 | import { fakeBrowser } from '@webext-core/fake-browser'; 32 | 33 | async function ensureNotificationExists( 34 | id: string, 35 | notification: Notifications.CreateNotificationOptions, 36 | ): Promise { 37 | const notifications = await browser.notifications.getAll(); 38 | if (!notifications[id]) await browser.notifications.create(id, notification); 39 | } 40 | 41 | describe('ensureNotificationExists', () => { 42 | const id = 'some-id'; 43 | const notification: Notifications.CreateNotificationOptions = { 44 | type: 'basic', 45 | title: 'Some Title', 46 | message: 'Some message...', 47 | }; 48 | 49 | beforeEach(() => { 50 | fakeBrowser.reset(); 51 | }); 52 | 53 | it('should create a notification if it does not exist', async () => { 54 | const createSpy = vi.spyOn(browser.notifications, 'create'); 55 | 56 | await ensureNotificationExists(id, notification); 57 | 58 | expect(createSpy).toBeCalledTimes(1); 59 | expect(createSpy).toBeCalledWith(id, notification); 60 | }); 61 | 62 | it('should not create the notification if it already exists', async () => { 63 | await fakeBrowser.notifications.create(id, notification); 64 | const createSpy = vi.spyOn(browser.notifications, 'create'); 65 | 66 | await ensureNotificationExists(id, notification); 67 | 68 | expect(createSpy).not.toBeCalled(); 69 | }); 70 | }); 71 | ``` 72 | 73 | ```ts [setupNotificationShownReports.test.ts] 74 | import { describe, it, beforeEach, vi, expect } from 'vitest'; 75 | import browser from 'webextension-polyfill'; 76 | import { fakeBrowser } from '@webext-core/fake-browser'; 77 | 78 | async function setupNotificationShownReports( 79 | reportEvent: (notificationId: string) => void, 80 | ): Promise { 81 | browser.notifications.onShown.addListener(id => reportEvent(id)); 82 | } 83 | 84 | describe('setupNotificationShownReports', () => { 85 | beforeEach(() => { 86 | fakeBrowser.reset(); 87 | }); 88 | 89 | it('should properly report an analytics event when a notification is shown', async () => { 90 | const reportAnalyticsEvent = vi.fn(); 91 | const id = 'notification-id'; 92 | 93 | setupNotificationShownReports(reportAnalyticsEvent); 94 | await fakeBrowser.notifications.onShown.trigger(id); 95 | 96 | expect(reportAnalyticsEvent).toBeCalledTimes(1); 97 | expect(reportAnalyticsEvent).toBeCalledWith(id); 98 | }); 99 | }); 100 | ``` 101 | 102 | :: 103 | 104 | ## `runtime` 105 | 106 | - All events have been implemented, but all of them other than `onMessage` must be triggered manually. 107 | - `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()`. 108 | - 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. 109 | 110 | ## `storage` 111 | 112 | - The `local`, `sync`, `session`, and `managed` storages are all stored separately in memory. 113 | - `storage.onChanged`, `storage.{area}.onChanged` events are all triggered when updating values. 114 | - Each storage area can be reset individually. 115 | 116 | ## `tabs` and `windows` 117 | 118 | - Fully implemented. 119 | - 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`). 120 | 121 | ## `webNavigation` 122 | 123 | - The two functions, `getFrame` and `getAllFrames` are not implemented. You will have to mock their return values yourself. 124 | - All the event listeners are implemented, but none are triggered automatically. They can be triggered manually by calling `browser.webNavigation.{event}.trigger(...)` 125 | -------------------------------------------------------------------------------- /packages/messaging/src/extension.test-d.ts: -------------------------------------------------------------------------------- 1 | import { describe, expectTypeOf, it } from 'vitest'; 2 | import { MaybePromise, ProtocolWithReturn } from './types'; 3 | import { defineExtensionMessaging, SendMessageOptions } from './extension'; 4 | 5 | describe('Messenger Typing', () => { 6 | it('should use any for data and return type when a protocol map is not passed', () => { 7 | const { sendMessage, onMessage } = defineExtensionMessaging(); 8 | 9 | expectTypeOf(sendMessage).parameter(0).toBeString(); 10 | expectTypeOf(sendMessage).parameter(1).toBeAny(); 11 | expectTypeOf(sendMessage).returns.resolves.toBeAny(); 12 | 13 | expectTypeOf(onMessage).parameter(1).parameter(0).toHaveProperty('data').toBeAny(); 14 | expectTypeOf(onMessage).parameter(1).returns.toMatchTypeOf>(); 15 | }); 16 | 17 | it('should support basic values representing the data type and no return type', () => { 18 | const { sendMessage, onMessage } = defineExtensionMessaging<{ 19 | someMessage: string; 20 | }>(); 21 | 22 | expectTypeOf(sendMessage).parameter(0).toMatchTypeOf<'someMessage'>(); 23 | expectTypeOf(sendMessage).parameter(1).toBeString(); 24 | expectTypeOf(sendMessage).returns.resolves.toBeVoid(); 25 | 26 | expectTypeOf(onMessage).parameter(1).parameter(0).toHaveProperty('data').toBeString(); 27 | expectTypeOf(onMessage).parameter(1).returns.resolves.toBeVoid(); 28 | }); 29 | 30 | it('should support ProtocolWithReturn representing the data and the return type', () => { 31 | const { sendMessage, onMessage } = defineExtensionMessaging<{ 32 | isOdd: ProtocolWithReturn; 33 | }>(); 34 | 35 | expectTypeOf(sendMessage).parameter(0).toMatchTypeOf<'isOdd'>(); 36 | expectTypeOf(sendMessage).parameter(1).toBeNumber(); 37 | expectTypeOf(sendMessage).returns.resolves.toBeBoolean(); 38 | 39 | expectTypeOf(onMessage).parameter(1).parameter(0).toHaveProperty('data').toBeNumber(); 40 | expectTypeOf(onMessage).parameter(1).returns.resolves.toBeBoolean(); 41 | }); 42 | 43 | it('should infer data and return types from a bound function declaration', () => { 44 | const { sendMessage, onMessage } = defineExtensionMessaging<{ 45 | getStringLength(data: string): number; 46 | }>(); 47 | 48 | expectTypeOf(sendMessage).parameter(0).toMatchTypeOf<'getStringLength'>(); 49 | expectTypeOf(sendMessage).parameter(1).toBeString(); 50 | expectTypeOf(sendMessage).returns.resolves.toBeNumber(); 51 | 52 | expectTypeOf(onMessage).parameter(1).parameter(0).toHaveProperty('data').toBeString(); 53 | expectTypeOf(onMessage).parameter(1).returns.resolves.toBeNumber(); 54 | }); 55 | 56 | it('should infer data and return types from an anonymous function declaration', () => { 57 | const { sendMessage, onMessage } = defineExtensionMessaging<{ 58 | getStringLength: (data: string) => number; 59 | }>(); 60 | 61 | // @ts-expect-error: Requires one parameter 62 | sendMessage('getStringLength'); 63 | sendMessage('getStringLength', 'test'); 64 | sendMessage('getStringLength', 'test', 123); 65 | 66 | expectTypeOf(sendMessage).parameter(0).toMatchTypeOf<'getStringLength'>(); 67 | expectTypeOf(sendMessage).parameter(1).toBeString(); 68 | expectTypeOf(sendMessage).returns.resolves.toBeNumber(); 69 | 70 | expectTypeOf(onMessage).parameter(1).parameter(0).toHaveProperty('data').toBeString(); 71 | expectTypeOf(onMessage).parameter(1).returns.resolves.toBeNumber(); 72 | }); 73 | 74 | it('should accept passing undefined to sendMessage when there is no data', () => { 75 | const { sendMessage } = defineExtensionMessaging<{ 76 | ping: ProtocolWithReturn; 77 | }>(); 78 | 79 | sendMessage('ping'); 80 | sendMessage('ping', undefined); 81 | // @ts-expect-error: It will still throw an error if you try to pass a target without sending `undefined` for the data. 82 | sendMessage('ping', 123); 83 | sendMessage('ping', undefined, 123); 84 | 85 | expectTypeOf(sendMessage).parameter(0).toMatchTypeOf<'ping'>(); 86 | expectTypeOf(sendMessage).parameter(1).toBeUndefined(); 87 | expectTypeOf(sendMessage).parameter(2).toEqualTypeOf(); 88 | }); 89 | 90 | it('should accept passing undefined to sendMessage when there is no arguments in a function definition', () => { 91 | const { sendMessage } = defineExtensionMessaging<{ 92 | ping(): 'pong'; 93 | }>(); 94 | 95 | sendMessage('ping'); 96 | sendMessage('ping', undefined); 97 | // @ts-expect-error: It will still throw an error if you try to pass a target without sending `undefined` for the data. 98 | sendMessage('ping', 123); 99 | sendMessage('ping', undefined, 123); 100 | 101 | expectTypeOf(sendMessage).parameter(0).toMatchTypeOf<'ping'>(); 102 | expectTypeOf(sendMessage).parameter(1).toBeUndefined(); 103 | expectTypeOf(sendMessage).parameter(2).toEqualTypeOf(); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /packages/messaging/src/window.ts: -------------------------------------------------------------------------------- 1 | import { uid } from 'uid'; 2 | import { GenericMessenger, defineGenericMessanging } from './generic'; 3 | import { NamespaceMessagingConfig, Message } from './types'; 4 | 5 | const REQUEST_TYPE = '@webext-core/messaging/window'; 6 | const RESPONSE_TYPE = '@webext-core/messaging/window/response'; 7 | 8 | /** 9 | * Configuration passed into `defineWindowMessaging`. 10 | */ 11 | export interface WindowMessagingConfig extends NamespaceMessagingConfig {} 12 | 13 | /** 14 | * For a `WindowMessenger`, `sendMessage` requires an additional argument, the `targetOrigin`. It 15 | * defines which frames inside the page should receive the message. 16 | * 17 | * > See for more 18 | * details. 19 | */ 20 | export type WindowSendMessageArgs = [targetOrigin?: string]; 21 | 22 | export type WindowMessenger> = GenericMessenger< 23 | TProtocolMap, 24 | {}, 25 | WindowSendMessageArgs 26 | >; 27 | 28 | /** 29 | * Returns a `WindowMessenger`. It is backed by the `window.postMessage` API. It can be used to 30 | * communicate between: 31 | * 32 | * - Content script and website 33 | * - Content script and injected script 34 | * 35 | * @example 36 | * interface WebsiteMessengerSchema { 37 | * initInjectedScript(data: ...): void; 38 | * } 39 | * 40 | * export const websiteMessenger = defineWindowMessaging(); 41 | * 42 | * // Content script 43 | * websiteMessenger.sendMessage("initInjectedScript", ...); 44 | * 45 | * // Injected script 46 | * websiteMessenger.onMessage("initInjectedScript", (...) => { 47 | * // ... 48 | * }) 49 | * 50 | * @link Spec diagrams 51 | * https://mermaid.live/edit#pako:eNqVlG9v2jAQxr-KZamvGv7YyYBYVSTGWgmpwAs6VZqQJpMc1BOxM8fZoIjvPieBACUVLC8SO3ru7vc4l9viUEWAGU7hdwYyhG-CLzWPZxLZa67WaKCkAWka01CLxDzMdSsobkKmhtsAUuy2SPIY0oSHwNBCKbQrMyRcGxGKhEuDbAT5qSTiKVJyBGnKl_CJKgUZ5br8eaa0-yPaUP6C0EDUeoX5VBi4hKP_A0dvgqM3wvWlMm-gi-Nb15ybe4k25_oTNPcmNPcKWrm8uzvWRF_Ld2NlAKk_oA_FnCodQ4PJaPR9PBz0X4aTMXp6nrwW6NP-6BH1p-j58enlkNsCoXKVX9WnbATB_f6AWcHWMpsE2AwnQi7JDNeFNNb7fmFILKXSlu-vLILP1HnOUkv3uCdqDemlOAgaVRWWS2phqjoXluipJVJriX6wRE8s0auWSGWJXLdEjpZovSV6ZqlcHjtBoVDFcSZFyG0LrIQ85D9rCfqhJcbnYUIWHRGJxQK0HRbHBrPJsINj0DEXkR0z2zz5DNs_I4YZZnYZwYJnK5ND7qyUZ0ZNNzLEzOgMHKxVtnzDbMFXqd1lSWTr7WdU9db2P2ZbvMasQYjX9AnteaRN3HbP7XgO3mBG3U6z533p-W637bue5-4c_K6UTUGbpOtT36edLvXaLu06GCJhlB6Vc7EYj0WJH4U-p9r9Aynbsqc 52 | */ 53 | export function defineWindowMessaging< 54 | TProtocolMap extends Record = Record, 55 | >(config: WindowMessagingConfig): WindowMessenger { 56 | const namespace = config.namespace; 57 | const instanceId = uid(); 58 | 59 | let removeAdditionalListeners: Array<() => void> = []; 60 | 61 | const sendWindowMessage = (message: Message, targetOrigin?: string) => 62 | new Promise(res => { 63 | const responseListener = (event: MessageEvent) => { 64 | if ( 65 | event.data.type === RESPONSE_TYPE && 66 | event.data.namespace === namespace && 67 | event.data.instanceId !== instanceId && 68 | event.data.message.type === message.type 69 | ) { 70 | res(event.data.response); 71 | removeResponseListener(); 72 | } 73 | }; 74 | const removeResponseListener = () => window.removeEventListener('message', responseListener); 75 | removeAdditionalListeners.push(removeResponseListener); 76 | window.addEventListener('message', responseListener); 77 | window.postMessage( 78 | { type: REQUEST_TYPE, message, senderOrigin: location.origin, namespace, instanceId }, 79 | targetOrigin ?? '*', 80 | ); 81 | }); 82 | 83 | const messenger = defineGenericMessanging({ 84 | ...config, 85 | 86 | sendMessage(message, targetOrigin) { 87 | return sendWindowMessage(message, targetOrigin); 88 | }, 89 | 90 | addRootListener(processMessage) { 91 | const listener = async (event: MessageEvent) => { 92 | if ( 93 | event.data.type !== REQUEST_TYPE || 94 | event.data.namespace !== namespace || 95 | event.data.instanceId === instanceId 96 | ) 97 | return; 98 | 99 | const response = await processMessage(event.data.message); 100 | window.postMessage( 101 | { type: RESPONSE_TYPE, response, instanceId, message: event.data.message, namespace }, 102 | event.data.senderOrigin, 103 | ); 104 | }; 105 | 106 | window.addEventListener('message', listener); 107 | return () => window.removeEventListener('message', listener); 108 | }, 109 | verifyMessageData(data) { 110 | return structuredClone(data); 111 | }, 112 | }); 113 | 114 | return { 115 | ...messenger, 116 | removeAllListeners() { 117 | messenger.removeAllListeners(); 118 | removeAdditionalListeners.forEach(removeListener => removeListener()); 119 | removeAdditionalListeners = []; 120 | }, 121 | }; 122 | } 123 | --------------------------------------------------------------------------------