Search code examples
javascriptgoogle-chrome-extensionasync-awaites6-promise

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

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