Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CRXJS doesn't work with content script in "MAIN" world #695

Open
1 of 2 tasks
svicalifornia opened this issue Apr 25, 2023 · 17 comments
Open
1 of 2 tasks

CRXJS doesn't work with content script in "MAIN" world #695

svicalifornia opened this issue Apr 25, 2023 · 17 comments
Labels
docs Documentation

Comments

@svicalifornia
Copy link

svicalifornia commented Apr 25, 2023

Build tool

Vite

Where do you see the problem?

  • 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_getRootURL event 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

@adam-s
Copy link

adam-s commented May 11, 2023

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',
  },
]);

@faahim
Copy link

faahim commented May 16, 2023

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. 🙂

@faahim
Copy link

faahim commented May 17, 2023

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.

@svicalifornia
Copy link
Author

@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:

  1. such an option would be non-standard in the Chrome extension manifest schema,
  2. all such scripts would not get Vite or HMR, and
  3. 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.

@faahim
Copy link

faahim commented May 27, 2023

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. 🙏

@gf-skorpach
Copy link

gf-skorpach commented May 27, 2023

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.

  1. Create your scripts for the MAIN world
  2. Create a world content loader script based on the instructions in this devlog (thanks @faahim). Import the script from step 1.
  3. 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).

@faahim
Copy link

faahim commented May 27, 2023

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. 😅

@insilications
Copy link

In the end, I would prefer having MAIN world injection with static declaration (manifest.json) and just give up on having Vite/HMR functionality.

@insilications
Copy link

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...

@flexchar
Copy link

@adam-s thank you for workaround. Is it possible to still have the script processed by Vite even thought without hot reloading?

@adam-s
Copy link

adam-s commented Oct 16, 2023

@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',
      },
    },
  },
});

@js-4
Copy link

js-4 commented Oct 19, 2023

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.

@zeroxt32
Copy link

zeroxt32 commented May 7, 2024

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;
    }
});

@Kitenite
Copy link

Kitenite commented Jun 14, 2024

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.

@emsiakkkk
Copy link

I have the similar issue.so I try to remove the “world”:”MAIN” to fix it and it works.

Bug-Reaper added a commit to SoundSafari/ShaderBalls that referenced this issue Aug 29, 2024
@Bug-Reaper
Copy link

Bug-Reaper commented Aug 29, 2024

I have the similar issue.so I try to remove the “world”:”MAIN” to fix it and it works.

Bless you @emsiakkkk.

Was trying to have a content-script use chrome.runtime.getURL() and got various versions of can't do it lol errors "chrome.runtime is undefined" and "getURL is not a function".

Just dropping “world”:”MAIN” allowed my content-script to once again enjoy chrome.runtime.getURL() with no issues. Full final manifest: https://github.com/SoundSafari/ShaderBalls/blob/25cea010c9c9263ec39825aff0d70ccc31aec736/manifest.json#L1-L27

@digital-flowers
Copy link

one way to fix this can be by polyfill the chrome.runtime.getURL
In your vite config you can add the following plugin in the build rollup options

  export default defineConfig({
   // ... your configs
   build: {
      rollupOptions: {
          plugins: [
        {
          name: "crx:dynamic-imports-polyfill",
          generateBundle(_, bundle) {
            const polyfill = `
                (function () {
                  const chrome = window.chrome || {};
                  chrome.runtime = chrome.runtime || {};
                  chrome.runtime.getURL = chrome.runtime.getURL || function(path) { return path.replace("assets/", "./"); };
                })();
            `;
            for (const chunk of Object.values(bundle)) {
              if (
                chunk.name?.endsWith("-loader.js") &&
                "source" in chunk &&
                typeof chunk.source === "string" &&
                chunk.source.includes("chrome.runtime.getURL") &&
                !chunk.source.includes(polyfill)
              ) {
                chunk.source = `
                  ${polyfill}
                  ${chunk.source}
                `;
              }
            }
          },
        },
      ]
      }
   }
  });

@FliPPeDround FliPPeDround added the docs Documentation label Jan 3, 2025
@Toumash Toumash marked this as a duplicate of #995 Mar 11, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs Documentation
Projects
None yet
Development

No branches or pull requests