CRXJS doesn't work with content script in "MAIN" world
Build tool
Vite
Where do you see the problem?
- [X] In the browser
- [ ] In the terminal
Describe the bug
When my manifest (V3) has a content script with "world": "MAIN", the following error appears in the Chrome console:
TypeError: Cannot read properties of undefined (reading 'getURL')
at my_content_script.js-loader.js:13:22
In the Sources tab, I see that my_content_script.js-loader.js contains:
(function () {
'use strict';
const injectTime = performance.now();
(async () => {
if ("")
await import(
/* @vite-ignore */
chrome.runtime.getURL("")
);
await import(
/* @vite-ignore */
chrome.runtime.getURL("vendor/vite-client.js")
);
const { onExecute } = await import(
/* @vite-ignore */
chrome.runtime.getURL("src/my_content_script.js")
);
onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } });
})().catch(console.error);
})();
CRXJS (or Vite?) is trying to call chrome.runtime.getURL to get the JS paths to import. However, chrome.runtime is not defined in the MAIN world.
These imports should be rewritten to first check for the existence of chrome.runtime β if it doesn't exist, then some sort of shim should be defined for chrome.runtime.getURL to get the desired paths.
As one way to do this, CRXJS could:
- Automatically add another content script in the isolated world
- Establish bidirectional communication between the two worlds (via events on
document.documentElement) - Send a
CRXJS_getRootURLevent to from MAIN to the isolated world - Handle the event in the isolated world by calling
chrome.runtime.getURL('')and sending it back to MAIN - Awaiting that response in the MAIN, then using it to define a shim for
chrome.runtime.getURL, then continuing with the imports as shown in the code block above.
Reproduction
mainfest.json:
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/my_content_script.js"],
"run_at": "document_start",
"world": "MAIN"
}
]
Logs
TypeError: Cannot read properties of undefined (reading 'getURL')
at my_content_script.js-loader.js:13:22
System Info
System:
OS: macOS 12.6.5
CPU: (8) arm64 Apple M1
Memory: 85.05 MB / 16.00 GB
Shell: 5.8.1 - /bin/zsh
Binaries:
Node: 16.14.2 - /usr/local/bin/node
Yarn: 1.22.15 - /opt/homebrew/bin/yarn
npm: 8.3.0 - /opt/homebrew/bin/npm
Browsers:
Firefox: 72.0.2
Safari: 16.4.1
npmPackages:
@crxjs/vite-plugin: 2.0.0-beta.16 => 2.0.0-beta.16
vite: ^4.1.1 => 4.1.1
Severity
blocking an upgrade
I encountered this issue also and used a workaround.
In manifest.json add the scripting permission and add either the activeTab permission or the allowed host with the host_permissions key.
In background.ts register the content script using chrome.scripting.registerContentScripts
chrome.scripting.registerContentScripts([
{
id: 'XMLOverride',
js: ['src/content/XMLOverride.js'],
matches: ['https://*.example.com/*'],
runAt: 'document_start',
world: 'MAIN',
},
]);
Having the same issue here as well.
Using the workaround @adam-s provided. Works okay, but it doesn't hot-reload the file changes and I have to build + reload the extension to see changes, which is not ideal. Would love to see this fixed. π
Just putting it here in case it helps any of you, I found this post written by @jacksteamdev where talks about MAIN world script. But, he mentions injecting script through the ISOLATED content script into the DOM. Which feels like a roundabout way to go about it. Having the dreict Manifest way would be amazing. But at least with that approach, I now get all the dev-tool magic working.
@faahim Thanks for posting that link. As you said, it is a roundabout way to go about it. Also, that post also seems to assume that all content scripts are in the isolated world, ignoring the option to set "world": "MAIN" on a content script in the manifest. The post says, "Unfortunately, the loader relies on the Chrome API only available to content scripts," but really, the loader relies on Chrome API only available in isolated content scripts.
All that said, I realized soon after I posted this GitHub issue that all content scripts in the manifest of a CRXJS project automatically get wrapped in the Vite loader code, and since that code relies on Chrome Extension API that is not available in main world, those main-world content scripts simply won't work from a CRXJS project manifest.
Perhaps @jacksteamdev could add an option for us to specify which content scripts in the manifest should not be wrapped with Vite loader code. However:
- such an option would be non-standard in the Chrome extension manifest schema,
- all such scripts would not get Vite or HMR, and
- it's not clear how TS scripts would get transpired to JS if Vite was not involved, unless CRXJS also used
tsc, Babel, or some other transpiler.
Even if there was a way to make Vite/HMR loader code work in the main world, there's another problem: using the Vite loader introduces a delay to code execution, which prevents those content scripts from running before other scripts on the page. This means that those content scripts will not be effective in overriding built-in functions used by other scripts on the page, and therefore a lot of potential use cases of Chrome extensions are lost by that Vite delay.
In the end, I switched to using Webpack and reloading my extension manually. It's a bummer to lose Vite and HMR, but at least I could be sure that my extension scripts will load first and be able to do everything in the expected order.
@jacksteamdev I'm interested in discussing this further and trying to find solutions to the above concerns if you have time.
Even if there was a way to make Vite/HMR loader code work in the main world, there's another problem: using the Vite loader introduces a delay to code execution, which prevents those content scripts from running before other scripts on the page. This means that those content scripts will not be effective in overriding built-in functions used by other scripts on the page, and therefore a lot of potential use cases of Chrome extensions are lost by that Vite delay.
Correct, @svicalifornia!
This is exactly the issue I'm facing with the approach described in the post. The project I'm doing involves wrapping the built-in fetch() which NEEDS to happen before the page script runs and I simply can't guarantee that with CRXJS.
I love all the other aspects of CRXJS so much that it feels painful to not be able to work with it.
I hope @jacksteamdev will take a minute to shed some more light on this. π
I found a method of dynamically loading scripts that will execute extension code before the rest of the page is loaded. This should suffice for intercepting fetch API calls, websocket connections and the like.
- Create your scripts for the MAIN world
- Create a world content loader script based on the instructions in this devlog (thanks @faahim). Import the script from step 1.
- In the manifest, add a content script entry to run the loader script from step 2 at
document_start(see below)
/* manifest.json */
{
/*...*/
"content_scripts": [
/*...*/
{
"matches": ["myfilter.url.example.com"],
"js": ["path/to/loader_script.js"],
"run_at": "document_start"
}
]
@svicalifornia unfortunately this won't solve the issues with vite or HMR. My gut feeling is that running across multiple window scopes may not be something that either module can handle, though I haven't investigated thoroughly.
My approach to this so far has been to keep the world scripts as lightweight as possible and emitting events on the document object from the world script and listening for them in the extension (note that the document is shared, the window is not).
Hi @gf-skorpach π
Thanks for these pointers. While this works, it still isn't ideal. I could be totally wrong here, but the way I understand it, when you add a loader script via the manifest with document_start directive, it ensures the loader script will be executed at the document start, not the actual script your loader is going to inject. Now, I've tried this and it works, but I just don't feel very confident shipping it on production cause it's not guaranteed.
I think if we're just looking for inserting script in the MAIN world AND guarantee that it runs before the page script, using registerContentScripts through the background script is a much more straightforward and cleaner way. But with the downside of no Vite/HMR.
Ideally, we want to have both, i.e. inserting MAIN world script directly through the manifest and having Vite/HMR for the script. π
In the end, I would prefer having MAIN world injection with static declaration (manifest.json) and just give up on having Vite/HMR functionality.
In my personal fork I ended up doing this: if content_scripts entry has "world": "MAIN", load it as module type (without HMR functionality), without loader, otherwise by default act like it needs loader and "world": "ISOLATED".
This solution is probably not desirable for @crxjs/vite-plugin...
@adam-s thank you for workaround. Is it possible to still have the script processed by Vite even thought without hot reloading?
@flexchar
I tried creating a custom plugin as a hack to watch for changes in vite.config.js so that the content script could be compiled from ts to js and moved to the .dist folder. However, I ran into a problem where the plugin runs and compiles after crx runs. The content script injected into the main world needs to be declared in manifest.json web_accessable_resources. Because the file isn't available when crx runs, crx throws an error not knowing the file will be made available later in the compile process. Perhaps have a placeholder file and put the content scripts which you want to compiled and moved into a different folder.
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import manifest from './manifest.json';
import { rollup, watch } from 'rollup';
import typescript from '@rollup/plugin-typescript';
const customPlugin = {
name: 'custom-plugin',
buildStart() {
const watcher = watch({
input: './src/content/example.ts',
plugins: [
typescript({ allowImportingTsExtensions: true, noEmit: false }),
],
output: {
file: './dist/src/content/example.js',
format: 'es',
sourcemap: false,
},
});
},
};
export default defineConfig({
//@ts-ignore
plugins: [
customPlugin,
svelte({
onwarn: (warning, handler) => {
const { code, frame } = warning;
if (code === 'css-unused-selector') return;
},
emitCss: false,
}),
crx({ manifest }),
],
server: {
port: 5173,
strictPort: true,
hmr: {
port: 5173,
},
},
build: {
rollupOptions: {
output: {
sourcemap: 'inline',
},
},
},
});
Hi, first of all I want to thank you for this project.
Couldn't we instruct vite for content-scripts with "world main" to bundle all dependencies in one single file without any import/exports? This way, everything else that is needed, such as auto-reload, could also work.
Here is how I resolved it, I let the content.js remain in ISOLATED world , and then inject my other script in the MAIN world using the manifest.json
{
"manifest_version": 3,
"name": "ExampleBot",
"version" : "1.0",
"description": "Automatically place bids once they are available",
"permissions": ["activeTab", "tabs", "storage", "scripting"],
"background":{
"service_worker":"./background.js"
},
"content_scripts":[
{
"content_security_policy": "script-src 'self' https://localhost:* 'nonce-randomNonceValue'; object-src 'self'",
"matches":["https://place_your_url_here.com/*/*"],
"js":["./content.js","./jquery.min.js"],
"run_at": "document_idle",
"world": "ISOLATED"
},
{
"content_security_policy": "script-src 'self' https://localhost:* 'nonce-randomNonceValue'; object-src 'self'",
"matches":["https://e*.com/*/*/*"],
"js":["./extractGlobals.js"],
"run_at": "document_idle",
"world": "MAIN"
}
],
"host_permissions": [
"https://place_your_url_here_of_site_to_inject.com/product/orders/*"
]
}
The code within ./extractGlobals.js will be executed at document_idle will be available in ISOLATED world from MAIN world where the content script is running at.
If you want to extract the background.js or service to grab globals from MAIN land where the content js cannot run, you can use background.js to send a request to inject the file using a Promise like this and you receive the globals in main page, remember you have to specify the globals that are to be returned.
// Listen for messages from the content script
//background.js
// Listen for messages from the content script
chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
if (message.action === "extractGlobals") {
// Execute the script in the main world
chrome.scripting.executeScript({
target: { tabId: sender.tab.id },
files: ["extractGlobals.js"],
world: "MAIN"
}).then(result => {
// Script execution successful, extract globals from the result
const globals = result[0];
sendResponse({ success: true, globals: globals });
}).catch(error => {
// Error executing the script
console.error("Error injecting script:", error.message);
sendResponse({ success: false, error: error.message });
});
// Return true to indicate that sendResponse will be called asynchronously
return true;
}
});
Bumping this. I would love for a way for the loader script to be able to inject in main without having to do a workaround. Potentially run all the loader script in ISOLATED but detect and inject the script into MAIN when appropriate.
I have the similar issue.so I try to remove the βworldβ:βMAINβ to fix it and it works.