'Using chrome.tabs.executeScript to execute an async function

I have a function I want to execute in the page using chrome.tabs.executeScript, running from a browser action popup. The permissions are set up correctly and it works fine with a synchronous callback:

chrome.tabs.executeScript(
    tab.id, 
    { code: `(function() { 
        // Do lots of things
        return true; 
    })()` },
    r => console.log(r[0])); // Logs true

The problem is that the function I want to call goes through several callbacks, so I want to use async and await:

chrome.tabs.executeScript(
    tab.id, 
    { code: `(async function() { 
        // Do lots of things with await
        return true; 
    })()` },
    async r => {
        console.log(r); // Logs array with single value [Object]
        console.log(await r[0]); // Logs empty Object {}
    }); 

The problem is that the callback result r. It should be an array of script results, so I expect r[0] to be a promise that resolves when the script finishes.

Promise syntax (using .then()) doesn't work either.

If I execute the exact same function in the page it returns a promise as expected and can be awaited.

Any idea what I'm doing wrong and is there any way around it?



Solution 1:[1]

The problem is that events and native objects are not directly available between the page and the extension. Essentially you get a serialised copy, something like you will if you do JSON.parse(JSON.stringify(obj)).

This means some native objects (for instance new Error or new Promise) will be emptied (become {}), events are lost and no implementation of promise can work across the boundary.

The solution is to use chrome.runtime.sendMessage to return the message in the script, and chrome.runtime.onMessage.addListener in popup.js to listen for it:

chrome.tabs.executeScript(
    tab.id, 
    { code: `(async function() { 
        // Do lots of things with await
        let result = true;
        chrome.runtime.sendMessage(result, function (response) {
            console.log(response); // Logs 'true'
        });
    })()` }, 
    async emptyPromise => {

        // Create a promise that resolves when chrome.runtime.onMessage fires
        const message = new Promise(resolve => {
            const listener = request => {
                chrome.runtime.onMessage.removeListener(listener);
                resolve(request);
            };
            chrome.runtime.onMessage.addListener(listener);
        });

        const result = await message;
        console.log(result); // Logs true
    }); 

I've extended this into a function chrome.tabs.executeAsyncFunction (as part of chrome-extension-async, which 'promisifies' the whole API):

function setupDetails(action, id) {
    // Wrap the async function in an await and a runtime.sendMessage with the result
    // This should always call runtime.sendMessage, even if an error is thrown
    const wrapAsyncSendMessage = action =>
        `(async function () {
    const result = { asyncFuncID: '${id}' };
    try {
        result.content = await (${action})();
    }
    catch(x) {
        // Make an explicit copy of the Error properties
        result.error = { 
            message: x.message, 
            arguments: x.arguments, 
            type: x.type, 
            name: x.name, 
            stack: x.stack 
        };
    }
    finally {
        // Always call sendMessage, as without it this might loop forever
        chrome.runtime.sendMessage(result);
    }
})()`;

    // Apply this wrapper to the code passed
    let execArgs = {};
    if (typeof action === 'function' || typeof action === 'string')
        // Passed a function or string, wrap it directly
        execArgs.code = wrapAsyncSendMessage(action);
    else if (action.code) {
        // Passed details object https://developer.chrome.com/extensions/tabs#method-executeScript
        execArgs = action;
        execArgs.code = wrapAsyncSendMessage(action.code);
    }
    else if (action.file)
        throw new Error(`Cannot execute ${action.file}. File based execute scripts are not supported.`);
    else
        throw new Error(`Cannot execute ${JSON.stringify(action)}, it must be a function, string, or have a code property.`);

    return execArgs;
}

function promisifyRuntimeMessage(id) {
    // We don't have a reject because the finally in the script wrapper should ensure this always gets called.
    return new Promise(resolve => {
        const listener = request => {
            // Check that the message sent is intended for this listener
            if (request && request.asyncFuncID === id) {

                // Remove this listener
                chrome.runtime.onMessage.removeListener(listener);
                resolve(request);
            }

            // Return false as we don't want to keep this channel open https://developer.chrome.com/extensions/runtime#event-onMessage
            return false;
        };

        chrome.runtime.onMessage.addListener(listener);
    });
}

chrome.tabs.executeAsyncFunction = async function (tab, action) {

    // Generate a random 4-char key to avoid clashes if called multiple times
    const id = Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);

    const details = setupDetails(action, id);
    const message = promisifyRuntimeMessage(id);

    // This will return a serialised promise, which will be broken
    await chrome.tabs.executeScript(tab, details);

    // Wait until we have the result message
    const { content, error } = await message;

    if (error)
        throw new Error(`Error thrown in execution script: ${error.message}.
Stack: ${error.stack}`)

    return content;
}

This executeAsyncFunction can then be called like this:

const result = await chrome.tabs.executeAsyncFunction(
    tab.id, 
    // Async function to execute in the page
    async function() { 
        // Do lots of things with await
        return true; 
    });

This wraps the chrome.tabs.executeScript and chrome.runtime.onMessage.addListener, and wraps the script in a try-finally before calling chrome.runtime.sendMessage to resolve the promise.

Solution 2:[2]

Passing promises from page to content script doesn't work, the solution is to use chrome.runtime.sendMessage and to send only simple data between two worlds eg.:

function doSomethingOnPage(data) {
  fetch(data.url).then(...).then(result => chrome.runtime.sendMessage(result));
}

let data = JSON.stringify(someHash);
chrome.tabs.executeScript(tab.id, { code: `(${doSomethingOnPage})(${data})` }, () => {
  new Promise(resolve => {
    chrome.runtime.onMessage.addListener(function listener(result) {
      chrome.runtime.onMessage.removeListener(listener);
      resolve(result);
    });
  }).then(result => {
    // we have received result here.
    // note: async/await are possible but not mandatory for this to work
    logger.error(result);
  }
});

Solution 3:[3]

For anyone who is reading this but using the new manifest version 3 (MV3), note that this should now be supported.

chrome.tabs.executeScript has been replaced by chrome.scripting.executeScript, and the docs explicitly state that "If the [injected] script evaluates to a promise, the browser will wait for the promise to settle and return the resulting value."

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 syntagma
Solution 2 Lev Lukomsky
Solution 3 Nic