Alright, I think I’ve got it now.
content-script.js:
// listen to messages from popup
browser.runtime.onMessage.addListener((message, sender) => {
// ...
})
const extensionObject = {
historyPushState: function(url) {
// ...
},
xhrOpen: function(url, xhr) {
// you can add `load` event listener to xhr to get response
// return `true` if you want to capture data in function below
},
xhrSend: function(url, data) {
// first element of result array tells if you want to send this xhr
// second is data you want to send
let result = [true, data];
return cloneInto(result, window);
}
};
window.wrappedJSObject.extensionObject = cloneInto(extensionObject, window, {cloneFunctions: true});
{
// inject script to page scope
const s = document.createElement('script');
s.src = browser.extension.getURL('inject.js');
s.onload = () => {
document.head.removeChild(s);
};
document.head.appendChild(s);
}
inject.js:
((extensionObject, realOpen, realSend, realPushState) => {
delete window.extensionObject;
const requestsToIntercept = {};
XMLHttpRequest.prototype.open = function(method, url) {
const interceptSend = extensionObject.xhrOpen(url, this);
if(interceptSend)
requestsToIntercept[this] = url;
realOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(data) {
const url = requestsToIntercept[this];
let sendRequest = true;
let newData = data;
if(typeof url === 'string') {
delete requestsToIntercept[this];
[sendRequest, newData] = extensionObject.xhrSend(url, newData);
}
if(sendRequest)
realSend.apply(this, [newData]);
};
history.pushState = function(obj, title, url) {
extensionObject.historyPushState(url);
realPushState.apply(history, arguments);
};
})(window.extensionObject, XMLHttpRequest.prototype.open,
XMLHttpRequest.prototype.send, history.pushState);
And since I store all data in content-script.js
I just need to communicate between browser/page action popup and content script:
popup.js:
// get active tab
browser.tabs.query({active:true,currentWindow:true}).then(tabs => {
browser.tabs.sendMessage(tabs[0].id, {message: "hello"});
});
This doesn’t even require activeTab
permission. 
Is this okay or should I do it another way?