├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── index.test.ts ├── index.ts ├── license ├── package.json ├── readme.md ├── tsconfig.json ├── types.d.ts ├── vitest.config.js └── vitest.setup.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | env: {} 2 | 3 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/node 4 | # SOURCE: https://github.com/fregante/ghatemplates 5 | # OPTIONS: {"exclude":["jobs.Test"]} 6 | 7 | name: CI 8 | on: 9 | - pull_request 10 | - push 11 | jobs: 12 | Lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version-file: package.json 19 | - name: install 20 | run: npm ci || npm install 21 | - name: XO 22 | run: npx xo 23 | Build: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version-file: package.json 30 | - name: install 31 | run: npm ci || npm install 32 | - name: build 33 | run: npm run build 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | Desktop.ini 4 | ._* 5 | Thumbs.db 6 | *.tmp 7 | *.bak 8 | *.log 9 | logs 10 | *.map 11 | index.js 12 | index.d.ts 13 | index.test.js 14 | index.test.d.ts 15 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/consistent-type-assertions */ 2 | import {chrome} from 'jest-chrome'; 3 | import { 4 | describe, it, assert, expect, 5 | } from 'vitest'; 6 | import {executeFunction, getTabsByUrl} from './index.js'; 7 | 8 | const tab1 = { 9 | id: 1, 10 | url: 'https://example.com/index.html', 11 | } as chrome.tabs.Tab; 12 | const tab2 = { 13 | id: 2, 14 | url: 'http://no-way.example.com/other/index.html', 15 | } as chrome.tabs.Tab; 16 | 17 | const queryMap = new Map([ 18 | ['https://example.com/*', [tab1]], 19 | ['http://no-way.example.com/*', [tab2]], 20 | ['*://*/*', [tab1, tab2]], 21 | ]); 22 | 23 | // @ts-expect-error junk types 24 | chrome.tabs.query.mockImplementation((query, callback: (...arguments_: any) => void) => { 25 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- Junk types 26 | callback(queryMap.get(query.url[0]) ?? []); 27 | }); 28 | 29 | describe('getTabsByUrl', () => { 30 | it('should handle the matches array', async () => { 31 | assert.deepEqual( 32 | await getTabsByUrl([]), 33 | [], 34 | 'No patterns means no tabs', 35 | ); 36 | assert.deepEqual( 37 | await getTabsByUrl(['https://example.com/*']), 38 | [1], 39 | 'It should pass the query to chrome.tabs', 40 | ); 41 | assert.deepEqual( 42 | await getTabsByUrl(['*://*/*']), 43 | [1, 2], 44 | 'It should pass the query to chrome.tabs', 45 | ); 46 | }); 47 | 48 | it('should handle the `excludeMatches` array', async () => { 49 | const excludeMatches = ['http://*/*']; 50 | assert.deepEqual( 51 | await getTabsByUrl([], excludeMatches), 52 | [], 53 | 'No patterns means no tabs', 54 | ); 55 | assert.deepEqual( 56 | await getTabsByUrl(['https://example.com/*'], excludeMatches), 57 | [1], 58 | 'It should pass the query to chrome.tabs', 59 | ); 60 | assert.deepEqual( 61 | await getTabsByUrl(['*://*/*'], excludeMatches), 62 | [1], 63 | 'It should exclude tabs with URLs matching `excludeMatches`', 64 | ); 65 | assert.deepEqual( 66 | await getTabsByUrl(['http://no-way.example.com/*'], excludeMatches), 67 | [], 68 | 'It should exclude tabs with URLs matching `excludeMatches`, even if it’s the only match', 69 | ); 70 | }); 71 | }); 72 | 73 | describe('executeFunction', () => { 74 | it('should throw with native functions', async () => { 75 | await expect(executeFunction(1, Date)).rejects.toMatchInlineSnapshot('[TypeError: Native functions need to be wrapped first, like `executeFunction(1, () => alert(1))`]'); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import chromeP from 'webext-polyfill-kinda'; 2 | import {patternToRegex} from 'webext-patterns'; 3 | import type {ContentScript, ExtensionFileOrCode, RunAt} from './types.js'; 4 | 5 | export type * from './types.js'; 6 | 7 | const gotScripting = Boolean(globalThis.chrome?.scripting); 8 | 9 | interface AllFramesTarget { 10 | tabId: number; 11 | frameId: number | undefined; 12 | allFrames: boolean; 13 | } 14 | 15 | interface Target { 16 | tabId: number; 17 | frameId: number; 18 | } 19 | 20 | interface InjectionOptions { 21 | ignoreTargetErrors?: boolean; 22 | } 23 | 24 | function castTarget(target: number | Target): Target { 25 | return typeof target === 'object' ? target : { 26 | tabId: target, 27 | frameId: 0, 28 | }; 29 | } 30 | 31 | function castAllFramesTarget(target: number | Target): AllFramesTarget { 32 | if (typeof target === 'object') { 33 | return {...target, allFrames: false}; 34 | } 35 | 36 | return { 37 | tabId: target, 38 | frameId: undefined, 39 | allFrames: true, 40 | }; 41 | } 42 | 43 | function castArray(possibleArray: A | A[]): A[] { 44 | if (Array.isArray(possibleArray)) { 45 | return possibleArray; 46 | } 47 | 48 | return [possibleArray]; 49 | } 50 | 51 | function normalizeFiles(files: InjectionDetails['files'], seen: string[] = []): ExtensionFileOrCode[] { 52 | return files 53 | .map(file => typeof file === 'string' ? {file} : file) 54 | .filter(content => { 55 | if ('code' in content) { 56 | return true; 57 | } 58 | 59 | const file = typeof content === 'string' ? content : content.file; 60 | if (seen.includes(file)) { 61 | console.debug(`Duplicated file not injected: ${file}`); 62 | return false; 63 | } 64 | 65 | seen.push(file); 66 | return true; 67 | }); 68 | } 69 | 70 | type MaybeArray = X | X[]; 71 | 72 | const nativeFunction = /^function \w+\(\) {[\n\s]+\[native code][\n\s]+}/; 73 | 74 | export async function executeFunction unknown>( 75 | target: number | Target, 76 | function_: FunctionToSerialize, 77 | ...arguments_: unknown[] 78 | ): Promise> { 79 | if (nativeFunction.test(String(function_))) { 80 | throw new TypeError('Native functions need to be wrapped first, like `executeFunction(1, () => alert(1))`'); 81 | } 82 | 83 | const {frameId, tabId} = castTarget(target); 84 | 85 | if (gotScripting) { 86 | const [injection] = await chrome.scripting.executeScript({ 87 | target: { 88 | tabId, 89 | frameIds: [frameId], 90 | }, 91 | func: function_, 92 | args: arguments_, 93 | }); 94 | 95 | return injection?.result as ReturnType; 96 | } 97 | 98 | const [result] = await chromeP.tabs.executeScript(tabId, { 99 | code: `(${function_.toString()})(...${JSON.stringify(arguments_)})`, 100 | matchAboutBlank: true, // Needed for `srcdoc` frames; doesn't hurt normal pages 101 | frameId, 102 | }) as [ReturnType]; 103 | 104 | return result; 105 | } 106 | 107 | function arrayOrUndefined(value?: X): [X] | undefined { 108 | return value === undefined ? undefined : [value]; 109 | } 110 | 111 | interface InjectionDetails { 112 | tabId: number; 113 | frameId?: number; 114 | matchAboutBlank?: boolean; 115 | allFrames?: boolean; 116 | runAt?: RunAt; 117 | files: string[] | ExtensionFileOrCode[]; 118 | } 119 | 120 | // eslint-disable-next-line @typescript-eslint/naming-convention -- It follows the native naming 121 | export async function insertCSS( 122 | { 123 | tabId, 124 | frameId, 125 | files, 126 | allFrames, 127 | matchAboutBlank, 128 | runAt, 129 | }: InjectionDetails, 130 | 131 | {ignoreTargetErrors}: InjectionOptions = {}, 132 | ): Promise { 133 | const normalizedFiles = normalizeFiles(files); 134 | const everyInsertion = Promise.all(normalizedFiles.map(async content => { 135 | if (gotScripting) { 136 | // One file at a time, according to the types 137 | return chrome.scripting.insertCSS({ 138 | target: { 139 | tabId, 140 | frameIds: arrayOrUndefined(frameId), 141 | allFrames: frameId === undefined ? allFrames : undefined, 142 | }, 143 | files: 'file' in content ? [content.file] : undefined, 144 | css: 'code' in content ? content.code : undefined, 145 | }); 146 | } 147 | 148 | return chromeP.tabs.insertCSS(tabId, { 149 | ...content, 150 | matchAboutBlank, 151 | allFrames, 152 | frameId, 153 | runAt: runAt ?? 'document_start', // CSS should prefer `document_start` when unspecified 154 | }); 155 | })); 156 | 157 | if (ignoreTargetErrors) { 158 | await catchTargetInjectionErrors(everyInsertion); 159 | } else { 160 | await everyInsertion; 161 | } 162 | } 163 | 164 | function assertNoCode(files: Array<{ 165 | code: string; 166 | } | { 167 | file: string; 168 | }>): asserts files is Array<{file: string}> { 169 | if (files.some(content => 'code' in content)) { 170 | throw new Error('chrome.scripting does not support injecting strings of `code`'); 171 | } 172 | } 173 | 174 | export async function executeScript( 175 | { 176 | tabId, 177 | frameId, 178 | files, 179 | allFrames, 180 | matchAboutBlank, 181 | runAt, 182 | }: InjectionDetails, 183 | 184 | {ignoreTargetErrors}: InjectionOptions = {}, 185 | ): Promise { 186 | const normalizedFiles = normalizeFiles(files); 187 | if (gotScripting) { 188 | assertNoCode(normalizedFiles); 189 | const injection = chrome.scripting.executeScript({ 190 | target: { 191 | tabId, 192 | frameIds: arrayOrUndefined(frameId), 193 | allFrames: frameId === undefined ? allFrames : undefined, 194 | }, 195 | files: normalizedFiles.map(({file}) => file), 196 | }); 197 | 198 | if (ignoreTargetErrors) { 199 | await catchTargetInjectionErrors(injection); 200 | } else { 201 | await injection; 202 | } 203 | 204 | // Don't return `injection`; the "return value" of a file is generally not useful 205 | return; 206 | } 207 | 208 | // Don't use .map(), `code` injections can't be "parallel" 209 | const executions: Array> = []; 210 | for (const content of normalizedFiles) { 211 | // Files are executed in order, but `code` isn’t, so it must await the last script before injecting more 212 | if ('code' in content) { 213 | // eslint-disable-next-line no-await-in-loop, n/no-unsupported-features/es-syntax -- On purpose, see above 214 | await executions.at(-1); 215 | } 216 | 217 | executions.push(chromeP.tabs.executeScript(tabId, { 218 | ...content, 219 | matchAboutBlank, 220 | allFrames, 221 | frameId, 222 | runAt, 223 | })); 224 | } 225 | 226 | if (ignoreTargetErrors) { 227 | await catchTargetInjectionErrors(Promise.all(executions)); 228 | } else { 229 | await Promise.all(executions); 230 | } 231 | } 232 | 233 | export async function getTabsByUrl(matches: string[], excludeMatches?: string[]): Promise { 234 | if (matches.length === 0) { 235 | return []; 236 | } 237 | 238 | const exclude = excludeMatches ? patternToRegex(...excludeMatches) : undefined; 239 | 240 | const tabs = await chromeP.tabs.query({url: matches}); 241 | return tabs 242 | .filter(tab => tab.id && tab.url && (exclude ? !exclude.test(tab.url) : true)) 243 | .map(tab => tab.id!); 244 | } 245 | 246 | export async function injectContentScript( 247 | where: MaybeArray, 248 | scripts: MaybeArray, 249 | options: InjectionOptions = {}, 250 | ): Promise { 251 | const targets = castArray(where); 252 | 253 | await Promise.all( 254 | targets.map( 255 | async target => injectContentScriptInSpecificTarget(castAllFramesTarget(target), scripts, options), 256 | ), 257 | ); 258 | } 259 | 260 | async function injectContentScriptInSpecificTarget( 261 | {frameId, tabId, allFrames}: AllFramesTarget, 262 | scripts: MaybeArray, 263 | options: InjectionOptions = {}, 264 | ): Promise { 265 | const seen: string[] = []; 266 | const injections = castArray(scripts).flatMap(script => { 267 | const css = normalizeFiles(script.css ?? [], seen); 268 | const js = normalizeFiles(script.js ?? [], seen); 269 | return [ 270 | css.length > 0 && insertCSS({ 271 | tabId, 272 | frameId, 273 | allFrames, 274 | files: css, 275 | matchAboutBlank: script.matchAboutBlank ?? script.match_about_blank, 276 | runAt: script.runAt ?? script.run_at as RunAt, 277 | }, options), 278 | 279 | js.length > 0 && executeScript({ 280 | tabId, 281 | frameId, 282 | allFrames, 283 | files: js, 284 | matchAboutBlank: script.matchAboutBlank ?? script.match_about_blank, 285 | runAt: script.runAt ?? script.run_at as RunAt, 286 | }, options), 287 | ]; 288 | }); 289 | 290 | await Promise.all(injections); 291 | } 292 | 293 | // Sourced from: 294 | // https://source.chromium.org/chromium/chromium/src/+/main:extensions/common/extension_urls.cc;drc=6b42116fe3b3d93a77750bdcc07948e98a728405;l=29 295 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts 296 | const blockedPrefixes = [ 297 | 'chrome.google.com/webstore', // Host *and* pathname 298 | 'chromewebstore.google.com', 299 | 'accounts-static.cdn.mozilla.net', 300 | 'accounts.firefox.com', 301 | 'addons.cdn.mozilla.net', 302 | 'addons.mozilla.org', 303 | 'api.accounts.firefox.com', 304 | 'content.cdn.mozilla.net', 305 | 'discovery.addons.mozilla.org', 306 | 'input.mozilla.org', 307 | 'install.mozilla.org', 308 | 'oauth.accounts.firefox.com', 309 | 'profile.accounts.firefox.com', 310 | 'support.mozilla.org', 311 | 'sync.services.mozilla.com', 312 | 'testpilot.firefox.com', 313 | ]; 314 | 315 | export function isScriptableUrl(url: string | undefined): boolean { 316 | if (!url?.startsWith('http')) { 317 | return false; 318 | } 319 | 320 | const cleanUrl = url.replace(/^https?:\/\//, ''); 321 | return blockedPrefixes.every(blocked => !cleanUrl.startsWith(blocked)); 322 | } 323 | 324 | const targetErrors = /^No frame with id \d+ in tab \d+.$|^No tab with id: \d+.$|^The tab was closed.$|^The frame was removed.$/; 325 | 326 | async function catchTargetInjectionErrors(promise: Promise): Promise { 327 | try { 328 | await promise; 329 | } catch (error) { 330 | // @ts-expect-error Optional chaining is good enough 331 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 332 | if (!targetErrors.test(error?.message)) { 333 | throw error; 334 | } 335 | } 336 | } 337 | 338 | export async function canAccessTab( 339 | target: number | Target, 340 | ): Promise { 341 | try { 342 | await executeFunction(castTarget(target), () => true); 343 | return true; 344 | } catch { 345 | return false; 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Federico Brigante (https://fregante.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webext-content-scripts", 3 | "version": "2.7.2", 4 | "description": "Utility functions to inject content scripts in WebExtensions, for Manifest v2 and v3", 5 | "keywords": [ 6 | "contentscript", 7 | "injection", 8 | "manifest", 9 | "chrome", 10 | "firefox", 11 | "browser", 12 | "extension", 13 | "executefunction", 14 | "executescript", 15 | "insertcss", 16 | "mv2", 17 | "mv3" 18 | ], 19 | "repository": "fregante/webext-content-scripts", 20 | "funding": "https://github.com/sponsors/fregante", 21 | "license": "MIT", 22 | "author": "Federico Brigante (https://fregante.com)", 23 | "type": "module", 24 | "exports": "./index.js", 25 | "main": "./index.js", 26 | "types": "./index.d.ts", 27 | "files": [ 28 | "index.js", 29 | "index.d.ts", 30 | "types.d.ts" 31 | ], 32 | "scripts": { 33 | "build": "tsc", 34 | "prepare": "tsc --sourceMap false", 35 | "test": "xo && tsc --noEmit", 36 | "watch": "tsc --watch" 37 | }, 38 | "xo": { 39 | "rules": { 40 | "unicorn/prefer-ternary": "off", 41 | "n/file-extension-in-import": "off", 42 | "@typescript-eslint/no-implicit-any-catch": "off", 43 | "@typescript-eslint/consistent-type-definitions": "off" 44 | } 45 | }, 46 | "dependencies": { 47 | "webext-patterns": "^1.5.0", 48 | "webext-polyfill-kinda": "^1.0.2" 49 | }, 50 | "devDependencies": { 51 | "@sindresorhus/tsconfig": "^7.0.0", 52 | "@types/chrome": "^0.0.299", 53 | "@types/jest": "^29.5.14", 54 | "jest-chrome": "^0.8.0", 55 | "typescript": "^5.7.3", 56 | "vitest": "^3.0.3", 57 | "xo": "^0.60.0" 58 | }, 59 | "engines": { 60 | "node": ">=16" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # webext-content-scripts [![npm version](https://img.shields.io/npm/v/webext-content-scripts.svg)](https://www.npmjs.com/package/webext-content-scripts) 2 | 3 | > Utility functions to inject content scripts in WebExtensions, for Manifest v2 and v3. 4 | 5 | - Browsers: Chrome, Firefox, and Safari 6 | - Manifest: v2 and v3 7 | - Permissions: In manifest v3, you'll need the `scripting` permission 8 | - Context: They can be called from any context that has access to the `chrome.tabs` or `chrome.scripting` APIs 9 | 10 | **Sponsored by [PixieBrix](https://www.pixiebrix.com)** :tada: 11 | 12 | ## Install 13 | 14 | You can download the [standalone bundle](https://bundle.fregante.com/?pkg=webext-content-scripts&name=window) and include it in your `manifest.json`. Or use npm: 15 | 16 | ```sh 17 | npm install webext-content-scripts 18 | ``` 19 | 20 | ```js 21 | // This module is only offered as a ES Module 22 | import { 23 | executeScript, 24 | insertCSS, 25 | injectContentScript, 26 | executeFunction, 27 | canAccessTab, 28 | } from 'webext-content-scripts'; 29 | ``` 30 | 31 | ## Usage 32 | 33 | ### `executeScript` 34 | 35 | Like `chrome.tabs.executeScript` but: 36 | 37 | - it works on Manifest v3 38 | - it can execute multiple scripts at once 39 | 40 | ```js 41 | executeScript({ 42 | tabId: 1, 43 | frameId: 20, 44 | files: ['react.js', 'main.js'], 45 | }); 46 | ``` 47 | 48 | ```js 49 | executeScript({ 50 | tabId: 1, 51 | frameId: 20, 52 | files: [ 53 | {file: 'react.js'}, 54 | {code: 'console.log(42)'}, // This will fail on Manifest v3 55 | ], 56 | }); 57 | ``` 58 | 59 | ### `insertCSS` 60 | 61 | Like `chrome.tabs.insertCSS` but: 62 | 63 | - it works on Manifest v3 64 | - it can insert multiple styles at once 65 | 66 | ```js 67 | insertCSS({ 68 | tabId: 1, 69 | frameId: 20, 70 | files: ['bootstrap.css', 'style.css'], 71 | }); 72 | ``` 73 | 74 | ```js 75 | insertCSS({ 76 | tabId: 1, 77 | frameId: 20, 78 | files: [ 79 | {file: 'bootstrap.css'}, 80 | {code: 'hmtl { color: red }'} 81 | ], 82 | }); 83 | ``` 84 | 85 | ### `injectContentScript(targets, scripts)` 86 | 87 | It combines `executeScript` and `injectCSS` in a single call. You can pass the entire `content_script` object from the manifest too, without change (even with `snake_case_keys`). It accepts either an object or an array of objects. 88 | 89 | ### targets 90 | 91 | This can be a tab ID, an array of tab IDs, a specific tab/frame combination, an array of such combinations: 92 | 93 | ```js 94 | injectContentScript(1, scripts); 95 | injectContentScript([1, 2], scripts) 96 | injectContentScript({tabId: 1, frameId: 0}, scripts); 97 | injectContentScript([{tabId: 1, frameId: 0}, {tabId: 23, frameId: 98765}], scripts); 98 | 99 | // You can also use the exported `getTabsByUrl` utility to inject by URL as well 100 | injectContentScript(await getTabsByUrl(['https://example.com/*']), scripts); 101 | ``` 102 | 103 | ### Examples 104 | 105 | ```js 106 | const tabId = 42; 107 | await injectContentScript(tabId, { 108 | runAt: 'document_idle', 109 | allFrames: true, // Default when passing frame-less tab IDs 110 | matchAboutBlank: true, 111 | js: [ 112 | 'contentscript.js' 113 | ], 114 | css: [ 115 | 'style.css' 116 | ], 117 | }) 118 | ``` 119 | 120 | ```js 121 | await injectContentScript({ 122 | tabId: 42, 123 | frameId: 56 124 | }, [ 125 | { 126 | js: [ 127 | 'jquery.js', 128 | 'contentscript.js' 129 | ], 130 | css: [ 131 | 'bootstrap.css', 132 | 'style.css' 133 | ], 134 | }, 135 | { 136 | runAt: 'document_start', 137 | css: [ 138 | 'more-styles.css' 139 | ], 140 | } 141 | ]) 142 | ``` 143 | 144 | ```js 145 | const tabId = 42; 146 | const scripts = browser.runtime.getManifest().content_scripts; 147 | // `matches`, `exclude_matches`, etc are ignored, so you can inject them on any host that you have permission to 148 | await injectContentScript(tabId, scripts); 149 | ``` 150 | 151 | ### `executeFunction(tabId, function, ...arguments)` 152 | 153 | ### `executeFunction({tabId, frameId}, function, ...arguments)` 154 | 155 | Like `chrome.tabs.executeScript`, except that it accepts a raw function to be executed in the chosen tab. 156 | 157 | ```js 158 | const tabId = 10; 159 | 160 | const tabUrl = await executeFunction(tabId, () => { 161 | alert('This code is run as a content script'); 162 | return location.href; 163 | }); 164 | 165 | console.log(tabUrl); 166 | ``` 167 | 168 | Note: The function must be self-contained because it will be serialized. 169 | 170 | ```js 171 | const tabId = 10; 172 | const catsAndDogs = 'cute'; 173 | 174 | await executeFunction(tabId, () => { 175 | console.log(catsAndDogs); // ERROR: catsAndDogs will be undeclared and will throw an error 176 | }); 177 | ``` 178 | 179 | you must pass it as arguments: 180 | 181 | ```js 182 | const tabId = 10; 183 | const catsAndDogs = 'cute'; 184 | 185 | await executeFunction(tabId, (localCatsAndDogs) => { 186 | console.log(localCatsAndDogs); // It logs "cute" 187 | }, catsAndDogs); // Argument 188 | ``` 189 | 190 | ### `canAccessTab(tabId)` 191 | 192 | ### `canAccessTab({tabId, frameId})` 193 | 194 | Checks whether the extension has access to a specific tab or frame (i.e. content scripts are allowed to run), either via `activeTab` permission or regular host permissions. 195 | 196 | ```js 197 | const tabId = 42; 198 | const access = await canAccessTab(tabId); 199 | if (access) { 200 | console.log('We can access this tab'); 201 | chrome.tabs.executeScript(tabId, {file: 'my-script.js'}); 202 | } else { 203 | console.warn('We have no access to the tab'); 204 | } 205 | ``` 206 | 207 | ```js 208 | const access = await canAccessTab({ 209 | tabId: 42, 210 | frameId: 56, 211 | }); 212 | if (access) { 213 | console.log('We can access this frame'); 214 | chrome.tabs.executeScript(42, {file: 'my-script.js', frameId: 56}); 215 | } else { 216 | console.warn('We have no access to the frame'); 217 | } 218 | ``` 219 | 220 | ### `isScriptableUrl(url)` 221 | 222 | Browsers block access to some URLs for security reasons. This function will check whether a passed URL is blocked. Permissions and the manifest are not checked, this function is completely static. It will also returns `false` for any URL that doesn't start with `http`. 223 | 224 | More info may be found on: 225 | 226 | - https://stackoverflow.com/q/11613371/288906 227 | - https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts 228 | 229 | ```js 230 | const url = 'https://addons.mozilla.org/en-US/firefox/addon/ghosttext/'; 231 | if (isScriptableUrl(url)) { 232 | console.log('I can inject content script to this page if permitted'); 233 | } else { 234 | console.log('Content scripts are never allowed on this page'); 235 | } 236 | ``` 237 | 238 | ## Related 239 | 240 | - [webext-tools](https://github.com/fregante/webext-tools) - Utility functions for Web Extensions. 241 | - [webext-permission-toggle](https://github.com/fregante/webext-permission-toggle) - Browser-action context menu to request permission for the current tab. 242 | - [webext-permissions](https://github.com/fregante/webext-permissions) - Get any optional permissions that users have granted you. 243 | - [webext-dynamic-content-scripts](https://github.com/fregante/webext-dynamic-content-scripts) - Automatically registers your content_scripts on domains added via permission.request 244 | - [More…](https://github.com/fregante/webext-fun) 245 | 246 | ## License 247 | 248 | MIT © [Federico Brigante](https://fregante.com) 249 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "." 5 | }, 6 | "files": [ 7 | "types.d.ts", 8 | "index.test.ts", 9 | "index.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | export type ExtensionFileOrCode = { 2 | code: string; 3 | } | { 4 | file: string; 5 | }; 6 | 7 | export type RunAt = 'document_start' | 'document_end' | 'document_idle'; 8 | 9 | export interface ContentScript { 10 | /** 11 | * The list of CSS files to inject 12 | */ 13 | css?: string[] | ExtensionFileOrCode[]; 14 | 15 | /** 16 | * The list of JS files to inject 17 | */ 18 | js?: string[] | ExtensionFileOrCode[]; 19 | 20 | /** 21 | * @deprecated Prefer `allFrames` 22 | */ 23 | all_frames?: boolean; 24 | 25 | /** 26 | * If allFrames is true, implies that the JavaScript or CSS should be injected into all frames of current page. 27 | * By default, it's false and is only injected into the top frame. 28 | */ 29 | allFrames?: boolean; 30 | 31 | /** 32 | * @deprecated Prefer `matchAboutBlank` 33 | */ 34 | match_about_blank?: boolean; 35 | 36 | /** 37 | * If matchAboutBlank is true, then the code is also injected in about:blank and about:srcdoc frames if your extension has 38 | * access to its parent document. Code cannot be inserted in top-level about:-frames. By default it is false. 39 | */ 40 | matchAboutBlank?: boolean; 41 | 42 | /** 43 | * @deprecated Prefer `runAt` 44 | */ 45 | run_at?: string; 46 | 47 | /** 48 | * The soonest that the JavaScript or CSS will be injected into the tab. Defaults to "document_idle". 49 | */ 50 | runAt?: RunAt; 51 | } 52 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['**/*.test.ts'], 6 | setupFiles: [ 7 | './vitest.setup.js', 8 | ], 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /vitest.setup.js: -------------------------------------------------------------------------------- 1 | import {chrome} from 'jest-chrome'; 2 | import {vi} from 'vitest'; 3 | 4 | // For `jest-chrome` https://github.com/vitest-dev/vitest/issues/2667 5 | globalThis.jest = vi; 6 | 7 | globalThis.chrome = chrome; 8 | --------------------------------------------------------------------------------