├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── esm-lint.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .parcelrc ├── contributing.md ├── how-to-add-github-enterprise-support-to-web-extensions.md ├── jest-puppeteer.config.cjs ├── license ├── package-lock.json ├── package.json ├── readme.md ├── source ├── __snapshots__ │ ├── deduplicator.test.ts.snap │ └── lib.test.ts.snap ├── deduplicator.test.ts ├── deduplicator.ts ├── index.ts ├── inject-to-existing-tabs.ts ├── lib.test.ts ├── lib.ts ├── register-content-script-shim.ts ├── utils.test.ts └── utils.ts ├── test ├── browser.js ├── demo-extension │ ├── mv2 │ │ ├── background.js │ │ ├── content.css │ │ ├── content.js │ │ ├── local.html │ │ └── manifest.json │ ├── mv3 │ │ ├── background.js │ │ ├── content.css │ │ ├── content.js │ │ └── manifest.json │ └── webext-permissions.js └── package.json ├── tsconfig.json ├── usage-mv2.md ├── utils.d.ts ├── utils.js ├── 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 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '*' 7 | push: 8 | branches: 9 | - main 10 | - 'test/*' 11 | 12 | jobs: 13 | Lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - run: npm ci 18 | - run: npx xo 19 | 20 | Build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v3 24 | - run: npm ci 25 | - run: npm run build 26 | - run: npm run demo:build 27 | 28 | UnitTest: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - run: npm ci 33 | - run: npm run vitest 34 | 35 | Test: 36 | # https://github.com/puppeteer/puppeteer/issues/12818#issuecomment-2415915338 37 | runs-on: ubuntu-22.04 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | permission: 42 | - tabs 43 | - webNavigation 44 | version: 45 | - "2" 46 | - "3" 47 | steps: 48 | - uses: actions/checkout@v3 49 | - run: sudo apt-get install xvfb 50 | - run: npm ci 51 | - run: npm run demo:build 52 | - name: Use tabs-only permission 53 | if: matrix.permission == 'tabs' 54 | run: | 55 | sed -i 's/webNavigation/tabs/' test/demo-extension/mv${{ matrix.version }}/manifest.json 56 | - run: xvfb-run --auto-servernum npm run jest:core 57 | env: 58 | TARGET: ${{ matrix.version }} 59 | -------------------------------------------------------------------------------- /.github/workflows/esm-lint.yml: -------------------------------------------------------------------------------- 1 | env: 2 | IMPORT_STATEMENT: import "webext-dynamic-content-scripts" 3 | 4 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/esm-lint 5 | # SOURCE: https://github.com/fregante/ghatemplates 6 | # OPTIONS: {"exclude":["jobs.Node"]} 7 | 8 | name: ESM 9 | on: 10 | pull_request: 11 | branches: 12 | - '*' 13 | push: 14 | branches: 15 | - master 16 | - main 17 | jobs: 18 | Pack: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - run: npm install 23 | - run: npm run build --if-present 24 | - run: npm pack --dry-run 25 | - run: npm pack | tail -1 | xargs -n1 tar -xzf 26 | - uses: actions/upload-artifact@v4 27 | with: 28 | path: package 29 | Publint: 30 | runs-on: ubuntu-latest 31 | needs: Pack 32 | steps: 33 | - uses: actions/download-artifact@v4 34 | - run: npx publint ./artifact 35 | Webpack: 36 | runs-on: ubuntu-latest 37 | needs: Pack 38 | steps: 39 | - uses: actions/download-artifact@v4 40 | - run: npm install --omit=dev ./artifact 41 | - run: echo "$IMPORT_STATEMENT" > index.js 42 | - run: webpack --entry ./index.js 43 | - run: cat dist/main.js 44 | Parcel: 45 | runs-on: ubuntu-latest 46 | needs: Pack 47 | steps: 48 | - uses: actions/download-artifact@v4 49 | - run: npm install --omit=dev ./artifact 50 | - run: echo "$IMPORT_STATEMENT" > index.js 51 | - run: > 52 | echo '{"@parcel/resolver-default": {"packageExports": true}}' > 53 | package.json 54 | - run: npx parcel@2 build index.js 55 | - run: cat dist/index.js 56 | Rollup: 57 | runs-on: ubuntu-latest 58 | needs: Pack 59 | steps: 60 | - uses: actions/download-artifact@v4 61 | - run: npm install --omit=dev ./artifact rollup@4 @rollup/plugin-node-resolve 62 | - run: echo "$IMPORT_STATEMENT" > index.js 63 | - run: npx rollup -p node-resolve index.js 64 | Vite: 65 | runs-on: ubuntu-latest 66 | needs: Pack 67 | steps: 68 | - uses: actions/download-artifact@v4 69 | - run: npm install --omit=dev ./artifact 70 | - run: echo '' > index.html 71 | - run: npx vite build 72 | - run: cat dist/assets/* 73 | esbuild: 74 | runs-on: ubuntu-latest 75 | needs: Pack 76 | steps: 77 | - uses: actions/download-artifact@v4 78 | - run: echo '{}' > package.json 79 | - run: echo "$IMPORT_STATEMENT" > index.js 80 | - run: npm install --omit=dev ./artifact 81 | - run: npx esbuild --bundle index.js 82 | TypeScript: 83 | runs-on: ubuntu-latest 84 | needs: Pack 85 | steps: 86 | - uses: actions/download-artifact@v4 87 | - run: echo '{"type":"module"}' > package.json 88 | - run: npm install --omit=dev ./artifact @sindresorhus/tsconfig 89 | - run: echo "$IMPORT_STATEMENT" > index.mts 90 | - run: > 91 | echo '{"extends":"@sindresorhus/tsconfig","files":["index.mts"]}' > 92 | tsconfig.json 93 | - run: npx --package typescript -- tsc 94 | - run: cat distribution/index.mjs 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | Desktop.ini 4 | ._* 5 | Thumbs.db 6 | *.tmp 7 | *.bak 8 | *.log 9 | logs 10 | .parcel-cache 11 | dist 12 | distribution 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/distribution/* 3 | *.test.* 4 | !/utils.* 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-webextension" 3 | } 4 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Demo extension 4 | 5 | There's a demo extension in both MV2 (manifest v2) and MV3 (manifest v3) variants. This extension can be build and run manually, via `web-ext` or as a test fixture. 6 | 7 | ### Building the extension locally 8 | 9 | Pick one of: 10 | 11 | ```sh 12 | npm run demo:build 13 | npm run demo:watch 14 | ``` 15 | 16 | Both MV2 and MV3 versions will be built at the same time 17 | 18 | ### Running Jest tests 19 | 20 | ```sh 21 | npm run jest 22 | ``` 23 | 24 | Both MV2 and MV3 versions will be tested in series. 25 | 26 | Or pick one: 27 | 28 | ```sh 29 | TARGET=2 npm run jest:core 30 | TARGET=3 npm run jest:core 31 | ``` 32 | 33 | ### Running the demo extension locally 34 | 35 | ```sh 36 | npx web-ext run 37 | ``` 38 | 39 | You can add these flags to `web-ext`: 40 | 41 | - `-t chromium` to open Chrome 42 | - `--sourceDir test/dist/mv3` to open the Manifest v3 version instead of v2 43 | -------------------------------------------------------------------------------- /how-to-add-github-enterprise-support-to-web-extensions.md: -------------------------------------------------------------------------------- 1 | # How to add GitHub Enterprise support to WebExtensions 2 | 3 | > Or rather, _how to enable your content scripts on optional domains domains, dynamically._ 4 | 5 | Context menu 6 | 7 | You can implement the feature effortlessly by using these 2 modules: 8 | 9 | [webext-permission-toggle](https://github.com/fregante/webext-permission-toggle) will add a toggle in the Browser Action icon that will let the user requestion permission to any domain. 10 | 11 | [webext-dynamic-content-scripts](https://github.com/fregante/webext-dynamic-content-scripts) will use this permission to inject the content scripts you declared in `manifest.json`, but instead of injecting just on the default domain (github.com) they'll be injected on all the new domains that the user added. 12 | 13 | ## background.js 14 | 15 | ```js 16 | addPermissionToggle(); 17 | ``` 18 | 19 | or if you use a bundler: 20 | 21 | ```js 22 | import 'webext-dynamic-content-scripts'; 23 | import addPermissionToggle from 'webext-permission-toggle'; 24 | 25 | addPermissionToggle(); 26 | ``` 27 | 28 | ## manifest.json v3 example 29 | 30 | ```js 31 | { 32 | "version": 3, 33 | "permissions": [ 34 | "scripting", 35 | "contextMenus", 36 | "activeTab" // Required for Firefox support (webext-permission-toggle) 37 | ], 38 | "action": { // Required for Firefox support (webext-permission-toggle) 39 | "default_icon": "icon.png" 40 | }, 41 | "optional_host_permissions": [ 42 | "*://*/*" 43 | ], 44 | "background": { 45 | "scripts": "background.worker.js" 46 | }, 47 | "content_scripts": [ 48 | { 49 | "matches": ["https://github.com/*"], 50 | "css": ["content.css"], 51 | "js": ["content.js"] 52 | } 53 | ] 54 | } 55 | ``` 56 | 57 | ## manifest.json v2 example 58 | 59 | ```js 60 | { 61 | "version": 2, 62 | "permissions": [ 63 | "https://github.com/*", 64 | "contextMenus", 65 | "activeTab" // Required for Firefox support (webext-permission-toggle) 66 | ], 67 | "browser_action": { // Required for Firefox support (webext-permission-toggle) 68 | "default_icon": "icon.png" 69 | }, 70 | "optional_permissions": [ 71 | "*://*/*" 72 | ], 73 | "background": { 74 | "scripts": [ 75 | "webext-permission-toggle.js", 76 | "webext-dynamic-content-scripts.js", 77 | "background.js" 78 | ] 79 | }, 80 | "content_scripts": [ 81 | { 82 | "matches": ["https://github.com/*"], 83 | "css": ["content.css"], 84 | "js": ["content.js"] 85 | } 86 | ] 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /jest-puppeteer.config.cjs: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | const process = require('node:process'); 3 | 4 | module.exports = { 5 | launch: { 6 | headless: false, 7 | args: [ 8 | '--disable-extensions-except=' + path.resolve(__dirname, 'test/dist/mv' + (process.env.TARGET ?? 2)), 9 | '--window-size=400,800', 10 | ], 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /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-dynamic-content-scripts", 3 | "version": "10.0.4", 4 | "description": "WebExtension module: Automatically registers your `content_scripts` on domains added via `permission.request`", 5 | "keywords": [ 6 | "contentscript", 7 | "register", 8 | "injection", 9 | "permissions", 10 | "request", 11 | "optional_permissions", 12 | "manifest", 13 | "new hosts", 14 | "chrome", 15 | "firefox", 16 | "browser", 17 | "extension" 18 | ], 19 | "repository": "fregante/webext-dynamic-content-scripts", 20 | "funding": "https://github.com/sponsors/fregante", 21 | "license": "MIT", 22 | "author": "Federico Brigante (https://fregante.com)", 23 | "type": "module", 24 | "exports": { 25 | ".": { 26 | "types": "./distribution/index.d.ts", 27 | "default": "./distribution/index.js" 28 | }, 29 | "./utils.js": { 30 | "types": "./distribution/utils.d.ts", 31 | "default": "./distribution/utils.js" 32 | } 33 | }, 34 | "scripts": { 35 | "build": "tsc", 36 | "demo:build": "parcel build --no-cache", 37 | "demo:watch": "parcel watch --no-cache --no-hmr", 38 | "prejest": "npm run demo:build", 39 | "jest": "TARGET=2 npm run jest:core && TARGET=3 npm run jest:core", 40 | "vitest": "vitest", 41 | "jest:core": "NODE_OPTIONS=--experimental-vm-modules JEST_PUPPETEER_CONFIG=jest-puppeteer.config.cjs jest", 42 | "prepack": "tsc --sourceMap false && npm pkg delete alias #parcel#8920", 43 | "lint": "xo", 44 | "fix": "xo --fix", 45 | "test": "run-p lint vitest jest", 46 | "watch": "tsc --watch" 47 | }, 48 | "xo": { 49 | "envs": [ 50 | "browser", 51 | "webextensions" 52 | ], 53 | "rules": { 54 | "no-implicit-globals": "off", 55 | "@typescript-eslint/prefer-nullish-coalescing": "off", 56 | "@typescript-eslint/no-implicit-any-catch": "off", 57 | "@typescript-eslint/naming-convention": "off", 58 | "import/extensions": "off", 59 | "import/no-unassigned-import": "off", 60 | "unicorn/prefer-top-level-await": "off", 61 | "unicorn/prefer-node-protocol": "off", 62 | "n/file-extension-in-import": "off" 63 | } 64 | }, 65 | "jest": { 66 | "preset": "jest-puppeteer", 67 | "testMatch": [ 68 | "**/test/*.js" 69 | ] 70 | }, 71 | "dependencies": { 72 | "content-scripts-register-polyfill": "^4.0.2", 73 | "webext-content-scripts": "^2.7.0", 74 | "webext-detect": "^5.0.2", 75 | "webext-events": "^3.0.1", 76 | "webext-patterns": "^1.5.0", 77 | "webext-permissions": "^3.1.3", 78 | "webext-polyfill-kinda": "^1.0.2" 79 | }, 80 | "devDependencies": { 81 | "@parcel/config-webextension": "^2.12.0", 82 | "@sindresorhus/tsconfig": "^6.0.0", 83 | "@types/chrome": "^0.0.268", 84 | "@types/firefox-webext-browser": "^120.0.4", 85 | "@types/jest": "^29.5.12", 86 | "expect-puppeteer": "^10.0.0", 87 | "jest": "^29.7.0", 88 | "jest-chrome": "^0.8.0", 89 | "jest-puppeteer": "^10.0.1", 90 | "npm-run-all": "^4.1.5", 91 | "parcel": "^2.12.0", 92 | "puppeteer": "^21.3.6", 93 | "typescript": "^5.5.2", 94 | "vitest": "^1.6.0", 95 | "xo": "^0.58.0" 96 | }, 97 | "@parcel/resolver-default": { 98 | "packageExports": true 99 | }, 100 | "alias": { 101 | "these-are-just-mocks-for-parcel-tests": "yolo", 102 | "webext-permissions": "./test/demo-extension/webext-permissions.js", 103 | "webext-dynamic-content-scripts": "./source/" 104 | }, 105 | "targets": { 106 | "main": false, 107 | "module": false, 108 | "mv2": { 109 | "source": "./test/demo-extension/mv2/manifest.json", 110 | "distDir": "./test/dist/mv2", 111 | "sourceMap": { 112 | "inline": true 113 | }, 114 | "optimize": false 115 | }, 116 | "mv3": { 117 | "source": "./test/demo-extension/mv3/manifest.json", 118 | "distDir": "./test/dist/mv3", 119 | "sourceMap": { 120 | "inline": true 121 | }, 122 | "optimize": false 123 | } 124 | }, 125 | "webExt": { 126 | "sourceDir": "test/dist/mv2", 127 | "run": { 128 | "startUrl": [ 129 | "https://static-ephiframe.vercel.app/Static", 130 | "https://static-ephiframe.vercel.app/Static?iframe=./Inner", 131 | "https://dynamic-ephiframe.vercel.app/Dynamic", 132 | "https://dynamic-ephiframe.vercel.app/Dynamic?iframe=./Inner", 133 | "https://dynamic-ephiframe.vercel.app/Dynamic?iframe=https://static-ephiframe.vercel.app/Static-inner", 134 | "https://static-ephiframe.vercel.app/Static?iframe=https://dynamic-ephiframe.vercel.app/Dynamic-inner", 135 | "chrome://extensions/" 136 | ] 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # webext-dynamic-content-scripts [![npm version](https://img.shields.io/npm/v/webext-dynamic-content-scripts.svg)](https://www.npmjs.com/package/webext-dynamic-content-scripts) 2 | 3 | > WebExtension module: Automatically registers your `content_scripts` on domains added via `permissions.request` 4 | 5 | - Browsers: Chrome, Firefox, and Safari 6 | - Manifest: v2 and v3 7 | 8 | This module will automatically register your `content_scripts` from `manifest.json` into new domains granted via `permissions.request()`, or via [webext-permission-toggle](https://github.com/fregante/webext-permission-toggle). 9 | 10 | The main use case is ship your extension with a minimal set of hosts and then allow the user to enable it on any domain; this way you don't need to use a broad `` permission. 11 | 12 | ## Guides 13 | 14 | [**How to let your users enable your extension on any domain.**](how-to-add-github-enterprise-support-to-web-extensions.md) 15 | 16 | ## Install 17 | 18 | You can download the [standalone bundle](https://bundle.fregante.com/?pkg=webext-dynamic-content-scripts) and include it in your `manifest.json`. Or use npm: 19 | 20 | ```sh 21 | npm install webext-dynamic-content-scripts 22 | ``` 23 | 24 | ```js 25 | // This module is only offered as a ES Module 26 | import 'webext-dynamic-content-scripts'; 27 | ``` 28 | 29 | ## Usage 30 | 31 | _For Manifest v2, refer to the [usage-mv2](./usage-mv2.md) documentation._ 32 | 33 | You need to: 34 | 35 | - import `webext-dynamic-content-scripts` in the worker (no functions need to be called) 36 | - specify `optional_host_permissions` in the manifest to allow new permissions to be added 37 | - specify at least one `content_scripts` 38 | 39 | ```js 40 | // example background.worker.js 41 | navigator.importScripts('webext-dynamic-content-scripts.js'); 42 | ``` 43 | 44 | ```json 45 | // example manifest.json 46 | { 47 | "permissions": ["scripting", "storage"], 48 | "optional_host_permissions": ["*://*/*"], 49 | "background": { 50 | "service_worker": "background.worker.js" 51 | }, 52 | "content_scripts": [ 53 | { 54 | "matches": ["https://github.com/*"], 55 | "css": ["content.css"], 56 | "js": ["content.js"] 57 | } 58 | ] 59 | } 60 | ``` 61 | 62 | ### Additional APIs 63 | 64 | #### `isContentScriptRegistered(url)` 65 | 66 | You can detect whether a specific URL will receive the content scripts by importing the `utils` file: 67 | 68 | ```js 69 | import {isContentScriptRegistered} from 'webext-dynamic-content-scripts/utils.js'; 70 | 71 | if (await isContentScriptRegistered('https://google.com/search')) { 72 | console.log('Either way, the content scripts are registered'); 73 | } 74 | ``` 75 | 76 | `isContentScriptRegistered` returns a promise that resolves with a string indicating the type of injection (`'static'` or `'dynamic'`) or `false` if it won't be injected on the specified URL. 77 | 78 | ## Related 79 | 80 | - [webext-permission-toggle](https://github.com/fregante/webext-permission-toggle) - Browser-action context menu to request permission for the current tab. 81 | - [webext-options-sync](https://github.com/fregante/webext-options-sync) - Helps you manage and autosave your extension's options. 82 | - [webext-detect](https://github.com/fregante/webext-detect) - Detects where the current browser extension code is being run. 83 | - [Awesome-WebExtensions](https://github.com/fregante/Awesome-WebExtensions) - A curated list of awesome resources for WebExtensions development. 84 | - [More…](https://github.com/fregante/webext-fun) 85 | 86 | ## License 87 | 88 | MIT © [Federico Brigante](https://fregante.com) 89 | -------------------------------------------------------------------------------- /source/__snapshots__/deduplicator.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`excludeDuplicateFiles > safe > it should drop the whole block if if empty 1`] = ` 4 | [ 5 | { 6 | "css": [ 7 | "first.css", 8 | ], 9 | "js": [ 10 | "alpha.js", 11 | ], 12 | }, 13 | ] 14 | `; 15 | 16 | exports[`excludeDuplicateFiles > safe > it should remove duplicate scripts 1`] = ` 17 | [ 18 | { 19 | "css": [], 20 | "js": [ 21 | "first.js", 22 | ], 23 | }, 24 | { 25 | "css": [], 26 | "js": [ 27 | "second.js", 28 | ], 29 | }, 30 | ] 31 | `; 32 | 33 | exports[`excludeDuplicateFiles > safe > it should remove duplicate scripts and stylesheets 1`] = ` 34 | [ 35 | { 36 | "css": [ 37 | "first.css", 38 | ], 39 | "js": [ 40 | "alpha.js", 41 | ], 42 | }, 43 | { 44 | "css": [ 45 | "second.css", 46 | ], 47 | "js": [], 48 | }, 49 | ] 50 | `; 51 | 52 | exports[`excludeDuplicateFiles > safe > it should remove duplicate stylesheets 1`] = ` 53 | [ 54 | { 55 | "css": [ 56 | "first.css", 57 | ], 58 | "js": [], 59 | }, 60 | { 61 | "css": [ 62 | "second.css", 63 | ], 64 | "js": [], 65 | }, 66 | ] 67 | `; 68 | 69 | exports[`excludeDuplicateFiles > warning > it should not warn when a differentiator is the same 1`] = ` 70 | [ 71 | { 72 | "css": [], 73 | "exclude_matches": [ 74 | "https://*/admin/*", 75 | ], 76 | "js": [ 77 | "first.js", 78 | ], 79 | }, 80 | ] 81 | `; 82 | 83 | exports[`excludeDuplicateFiles > warning > it should warn when a differentiator is different 1`] = ` 84 | [ 85 | { 86 | "css": [ 87 | "first.css", 88 | ], 89 | "js": [], 90 | }, 91 | { 92 | "css": [ 93 | "second.css", 94 | ], 95 | "js": [], 96 | "run_at": "document_start", 97 | }, 98 | ] 99 | `; 100 | 101 | exports[`excludeDuplicateFiles > warning > it should warn when a differentiator is different 2`] = ` 102 | [ 103 | { 104 | "css": [], 105 | "js": [ 106 | "first.js", 107 | "second.js", 108 | ], 109 | "run_at": "document_end", 110 | }, 111 | ] 112 | `; 113 | 114 | exports[`excludeDuplicateFiles > warning > it should warn when a differentiator is different 3`] = ` 115 | [ 116 | { 117 | "all_frames": true, 118 | "css": [ 119 | "first.css", 120 | ], 121 | "js": [], 122 | }, 123 | { 124 | "css": [ 125 | "second.css", 126 | ], 127 | "js": [], 128 | }, 129 | ] 130 | `; 131 | 132 | exports[`excludeDuplicateFiles > warning 1`] = ` 133 | [MockFunction warn] { 134 | "calls": [ 135 | [ 136 | "Duplicate file in the manifest content_scripts: first.css 137 | More info: https://github.com/fregante/webext-dynamic-content-scripts/issues/62", 138 | ], 139 | ], 140 | "results": [ 141 | { 142 | "type": "return", 143 | "value": undefined, 144 | }, 145 | ], 146 | } 147 | `; 148 | 149 | exports[`excludeDuplicateFiles > warning 2`] = ` 150 | [MockFunction warn] { 151 | "calls": [ 152 | [ 153 | "Duplicate file in the manifest content_scripts: first.js 154 | More info: https://github.com/fregante/webext-dynamic-content-scripts/issues/62", 155 | ], 156 | [ 157 | "Duplicate file in the manifest content_scripts: second.js 158 | More info: https://github.com/fregante/webext-dynamic-content-scripts/issues/62", 159 | ], 160 | ], 161 | "results": [ 162 | { 163 | "type": "return", 164 | "value": undefined, 165 | }, 166 | { 167 | "type": "return", 168 | "value": undefined, 169 | }, 170 | ], 171 | } 172 | `; 173 | 174 | exports[`excludeDuplicateFiles > warning 3`] = ` 175 | [MockFunction warn] { 176 | "calls": [ 177 | [ 178 | "Duplicate file in the manifest content_scripts: first.css 179 | More info: https://github.com/fregante/webext-dynamic-content-scripts/issues/62", 180 | ], 181 | ], 182 | "results": [ 183 | { 184 | "type": "return", 185 | "value": undefined, 186 | }, 187 | ], 188 | } 189 | `; 190 | -------------------------------------------------------------------------------- /source/__snapshots__/lib.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`init - registerContentScript > should register multiple manifest scripts on new permissions 1`] = ` 4 | [MockFunction registerContentScript] { 5 | "calls": [ 6 | [ 7 | { 8 | "allFrames": undefined, 9 | "css": [], 10 | "excludeMatches": undefined, 11 | "js": [ 12 | "/script.js", 13 | ], 14 | "matches": [ 15 | "https://granted.example.com/*", 16 | ], 17 | "runAt": undefined, 18 | }, 19 | ], 20 | [ 21 | { 22 | "allFrames": undefined, 23 | "css": [], 24 | "excludeMatches": undefined, 25 | "js": [ 26 | "/otherScript.js", 27 | ], 28 | "matches": [ 29 | "https://granted.example.com/*", 30 | ], 31 | "runAt": undefined, 32 | }, 33 | ], 34 | ], 35 | "results": [ 36 | { 37 | "type": "return", 38 | "value": undefined, 39 | }, 40 | { 41 | "type": "return", 42 | "value": undefined, 43 | }, 44 | ], 45 | } 46 | `; 47 | 48 | exports[`init - registerContentScript > should register the manifest scripts on multiple new permissions 1`] = ` 49 | [MockFunction registerContentScript] { 50 | "calls": [ 51 | [ 52 | { 53 | "allFrames": undefined, 54 | "css": [], 55 | "excludeMatches": undefined, 56 | "js": [ 57 | "/script.js", 58 | ], 59 | "matches": [ 60 | "https://granted.example.com/*", 61 | ], 62 | "runAt": undefined, 63 | }, 64 | ], 65 | [ 66 | { 67 | "allFrames": undefined, 68 | "css": [], 69 | "excludeMatches": undefined, 70 | "js": [ 71 | "/script.js", 72 | ], 73 | "matches": [ 74 | "https://granted-more.example.com/*", 75 | ], 76 | "runAt": undefined, 77 | }, 78 | ], 79 | ], 80 | "results": [ 81 | { 82 | "type": "return", 83 | "value": undefined, 84 | }, 85 | { 86 | "type": "return", 87 | "value": undefined, 88 | }, 89 | ], 90 | } 91 | `; 92 | 93 | exports[`init - registerContentScript > should register the manifest scripts on new permissions 1`] = ` 94 | [MockFunction registerContentScript] { 95 | "calls": [ 96 | [ 97 | { 98 | "allFrames": undefined, 99 | "css": [], 100 | "excludeMatches": undefined, 101 | "js": [ 102 | "/script.js", 103 | ], 104 | "matches": [ 105 | "https://granted.example.com/*", 106 | ], 107 | "runAt": undefined, 108 | }, 109 | ], 110 | ], 111 | "results": [ 112 | { 113 | "type": "return", 114 | "value": undefined, 115 | }, 116 | ], 117 | } 118 | `; 119 | -------------------------------------------------------------------------------- /source/deduplicator.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | test, describe, afterEach, expect, 3 | } from 'vitest'; 4 | import {excludeDuplicateFiles} from './deduplicator.js'; 5 | 6 | const warnMock = jest.spyOn(console, 'warn').mockImplementation(() => undefined); 7 | 8 | afterEach(() => { 9 | warnMock.mockClear(); 10 | }); 11 | 12 | describe('excludeDuplicateFiles', () => { 13 | test('safe', () => { 14 | expect(excludeDuplicateFiles([ 15 | { 16 | js: ['first.js'], 17 | matches: ['https://virgilio.it/*'], 18 | }, 19 | { 20 | js: ['first.js', 'second.js'], 21 | matches: ['https://example.com/*'], 22 | }, 23 | ])).toMatchSnapshot('it should remove duplicate scripts'); 24 | expect(warnMock).not.toHaveBeenCalled(); 25 | 26 | expect(excludeDuplicateFiles([ 27 | { 28 | css: ['first.css'], 29 | matches: ['https://virgilio.it/*'], 30 | }, 31 | { 32 | css: ['first.css', 'second.css'], 33 | matches: ['https://example.com/*'], 34 | }, 35 | ])).toMatchSnapshot('it should remove duplicate stylesheets'); 36 | expect(warnMock).not.toHaveBeenCalled(); 37 | 38 | expect(excludeDuplicateFiles([ 39 | { 40 | js: ['alpha.js'], 41 | css: ['first.css'], 42 | matches: ['https://virgilio.it/*'], 43 | }, 44 | { 45 | css: ['first.css', 'second.css'], 46 | js: ['alpha.js'], 47 | matches: ['https://example.com/*'], 48 | }, 49 | ])).toMatchSnapshot('it should remove duplicate scripts and stylesheets'); 50 | expect(warnMock).not.toHaveBeenCalled(); 51 | 52 | expect(excludeDuplicateFiles([ 53 | { 54 | js: ['alpha.js'], 55 | css: ['first.css'], 56 | matches: ['https://virgilio.it/*'], 57 | }, 58 | { 59 | css: ['first.css'], 60 | js: ['alpha.js'], 61 | matches: ['https://example.com/*'], 62 | }, 63 | ])).toMatchSnapshot('it should drop the whole block if if empty'); 64 | expect(warnMock).not.toHaveBeenCalled(); 65 | }); 66 | test('warning', () => { 67 | expect(excludeDuplicateFiles([ 68 | { 69 | js: ['first.js'], 70 | matches: ['https://virgilio.it/*'], 71 | exclude_matches: ['https://*/admin/*'], 72 | }, 73 | { 74 | js: ['first.js'], 75 | exclude_matches: ['https://*/admin/*'], 76 | matches: ['https://*.example.com/*'], 77 | }, 78 | ])).toMatchSnapshot('it should not warn when a differentiator is the same'); 79 | expect(warnMock).not.toHaveBeenCalled(); 80 | 81 | expect(excludeDuplicateFiles([ 82 | { 83 | css: ['first.css'], 84 | matches: ['https://virgilio.it/*'], 85 | }, 86 | { 87 | css: ['first.css', 'second.css'], 88 | run_at: 'document_start', 89 | matches: ['https://example.com/*'], 90 | }, 91 | ])).toMatchSnapshot('it should warn when a differentiator is different'); 92 | expect(warnMock).toMatchSnapshot(); 93 | warnMock.mockClear(); 94 | 95 | expect(excludeDuplicateFiles([ 96 | { 97 | js: ['first.js', 'second.js'], 98 | matches: ['https://virgilio.it/*'], 99 | run_at: 'document_end', 100 | }, 101 | { 102 | js: ['first.js', 'second.js'], 103 | run_at: 'document_start', 104 | matches: ['https://example.com/*'], 105 | }, 106 | ])).toMatchSnapshot('it should warn when a differentiator is different'); 107 | expect(warnMock).toMatchSnapshot(); 108 | warnMock.mockClear(); 109 | 110 | expect(excludeDuplicateFiles([ 111 | { 112 | css: ['first.css'], 113 | matches: ['https://virgilio.it/*'], 114 | all_frames: true, 115 | }, 116 | { 117 | css: ['first.css', 'second.css'], 118 | matches: ['https://example.com/*'], 119 | }, 120 | ])).toMatchSnapshot('it should warn when a differentiator is different'); 121 | expect(warnMock).toMatchSnapshot(); 122 | warnMock.mockClear(); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /source/deduplicator.ts: -------------------------------------------------------------------------------- 1 | type ManifestContentScript = NonNullable[0]; 2 | 3 | // Not all keys are supported by the polyfill to begin with 4 | // matchAboutBlank: https://github.com/fregante/content-scripts-register-polyfill/issues/2 5 | // *Globs: https://github.com/fregante/content-scripts-register-polyfill/issues/35 6 | function getDifferentiators(c: ManifestContentScript): string { 7 | return JSON.stringify([c.all_frames, c.exclude_matches, c.run_at]); 8 | } 9 | 10 | /** 11 | Exclude same-file injections from a manifest `content_script` array due to a change in the polyfill. 12 | https://github.com/fregante/webext-dynamic-content-scripts/issues/62 13 | */ 14 | export function excludeDuplicateFiles( 15 | contentScripts: ManifestContentScript[], 16 | {warn = true} = {}, 17 | ): ManifestContentScript[] { 18 | const uniques = new Map(); 19 | const filterWarnAndAdd = (files: string[] | undefined, context: ManifestContentScript) => { 20 | if (!files) { 21 | return []; 22 | } 23 | 24 | return files.filter(file => { 25 | // If a content script has the same options, then it's 100% duplicate and safe to remove. 26 | // If it doesn't have the same options, then removing it can cause issues. 27 | const differentiators = getDifferentiators(context); 28 | 29 | // Exclude files from current script if they were already injected by another script 30 | if (uniques.has(file)) { 31 | // Warn the user in case this removal changes the behavior from what's expected 32 | if (warn && differentiators !== uniques.get(file)) { 33 | console.warn(`Duplicate file in the manifest content_scripts: ${file} \nMore info: https://github.com/fregante/webext-dynamic-content-scripts/issues/62`); 34 | } 35 | 36 | return false; 37 | } 38 | 39 | uniques.set(file, differentiators); 40 | return true; 41 | }); 42 | }; 43 | 44 | return contentScripts.flatMap(contentScript => { 45 | // To avoid confusion, drop the `matches` array since it's not used by `webext-dynamic-content-scripts` 46 | const {matches, ...cleanContentScript} = contentScript; 47 | 48 | const result = ({ 49 | ...cleanContentScript, 50 | js: filterWarnAndAdd(contentScript.js, contentScript), 51 | css: filterWarnAndAdd(contentScript.css, contentScript), 52 | }); 53 | 54 | // Drop entire block if complete duplicate (the `matches` array could be different, but that doesn't matter in `webext-dynamic-content-scripts`) 55 | return result.css.length + result.js.length === 0 ? [] : result; 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | import {init} from './lib.js'; 2 | 3 | init(); 4 | -------------------------------------------------------------------------------- /source/inject-to-existing-tabs.ts: -------------------------------------------------------------------------------- 1 | import {getTabsByUrl, injectContentScript} from 'webext-content-scripts'; 2 | 3 | type ManifestContentScripts = NonNullable; 4 | 5 | // May not be needed in the future in Firefox 6 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1458947 7 | export async function injectToExistingTabs( 8 | origins: string[], 9 | scripts: ManifestContentScripts, 10 | ) { 11 | const excludeMatches = scripts.flatMap(script => script.matches ?? []); 12 | return injectContentScript( 13 | await getTabsByUrl(origins, excludeMatches), 14 | scripts, 15 | {ignoreTargetErrors: true}, 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /source/lib.test.ts: -------------------------------------------------------------------------------- 1 | import {chrome} from 'jest-chrome'; 2 | import { 3 | describe, it, vi, beforeEach, expect, 4 | } from 'vitest'; 5 | import {queryAdditionalPermissions} from 'webext-permissions'; 6 | import {onExtensionStart} from 'webext-events'; 7 | import {init} from './lib.js'; 8 | import {injectToExistingTabs} from './inject-to-existing-tabs.js'; 9 | import {registerContentScript} from './register-content-script-shim.js'; 10 | 11 | type AsyncFunction = () => void | Promise; 12 | 13 | vi.mock('webext-permissions'); 14 | vi.mock('webext-events'); 15 | vi.mock('./register-content-script-shim.js'); 16 | vi.mock('./inject-to-existing-tabs.js'); 17 | 18 | const baseManifest: chrome.runtime.Manifest = { 19 | name: 'required', 20 | manifest_version: 3, 21 | version: '0.0.0', 22 | content_scripts: [ 23 | { 24 | js: ['script.js'], 25 | matches: ['https://content-script.example.com/*'], 26 | }, 27 | ], 28 | permissions: ['storage'], 29 | host_permissions: ['https://permission-only.example.com/*'], 30 | optional_host_permissions: ['*://*/*'], 31 | }; 32 | 33 | const additionalPermissions: Required = { 34 | origins: ['https://granted.example.com/*'], 35 | permissions: [], 36 | }; 37 | 38 | const queryAdditionalPermissionsMock = vi.mocked(queryAdditionalPermissions); 39 | const injectToExistingTabsMock = vi.mocked(injectToExistingTabs); 40 | const registerContentScriptMock = vi.mocked(registerContentScript); 41 | 42 | const callbacks = new Set(); 43 | 44 | vi.mocked(onExtensionStart.addListener).mockImplementation((callback: AsyncFunction) => { 45 | callbacks.add(callback); 46 | }); 47 | 48 | async function simulateExtensionStart() { 49 | await Promise.all(Array.from(callbacks).map(async callback => callback())); 50 | callbacks.clear(); 51 | } 52 | 53 | beforeEach(() => { 54 | registerContentScriptMock.mockClear(); 55 | injectToExistingTabsMock.mockClear(); 56 | queryAdditionalPermissionsMock.mockResolvedValue(additionalPermissions); 57 | chrome.runtime.getManifest.mockReturnValue(baseManifest); 58 | }); 59 | 60 | describe('init', () => { 61 | it('it should register the listeners and start checking permissions', async () => { 62 | init(); 63 | await simulateExtensionStart(); 64 | 65 | expect(queryAdditionalPermissionsMock).toHaveBeenCalled(); 66 | expect(injectToExistingTabsMock).toHaveBeenCalledWith( 67 | additionalPermissions.origins, 68 | [{css: [], js: ['script.js']}], 69 | ); 70 | 71 | // TODO: https://github.com/extend-chrome/jest-chrome/issues/20 72 | // expect(chrome.permissions.onAdded.addListener).toHaveBeenCalledOnce(); 73 | // expect(chrome.permissions.onRemoved.addListener).toHaveBeenCalledOnce(); 74 | }); 75 | 76 | it('it should throw if no content scripts exist at all', async () => { 77 | const manifest = structuredClone(baseManifest); 78 | delete manifest.content_scripts; 79 | chrome.runtime.getManifest.mockReturnValue(manifest); 80 | init(); 81 | await expect(simulateExtensionStart).rejects 82 | .toThrowErrorMatchingInlineSnapshot('[Error: webext-dynamic-content-scripts tried to register scripts on the new host permissions, but no content scripts were found in the manifest.]'); 83 | }); 84 | }); 85 | 86 | describe('init - registerContentScript', () => { 87 | it('should register the manifest scripts on new permissions', async () => { 88 | init(); 89 | await simulateExtensionStart(); 90 | expect(registerContentScriptMock).toMatchSnapshot(); 91 | }); 92 | 93 | it('should register the manifest scripts on multiple new permissions', async () => { 94 | queryAdditionalPermissionsMock.mockResolvedValue({ 95 | origins: [ 96 | 'https://granted.example.com/*', 97 | 'https://granted-more.example.com/*', 98 | ], 99 | permissions: [], 100 | }); 101 | 102 | init(); 103 | await simulateExtensionStart(); 104 | expect(registerContentScriptMock).toMatchSnapshot(); 105 | }); 106 | 107 | it('should register multiple manifest scripts on new permissions', async () => { 108 | const manifest = structuredClone(baseManifest); 109 | manifest.content_scripts!.push({ 110 | js: ['otherScript.js'], 111 | matches: ['https://content-script-extra.example.com/*'], 112 | }); 113 | chrome.runtime.getManifest.mockReturnValue(manifest); 114 | 115 | init(); 116 | await simulateExtensionStart(); 117 | expect(registerContentScriptMock).toMatchSnapshot(); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /source/lib.ts: -------------------------------------------------------------------------------- 1 | import {queryAdditionalPermissions} from 'webext-permissions'; 2 | import {onExtensionStart} from 'webext-events'; 3 | import {excludeDuplicateFiles} from './deduplicator.js'; 4 | import {injectToExistingTabs} from './inject-to-existing-tabs.js'; 5 | import {registerContentScript} from './register-content-script-shim.js'; 6 | 7 | const registeredScripts = new Map< 8 | string, 9 | Promise 10 | >(); 11 | 12 | // In Firefox, paths in the manifest are converted to full URLs under `moz-extension://` but browser.contentScripts expects exclusively relative paths 13 | function makePathRelative(file: string): string { 14 | return new URL(file, location.origin).pathname; 15 | } 16 | 17 | function getContentScripts() { 18 | const {content_scripts: rawManifest, manifest_version: manifestVersion} = chrome.runtime.getManifest(); 19 | 20 | if (!rawManifest) { 21 | throw new Error('webext-dynamic-content-scripts tried to register scripts on the new host permissions, but no content scripts were found in the manifest.'); 22 | } 23 | 24 | return excludeDuplicateFiles(rawManifest, {warn: manifestVersion === 2}); 25 | } 26 | 27 | // Automatically register the content scripts on the new origins 28 | async function registerOnOrigins( 29 | origins: string[], 30 | contentScripts: ReturnType, 31 | ): Promise { 32 | if (origins.length === 0) { 33 | return; 34 | } 35 | 36 | // Register one at a time to allow removing one at a time as well 37 | for (const origin of origins) { 38 | for (const config of contentScripts) { 39 | const registeredScript = registerContentScript({ 40 | // Always convert paths here because we don't know whether Firefox MV3 will accept full URLs 41 | js: config.js?.map(file => makePathRelative(file)), 42 | css: config.css?.map(file => makePathRelative(file)), 43 | allFrames: config.all_frames, 44 | matches: [origin], 45 | excludeMatches: config.matches, 46 | runAt: config.run_at as browser.extensionTypes.RunAt, 47 | }); 48 | registeredScripts.set(origin, registeredScript); 49 | } 50 | } 51 | } 52 | 53 | async function handleNewPermissions({origins}: chrome.permissions.Permissions) { 54 | await enableOnOrigins(origins); 55 | } 56 | 57 | async function handledDroppedPermissions({origins}: chrome.permissions.Permissions) { 58 | if (!origins?.length) { 59 | return; 60 | } 61 | 62 | for (const [origin, scriptPromise] of registeredScripts) { 63 | if (origins.includes(origin)) { 64 | // eslint-disable-next-line no-await-in-loop 65 | const script = await scriptPromise; 66 | void script.unregister(); 67 | } 68 | } 69 | } 70 | 71 | async function enableOnOrigins(origins: string[] | undefined) { 72 | if (!origins?.length) { 73 | return; 74 | } 75 | 76 | const contentScripts = getContentScripts(); 77 | await Promise.all([ 78 | injectToExistingTabs(origins, contentScripts), 79 | registerOnOrigins(origins, contentScripts), 80 | ]); 81 | } 82 | 83 | async function registerExistingOrigins() { 84 | const {origins} = await queryAdditionalPermissions({ 85 | strictOrigins: false, 86 | }); 87 | 88 | await enableOnOrigins(origins); 89 | } 90 | 91 | export function init() { 92 | chrome.permissions.onRemoved.addListener(handledDroppedPermissions); 93 | chrome.permissions.onAdded.addListener(handleNewPermissions); 94 | onExtensionStart.addListener(registerExistingOrigins); 95 | } 96 | -------------------------------------------------------------------------------- /source/register-content-script-shim.ts: -------------------------------------------------------------------------------- 1 | import registerContentScriptPonyfill from 'content-scripts-register-polyfill/ponyfill.js'; 2 | 3 | export const chromeRegister = globalThis.chrome?.scripting?.registerContentScripts; 4 | export const firefoxRegister = globalThis.browser?.contentScripts?.register; 5 | 6 | export async function registerContentScript( 7 | contentScript: Omit & {matches: string[]}, 8 | ): Promise { 9 | if (chromeRegister) { 10 | const id = 'webext-dynamic-content-script-' + JSON.stringify(contentScript); 11 | try { 12 | await chromeRegister([{ 13 | ...contentScript, 14 | id, 15 | }]); 16 | } catch (error) { 17 | if (!(error as Error)?.message.startsWith('Duplicate script ID')) { 18 | throw error; 19 | } 20 | } 21 | 22 | return { 23 | unregister: async () => chrome.scripting.unregisterContentScripts({ids: [id]}), 24 | }; 25 | } 26 | 27 | const firefoxContentScript = { 28 | ...contentScript, 29 | js: contentScript.js?.map(file => ({file})), 30 | css: contentScript.css?.map(file => ({file})), 31 | } as const; 32 | 33 | if (firefoxRegister) { 34 | return firefoxRegister(firefoxContentScript); 35 | } 36 | 37 | return registerContentScriptPonyfill(firefoxContentScript); 38 | } 39 | -------------------------------------------------------------------------------- /source/utils.test.ts: -------------------------------------------------------------------------------- 1 | import {chrome} from 'jest-chrome'; 2 | import { 3 | test, vi, beforeAll, assert, 4 | } from 'vitest'; 5 | import {queryAdditionalPermissions} from 'webext-permissions'; 6 | import {isContentScriptStaticallyRegistered, isContentScriptDynamicallyRegistered, isContentScriptRegistered} from './utils.js'; 7 | 8 | vi.mock('webext-permissions'); 9 | 10 | const manifest: chrome.runtime.Manifest = { 11 | name: 'required', 12 | manifest_version: 2, 13 | version: '0.0.0', 14 | content_scripts: [ 15 | { 16 | js: [ 17 | 'script.js', 18 | ], 19 | matches: [ 20 | 'https://content-script.example.com/*', 21 | ], 22 | }, 23 | ], 24 | permissions: [ 25 | 'https://permission-only.example.com/*', 26 | ], 27 | optional_permissions: [ 28 | '*://*/*', 29 | ], 30 | }; 31 | 32 | const queryAdditionalPermissionsMock = vi.mocked(queryAdditionalPermissions); 33 | 34 | beforeAll(() => { 35 | queryAdditionalPermissionsMock.mockImplementation(async () => ({ 36 | origins: ['https://granted.example.com/*'], 37 | permissions: [], 38 | })); 39 | chrome.runtime.getManifest.mockImplementation(() => manifest); 40 | }); 41 | 42 | test('isContentScriptStaticallyRegistered', () => { 43 | const s = isContentScriptStaticallyRegistered; 44 | assert.isTrue( 45 | s('https://content-script.example.com'), 46 | 'it should find a script in matches', 47 | ); 48 | assert.isTrue( 49 | s('https://content-script.example.com/sub-page'), 50 | 'it should match a sub-page of matches', 51 | ); 52 | 53 | assert.isFalse( 54 | s('https://nope.content-script.example.com'), 55 | 'it should not match a subdomain that is not in patches', 56 | ); 57 | assert.isFalse( 58 | s('https://permission-only.example.com'), 59 | 'it should ignore non-content script permissions', 60 | ); 61 | 62 | assert.isFalse( 63 | s('https://granted.example.com/granted'), 64 | 'it should ignore dynamic permissions', 65 | ); 66 | assert.isFalse( 67 | s('https://www.example.com/page'), 68 | 'it should ignore unrelated sites', 69 | ); 70 | assert.isFalse( 71 | s('https://example.com/page'), 72 | 'it should ignore unrelated sites', 73 | ); 74 | }); 75 | 76 | test('isContentScriptDynamicallyRegistered', async () => { 77 | const s = isContentScriptDynamicallyRegistered; 78 | 79 | assert.isTrue( 80 | await s('https://granted.example.com'), 81 | 'it should find a granted host permission unrelated sites'); 82 | 83 | assert.isFalse( 84 | await s('https://content-script.example.com'), 85 | 'it should not use an origin in matches'); 86 | 87 | assert.isFalse( 88 | await s('https://www.example.com/page'), 89 | 'it should ignore unrelated sites'); 90 | assert.isFalse( 91 | await s('https://example.com/page'), 92 | 'it should ignore unrelated sites'); 93 | }); 94 | 95 | test('isContentScriptRegistered', async () => { 96 | const s = isContentScriptRegistered; 97 | assert.equal( 98 | await s('https://content-script.example.com'), 99 | 'static', 100 | 'it should find a script in matches', 101 | ); 102 | assert.equal( 103 | await s('https://content-script.example.com/sub-page'), 104 | 'static', 105 | 'it should match a sub-page of matches', 106 | ); 107 | 108 | assert.equal( 109 | await s('https://permission-only.example.com'), 110 | false, 111 | 'it should ignore non-content script permissions', 112 | ); 113 | assert.equal( 114 | await s('https://example.com/page'), 115 | false, 116 | 'it should ignore unrelated sites', 117 | ); 118 | }); 119 | -------------------------------------------------------------------------------- /source/utils.ts: -------------------------------------------------------------------------------- 1 | import {queryAdditionalPermissions} from 'webext-permissions'; 2 | import {patternToRegex} from 'webext-patterns'; 3 | 4 | export function isContentScriptStaticallyRegistered(url: string): boolean { 5 | return Boolean(chrome.runtime 6 | .getManifest() 7 | .content_scripts 8 | ?.flatMap(script => script.matches!) 9 | .some(pattern => patternToRegex(pattern).test(url))); 10 | } 11 | 12 | export async function isContentScriptDynamicallyRegistered(url: string): Promise { 13 | // Injected by `webext-dynamic-content-scripts` 14 | const {origins} = await queryAdditionalPermissions({ 15 | strictOrigins: false, 16 | }); 17 | 18 | // Do not replace the 2 calls above with `permissions.getAll` because it might also include hosts that are permitted by the manifest but have no content script registered 19 | return patternToRegex(...origins).test(url); 20 | } 21 | 22 | /** 23 | Checks whether a URL will have the content scripts automatically injected. 24 | It returns a promise that resolves with string indicating the type of injection ('static' or 'dynamic') or `false` if it won't be injected on the specified URL. 25 | */ 26 | export async function isContentScriptRegistered( 27 | url: string, 28 | ): Promise<'static' | 'dynamic' | false> { 29 | if (isContentScriptStaticallyRegistered(url)) { 30 | return 'static'; 31 | } 32 | 33 | if (await isContentScriptDynamicallyRegistered(url)) { 34 | return 'dynamic'; 35 | } 36 | 37 | return false; 38 | } 39 | -------------------------------------------------------------------------------- /test/browser.js: -------------------------------------------------------------------------------- 1 | /* globals page */ 2 | /* Keep file in sync with https://github.com/fregante/content-scripts-register-polyfill/blob/main/test/test.js */ 3 | 4 | import {describe, beforeAll, it} from '@jest/globals'; 5 | import {expect} from 'expect-puppeteer'; 6 | 7 | async function expectToNotMatchElement(window, selector) { 8 | try { 9 | await expect(window).toMatchElement(selector); 10 | throw new Error(`Unexpected "${selector}" element found`); 11 | } catch (error) { 12 | if (!error.message.startsWith(`Element ${selector} not found`)) { 13 | throw error.message; 14 | } 15 | } 16 | } 17 | 18 | // TODO: Test CSS injection 19 | 20 | // "Static" will test the manifest-based injection 21 | // "Dynamic" is the URL we're injecting. The "additional permission" is faked via a mock 22 | const pages = [ 23 | ['static', 'https://static-ephiframe.vercel.app/Parent-page?iframe=./Framed-page'], 24 | ['dynamic', 'https://dynamic-ephiframe.vercel.app/Parent-page?iframe=./Framed-page'], 25 | ]; 26 | 27 | const nestedPages = [ 28 | ['static', './Framed-page'], 29 | ['dynamic', 'https://dynamic-ephiframe.vercel.app/Framed-page'], 30 | ]; 31 | 32 | describe.each(pages)('%s: tab', (title, url) => { 33 | beforeAll(async () => { 34 | await page.goto(url); 35 | }); 36 | 37 | it('should load page', async () => { 38 | await expect(page).toMatchTextContent('Parent page'); 39 | }); 40 | 41 | it('should load the content script, once', async () => { 42 | await expect(page).toMatchElement('.web-ext'); 43 | await expectToNotMatchElement(page, '.web-ext + .web-ext'); 44 | }); 45 | 46 | it('should load the content script after a reload, once', async () => { 47 | await page.reload(); 48 | await expect(page).toMatchElement('.web-ext'); 49 | await expectToNotMatchElement(page, '.web-ext + .web-ext'); 50 | }); 51 | }); 52 | 53 | let iframe; 54 | describe.each(pages)('%s: iframe', (title, url) => { 55 | beforeAll(async () => { 56 | await page.goto(url); 57 | const elementHandle = await page.waitForSelector('iframe'); 58 | iframe = await elementHandle.contentFrame(); 59 | }); 60 | it('should load iframe page', async () => { 61 | await expect(iframe).toMatchTextContent('Framed page'); 62 | }); 63 | 64 | it('should load the content script, once', async () => { 65 | await expect(iframe).toMatchElement('.web-ext'); 66 | await expectToNotMatchElement(iframe, '.web-ext + .web-ext'); 67 | }); 68 | 69 | it('should load the content script after a reload, once', async () => { 70 | await iframe.goto(iframe.url()); 71 | await expect(iframe).toMatchElement('.web-ext'); 72 | await expectToNotMatchElement(iframe, '.web-ext + .web-ext'); 73 | }); 74 | }); 75 | 76 | let iframeOfExcludedParent; 77 | describe.each(nestedPages)('%s: excludeMatches', (title, url) => { 78 | beforeAll(async () => { 79 | await page.goto('https://partial-ephiframe.vercel.app/Excluded-page?iframe=' + encodeURIComponent(url)); 80 | const elementHandle = await page.waitForSelector('iframe'); 81 | iframeOfExcludedParent = await elementHandle.contentFrame(); 82 | }); 83 | 84 | it('should load page and iframe', async () => { 85 | await expect(page).toMatchElement('title', {text: 'Excluded page'}); 86 | await expect(iframeOfExcludedParent).toMatchElement('title', {text: 'Framed page'}); 87 | }); 88 | 89 | it('should load the content script only in iframe, once', async () => { 90 | await expectToNotMatchElement(page, '.web-ext'); 91 | await expect(iframeOfExcludedParent).toMatchElement('.web-ext'); 92 | await expectToNotMatchElement(iframeOfExcludedParent, '.web-ext + .web-ext'); 93 | }); 94 | }); 95 | 96 | // Uncomment to hold the browser open a little longer 97 | // import {jest} from '@jest/globals'; 98 | // jest.setTimeout(10000000); 99 | // describe('hold', () => { 100 | // it('should wait forever', async () => { 101 | // await new Promise(resolve => setTimeout(resolve, 1000000)) 102 | // }) 103 | // }); 104 | -------------------------------------------------------------------------------- /test/demo-extension/mv2/background.js: -------------------------------------------------------------------------------- 1 | import 'webext-dynamic-content-scripts'; 2 | 3 | console.log('Background loaded'); 4 | -------------------------------------------------------------------------------- /test/demo-extension/mv2/content.css: -------------------------------------------------------------------------------- 1 | body:before { 2 | content: 'CSS LOADED'; 3 | padding: 4px; 4 | font-size: 20px; 5 | background: silver; 6 | display: inline-block; 7 | } 8 | -------------------------------------------------------------------------------- /test/demo-extension/mv2/content.js: -------------------------------------------------------------------------------- 1 | document.body.insertAdjacentHTML( 2 | 'afterBegin', 3 | ` 4 | 10 | JS LOADED 11 | 12 | `, 13 | ); 14 | console.log('Content script loaded', new Date()); 15 | -------------------------------------------------------------------------------- /test/demo-extension/mv2/local.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Local page 5 | 6 | 7 | This is a local page test 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/demo-extension/mv2/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webext-dynamic-content-scripts", 3 | "version": "0.0.0", 4 | "manifest_version": 2, 5 | "permissions": [ 6 | "webNavigation", 7 | "https://dynamic-ephiframe.vercel.app/*", 8 | "https://accepted-ephiframe.vercel.app/*" 9 | ], 10 | "background": { 11 | "scripts": ["background.js"] 12 | }, 13 | "browser_action": {}, 14 | "content_scripts": [ 15 | { 16 | "all_frames": true, 17 | "matches": ["https://static-ephiframe.vercel.app/*"], 18 | "js": ["content.js"], 19 | "css": ["content.css"] 20 | }, 21 | { 22 | "all_frames": true, 23 | "matches": ["https://partial-ephiframe.vercel.app/*"], 24 | "exclude_matches": [ 25 | "https://partial-ephiframe.vercel.app/Excluded*" 26 | ], 27 | "js": ["content.js"], 28 | "css": ["content.css"] 29 | } 30 | ], 31 | "web_accessible_resources": [ 32 | "*.html" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /test/demo-extension/mv3/background.js: -------------------------------------------------------------------------------- 1 | import 'webext-dynamic-content-scripts'; 2 | 3 | console.log('Background loaded'); 4 | -------------------------------------------------------------------------------- /test/demo-extension/mv3/content.css: -------------------------------------------------------------------------------- 1 | body:before { 2 | content: 'CSS LOADED'; 3 | padding: 4px; 4 | font-size: 20px; 5 | background: silver; 6 | display: inline-block; 7 | } 8 | -------------------------------------------------------------------------------- /test/demo-extension/mv3/content.js: -------------------------------------------------------------------------------- 1 | document.body.insertAdjacentHTML( 2 | 'afterBegin', 3 | ` 4 | 10 | JS LOADED 11 | 12 | `, 13 | ); 14 | console.log('Content script loaded', new Date()); 15 | -------------------------------------------------------------------------------- /test/demo-extension/mv3/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webext-dynamic-content-scripts-mv3", 3 | "version": "0.0.0", 4 | "manifest_version": 3, 5 | "permissions": ["webNavigation", "scripting", "storage"], 6 | "host_permissions": [ 7 | "https://dynamic-ephiframe.vercel.app/*", 8 | "https://accepted-ephiframe.vercel.app/*" 9 | ], 10 | "background": { 11 | "service_worker": "background.js", 12 | "type": "module" 13 | }, 14 | "action": {}, 15 | "content_scripts": [ 16 | { 17 | "all_frames": true, 18 | "matches": ["https://static-ephiframe.vercel.app/*"], 19 | "js": ["content.js"], 20 | "css": ["content.css"] 21 | }, 22 | { 23 | "all_frames": true, 24 | "matches": ["https://partial-ephiframe.vercel.app/*"], 25 | "exclude_matches": [ 26 | "https://partial-ephiframe.vercel.app/Excluded*" 27 | ], 28 | "js": ["content.js"], 29 | "css": ["content.css"] 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /test/demo-extension/webext-permissions.js: -------------------------------------------------------------------------------- 1 | // Mock to fake a user-granted permission 2 | export function queryAdditionalPermissions() { 3 | return { 4 | origins: ['https://dynamic-ephiframe.vercel.app/*'], 5 | permissions: [], 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "distribution", 5 | "target": "ES2022", 6 | "module": "ES2022", 7 | "moduleResolution": "node", 8 | }, 9 | "include": [ 10 | "source", 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /usage-mv2.md: -------------------------------------------------------------------------------- 1 | # webext-dynamic-content-scripts on Manifest v2 2 | 3 | _For Manifest v3, refer to the [Usage section on the main readme](readme.md)._ 4 | 5 | Include `webext-dynamic-content-scripts` as a background script and add `optional_permissions` to allow new permissions to be added. The scripts defined in `content_scripts` will be added on the new domains (`matches` will be replaced) 6 | 7 | ```json 8 | // example manifest.json 9 | { 10 | "optional_permissions": ["*://*/*"], 11 | "background": { 12 | "scripts": [ 13 | "webext-dynamic-content-scripts.js", 14 | "background.js" 15 | ] 16 | }, 17 | "content_scripts": [ 18 | { 19 | "matches": ["https://github.com/*"], 20 | "css": ["content.css"], 21 | "js": ["content.js"] 22 | } 23 | ] 24 | } 25 | ``` 26 | 27 | ## Permissions for Chrome Manifest v2 28 | 29 | This section does not apply to Firefox users. 30 | 31 | In order to use `all_frames: true` you should the add [`webNavigation` permission](https://developer.chrome.com/docs/extensions/reference/webNavigation/). Without it, `all_frames: true` won’t work: 32 | 33 | - when the iframe is not on the same domain as the top frame 34 | - when the iframe reloads or navigates to another page 35 | - when the iframe is not ready when `runAt` is configured to run (`runAt: 'start'` is unlikely to work) 36 | 37 | If available, the `webNavigation` API will be automatically used in every situation for better performance. 38 | -------------------------------------------------------------------------------- /utils.d.ts: -------------------------------------------------------------------------------- 1 | // Temporary entry point until the `exports` key is added to the package.json 2 | export * from './distribution/utils.d'; 3 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | // Temporary entry point until the `exports` key is added to the package.json 2 | export * from './distribution/utils.js'; 3 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | setupFiles: [ 6 | './vitest.setup.js', 7 | ], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /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 | globalThis.location = {origin: 'chrome://abc/'}; 9 | Object.freeze(globalThis.location); 10 | --------------------------------------------------------------------------------