Can a site invoke a browser extension?

Since Chrome introduced externally_connectable, this is quite easy to do in Chrome. First, specify the allowed domain in your manifest.json file:

"externally_connectable": {
  "matches": ["*://*.example.com/*"]
}

Use chrome.runtime.sendMessage to send a message from the page:

chrome.runtime.sendMessage(editorExtensionId, {openUrlInEditor: url},
  function(response) {
    // ...
  });

Finally, listen in your background page with chrome.runtime.onMessageExternal:

chrome.runtime.onMessageExternal.addListener(
  function(request, sender, sendResponse) {
    // verify `sender.url`, read `request` object, reply with `sednResponse(...)`...
  });

If you don’t have access to externally_connectable support, the original answer follows:

I’ll answer from a Chrome-centric perspective, although the principles described here (webpage script injections, long-running background scripts, message passing) are applicable to virtually all browser extension frameworks.

From a high level, what you want to do is inject a content script into every web page, which adds an API, accessible to the web page. When the site calls the API, the API triggers the content script to do something, like sending messages to the background page and/or send a result back to the content script, via asynchronous callback.

The main difficulty here is that content scripts which are “injected” into a web page cannot directly alter the JavaScript execution environment of a page. They share the DOM, so events and changes to DOM structure are shared between the content script and the web page, but functions and variables are not shared. Examples:

  • DOM manipulation: If a content script adds a <div> element to a page, that will work as expected. Both content script and page will see the new <div>.

  • Events: If a content script sets up an event listener, e.g., for clicks on an element, the listener will successfully fire when the event occurs. If the page sets up a listener for custom events fired from the content script, they will be successfully received when the content script fires those events.

  • Functions: If the content script defines a new global function foo() (as you might try when setting up a new API). The page cannot see or execute foo, because foo exists only in the content script’s execution environment, not in the page’s environment.

So, how can you set up a proper API? The answer comes in many steps:

  1. At a low-level, make your API event-based. The web page fires custom DOM events with dispatchEvent, and the content scripts listens for them with addEventListener, taking action when they are received. Here’s a simple event-based storage API which a web page can use to have the extension to store data for it:

    content_script.js (in your extension):

    // an object used to store things passed in from the API
    internalStorage = {};
    
    // listen for myStoreEvent fired from the page with key/value pair data
    document.addEventListener('myStoreEvent', function(event) {
        var dataFromPage = event.detail;
        internalStorage[dataFromPage.key] = dataFromPage.value
    });
    

    Non-extension web page, using your event-based API:

    function sendDataToExtension(key, value) {
        var dataObj = {"key":key, "value":value};
        var storeEvent = new CustomEvent('myStoreEvent', {"detail":dataObj});
        document.dispatchEvent(storeEvent);
    }
    sendDataToExtension("hello", "world");
    

    As you can see, the ordinary web page is firing events that the content script can see and react to, because they share the DOM. The events have data attached, added in the CustomEvent constructor. My example here is pitifully simple — you can obviously do much more in your content script once it has the data from the page (most likely pass it to the background page for further processing).

  2. However, this is only half the battle. In my example above, the ordinary web page had to create sendDataToExtension itself. Creating and firing custom events is quite verbose (my code takes up 3 lines and is relatively brief). You don’t want to force a site to write arcane event-firing code just to use your API. The solution is a bit of a nasty hack: append a <script> tag to your shared DOM which adds the event-firing code to the main page’s execution environment.

    Inside content_script.js:

    // inject a script from the extension's files
    // into the execution environment of the main page
    var s = document.createElement('script');
    s.src = chrome.extension.getURL("myapi.js");
    document.documentElement.appendChild(s);
    

    Any functions that are defined in myapi.js will become accessible to the main page. (If you are using "manifest_version":2, you’ll need to include myapi.js in your manifest’s list of web_accessible_resources).

    myapi.js:

    function sendDataToExtension(key, value) {
        var dataObj = {"key":key, "value":value};
        var storeEvent = new CustomEvent('myStoreEvent', {"detail":dataObj});
        document.dispatchEvent(storeEvent);
    }
    

    Now the plain web page can simply do:

    sendDataToExtension("hello", "world");
    
  3. There is one further wrinkle to our API process: the myapi.js script will not be available exactly at load time. Instead, it will be loaded some time after page-load time. Therefore, the plain web page needs to know when it can safely call your API. You can solve this by having myapi.js fire an “API ready” event, which your page listens for.

    myapi.js:

    function sendDataToExtension(key, value) {
        // as above
    }
    
    // since this script is running, myapi.js has loaded, so let the page know
    var customAPILoaded = new CustomEvent('customAPILoaded');
    document.dispatchEvent(customAPILoaded);
    

    Plain web page using API:

    document.addEventListener('customAPILoaded', function() {
        sendDataToExtension("hello", "world");
        // all API interaction goes in here, now that the API is loaded...
    });
    
  4. Another solution to the problem of script availability at load time is setting run_at property of content script in manifest to "document_start" like this:

    manifest.json:

        "content_scripts": [
          {
            "matches": ["https://example.com/*"],
            "js": [
              "myapi.js"
            ],
            "run_at": "document_start"
          }
        ],
    

    Excerpt from docs:

    In the case of “document_start”, the files are injected after any files from css, but before any other DOM is constructed or any other script is run.

    For some contentscripts that could be more appropriate and of less effort than having “API loaded” event.

  5. In order to send results back to the page, you need to provide an asynchronous callback function. There is no way to synchronously return a result from your API, because event firing/listening is inherently asynchronous (i.e., your site-side API function terminates before the content script ever gets the event with the API request).

    myapi.js:

    function getDataFromExtension(key, callback) {
        var reqId = Math.random().toString(); // unique ID for this request
        var dataObj = {"key":key, "reqId":reqId};
        var fetchEvent = new CustomEvent('myFetchEvent', {"detail":dataObj});
        document.dispatchEvent(fetchEvent);
    
        // get ready for a reply from the content script
        document.addEventListener('fetchResponse', function respListener(event) {
            var data = event.detail;
    
            // check if this response is for this request
            if(data.reqId == reqId) {
                callback(data.value);
                document.removeEventListener('fetchResponse', respListener);
            }
        }
    }
    

    content_script.js (in your extension):

    // listen for myFetchEvent fired from the page with key
    // then fire a fetchResponse event with the reply
    document.addEventListener('myStoreEvent', function(event) {
        var dataFromPage = event.detail;
        var responseData = {"value":internalStorage[dataFromPage.key], "reqId":data.reqId};
        var fetchResponse = new CustomEvent('fetchResponse', {"detail":responseData});
        document.dispatchEvent(fetchResponse);
    });
    

    ordinary web page:

    document.addEventListener('customAPILoaded', function() {
        getDataFromExtension("hello", function(val) {
            alert("extension says " + val);
        });
    });
    

    The reqId is necessary in case you have multiple requests out at once, so that they don’t read the wrong responses.

And I think that’s everything! So, not for the faint of heart, and possibly not worth it, when you consider that other extensions can also bind listeners to your events to eavesdrop on how a page is using your API. I only know all this because I made made a proof-of-concept cryptography API for a school project (and subsequently learned the major security pitfalls associated with it).

In sum: A content script can listen for custom events from an ordinary web page, and the script can also inject a script file with functions that makes it easier for web pages to fire those events. The content script can pass messages to a background page, which then stores, transforms, or transmits data from the message.

Leave a Comment