Using chrome.tabs.executeScript to execute asynchronous function
I have a function that I want to execute on a page using chrome.tabs.executeScript
that is triggered from a browser action popup. The permissions are set up correctly and work 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 multiple 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 result of the callback r
. It should be an array of the script's results, so I expect to r[0]
be a promise to be resolved when the script ends.
The Promise syntax (with .then()
) doesn't work either.
If I execute the same function on the page, it returns the promise as expected and it can be expected.
Any idea what I am doing wrong and is there any way out there?
source to share
The problem is that events and native objects are not directly accessible between the page and the extension. Basically, you get a serialized copy, something similar to what you would get if you did JSON.parse(JSON.stringify(obj))
.
This means that some of its own objects (for example, new Error
or new Promise
) will be cleared (become {}
), events will be lost, and no promise implementation can work across the border.
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 to 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 expanded this to a functionchrome.tabs.executeAsyncFunction
(as the part that "promises" the entire API): chrome-extension-async
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 chrome.tabs.executeScript
and chrome.runtime.onMessage.addListener
, and wraps the script in try
- finally
, before calling chrome.runtime.sendMessage
to resolve the promise.
source to share
Passing promises from page to content script does not work, the solution is to use chrome.runtime.sendMessage and only send simple data between the two worlds, e.g . :
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);
}
});
source to share