├── .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 |
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 [](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 |
--------------------------------------------------------------------------------