crxjs / chrome-extension-tools

Bundling Chrome Extensions can be pretty complex. It doesn't have to be.
https://crxjs.dev/vite-plugin
2.6k stars 177 forks source link

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

Open svicalifornia opened 1 year ago

svicalifornia commented 1 year ago

Build tool

Vite

Where do you see the problem?

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:

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

@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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 9 months ago

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

adam-s commented 9 months ago

@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 commented 9 months ago

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 commented 2 months ago

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 commented 1 month ago

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 commented 2 weeks ago

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