Debugging and performance profiling ManifestV3 extension Service worker - google-chrome-extension

I'm learning how to build chrome extensions with manifest v3, what I'm trying to do is the following
In my extension background.js (service worker) I want to do this:
connect to WebSocket to get data updates
reconnect to the Websocket when service-worker wake up
those are tasks to get data updates from a WebSocket and update the badge text and send notifications.
I need to do these tasks while not relying on having a port open with the popup or a content script.
I'm using Chrome Alarms to wake up the service worker
it may sound weird that I have to reconnect every time the service worker wakes up considering chrome is shutting the service worker down like every 15s or less once I close the extensions dev tools (which makes me cry blood) but it is better than sending XHR periodically using Chrome alarms, which would result in a lot more requests being sent to an API, so reconnecting to the Websocket seems less problematic.
I'm having a super hard time debugging my service worker (background script) in my chrome extension. The problem is when I have dev tools open the service worker will NEVER go inactive, and what I'm trying to do is identify when the SW wakes up to perform tasks, super-duper weird because I need dev tools open to debugging...
how do you debug an extension SW without devtools open?
do you/anyone reading this have any recommendations/thoughts on what I want to do with this extension and the pain process for debugging the SW?
here is the code I have for the background.js
const extension = {
count: 0,
disconnected: false,
port: {} as any,
ws: null,
};
const fetchData = () => {
return fetch(
'https://api.coingecko.com/api/v3/coins/ethereum?localization=incididuntelit&tickers=false&market_data=true&community_data=true&developer_data=true&sparkline=true'
).then((res) => res.json());
};
// Chrome Alarms
const setupAlarms = () => {
console.log('###ALARMS-SETUP');
chrome.alarms.get('data-fetch', (alarm) => {
if (!alarm) {
chrome.alarms.create('data-fetch', { periodInMinutes: 0.1 });
}
});
chrome.alarms.get('client-pinging-server', (alarm) => {
if (!alarm) {
chrome.alarms.create('client-pinging-server', { periodInMinutes: 0.1 });
}
});
chrome.alarms.onAlarm.addListener((e) => {
if (e.name === 'data-fetch') {
fetchData()
.then((res) => {
// console.log('###ETHEREUM:', res.market_data.current_price.cad);
chrome.action.setBadgeText({ text: `${Math.round(Math.random() * 100)}` });
})
.catch((error) => console.error('###ERROR', error));
}
if (e.name === 'client-pinging-server') {
if (!extension.ws || !extension.ws.getInstance()) {
console.log('\n');
console.log('###reconnecting...', extension.ws);
console.log('\n');
extension.ws = WebSocketClient();
extension.ws.connect();
}
if (extension.ws.getInstance()) {
console.log('###sending-client-ping');
extension.ws.getInstance().send(JSON.stringify({ message: 'client ping - keep alive' }));
}
}
});
};
// ON INSTALL
chrome.runtime.onInstalled.addListener(async (e) => {
const API_URL = 'ws://localhost:8080';
chrome.storage.local.set({ apiUrl: API_URL, count: 0 });
console.info('###Extension installed', e);
chrome.action.setBadgeText({ text: '0' });
chrome.action.setBadgeBackgroundColor({ color: '#FF9900' });
});
// ON SUSPEND
chrome.runtime.onSuspend.addListener(() => {
console.log('Unloading.');
chrome.action.setBadgeText({ text: `off` });
});
function WebSocketClient() {
let instance = null;
const connect = () => {
return new Promise((resolve, reject) => {
const ws = new WebSocket('ws://localhost:8080');
const onOpen = () => {
instance = ws;
console.log('###websocket:connected', instance);
return resolve(ws);
};
const onError = (event) => {
console.log('###INIT-FAILED', event);
ws.close(1000, 'closing due to unknown error');
return reject('failed to connect to websocket');
};
const onClose = () => {
console.log('###websocket:disconnected');
instance = null;
// reconnect is happening in the alarm callback
};
ws.onopen = onOpen;
ws.onerror = onError;
ws.onclose = onClose;
});
};
const getInstance = () => {
return instance;
};
return {
connect,
getInstance,
};
}
self.addEventListener('install', async (event) => {
console.log('====install', event);
chrome.action.setBadgeBackgroundColor({ color: '#a6e22e' });
});
self.addEventListener('activate', async (event) => {
console.log('====activate', event);
chrome.action.setBadgeBackgroundColor({ color: '#FF9900' });
extension.ws = WebSocketClient();
extension.ws.connect();
setupAlarms();
});
self.addEventListener('push', function (event) {
// Keep the service worker alive until the notification is created.
event.waitUntil(
self.registration.showNotification('Testing PUSH API', {
body: 'coming from push event',
})
);
});

Since Devtools can attach to multiple contexts at once, you can open it for another context so the SW will be secondary and thus will be able to unload normally.
Debugging
Open any visible page of the extension or, if there are none, its manifest.json URL:
chrome-extension://ID/manifest.json where ID is the extension's id
Open devtools and switch to its Application tab, then choose Service worker on the left.
Click start (if shown) to start the service worker, click the background script name to open it in the Sources panel, set breakpoints, etc.
Click stop to stop the service worker, optionally click Update at the top, and skip waiting in the middle (if shown) to force an update.
Click start again - your breakpoints will trigger.
Performance profiling
Open any visible page of the extension or, if there are none, its manifest.json URL:
chrome-extension://ID/manifest.json where ID is the extension's id
Open devtools and switch to its Application tab, then choose Service worker on the left.
Duplicate the tab, open devtools there, go to Performance tab, click "Start" or press Ctrl-E
Switch back to the first tab and click the start button (or stop first, then start). In certain cases you may also see skip waiting in the middle, click it then.
Switch to the second tab, wait for a while and click the recording button again or press Ctrl-E.
Notes
When the service worker is started you can see its context in the Sources panel on the left (in the files panel), on the top-right (in the threads panel), in the console toolbar (the context selector).
This may seem unwieldy at first, but once you try and get the knack of it, it's quite trivial and can even beat devtools that's shown when clicking the "service worker" link in chrome://extensions page because this one a) shows extension's localStorage/IndexedDB in devtools, b) provides control over service worker lifetime/execution, c) supports performance profiling of SW startup.
Note that the ManifestV3 documentation's claims about benefits provided by service workers for extensions are largely exaggerated or completely false, e.g. in your case it's clear that restarting the service worker is bad, so you should use a port to prolong the SW's lifetime as much as possible.

Related

Abandoned http requests after server.close()?

I have a vanilla nodejs server like this:
let someVar // to be set to a Promise
const getData = url => {
return new Promise((resolve, reject) => {
https.get(
url,
{ headers: { ...COMMON_REQUEST_HEADERS, 'X-Request-Time': '' + Date.now() } },
res => {
if (res.statusCode === 401) return reject(new RequestError(INVALID_KEY, res))
if (res.statusCode !== 200) return reject(new RequestError(BAD_REQUEST, res))
let json = ''
res.on('data', chunk => json += chunk)
res.on('end', () => {
try {
resolve(JSON.parse(json).data)
} catch (error) {
return reject(new RequestError(INVALID_RESPONSE, res, json))
}
})
}
).on('error', error => reject(new RequestError(FAILED, error)))
})
}
const aCallback = () =>
console.log('making api call')
someVar = getData('someApiEndpoint')
.then(data => { ... })
}
const main = () => {
const server = http.createServer(handleRequest)
anInterval = setInterval(aCallback, SOME_LENGTH_OF_TIME)
const exit = () => {
server.close(() => process.exit())
log('Server is closed')
}
process.on('SIGINT', exit)
process.on('SIGTERM', exit)
process.on('uncaughtException', (err, origin) => {
log(`Process caught unhandled exception ${err} ${origin}`, 'ERROR')
})
}
main()
I was running into a situation where I would ctrl-c and would see the Server is closed log, followed by my command prompt, but then I would see more logs printed indicting that more api calls are being made.
Calling clearInterval(anInterval) inside exit() (before server.close()) seems to have solved the issue of the interval continuing even when the server is closed, so that's good. BUT:
From these node docs:
Closes all connections connected to this server which are not sending a request or waiting for a response.
I.e., I assume server.close() will not automatically kill the http request.
What happens to the http response information when my computer / node are no longer keeping track of the variable someVar?
What are the consequences of not specifically killing the thread that made the http request (and is waiting for the response)?
Is there a best practice for cancelling the request?
What does that consist of (i.e. would I ultimately tell the API's servers 'never mind please don't send anything', or would I just instruct node to not receive any new information)?
There are a couple things you should be aware of. First off, handling SIGINT is a complicated thing in software. Next, you should never need to call process.exit() as node will always exit when it's ready. If your process doesn't exit correctly, that means there is "work being done" that you need to stop. As soon as there is no more work to be done, node will safely exit on its own. This is best explained by example. Let's start with this simple program:
const interval = setInterval(() => console.log('Hello'), 5000);
If you run this program and then press Ctrl + C (which sends the SIGINT signal), node will automatically clear the interval for you and exit (well... it's more of a "fatal" exit, but that's beyond the scope of this answer). This auto-exit behavior changes as soon as you listen for the SIGINT event:
const interval = setInterval(() => console.log('Hello'), 5000);
process.on('SIGINT', () => {
console.log('SIGINT received');
});
Now if you run this program and press Ctrl + C, you will see the "SIGINT received" message, but the process will never exit. When you listen for SIGINT, you are telling node "hey, I have some things I need to cleanup before you exit". Node will then wait for any "ongoing work" to finish before it exits. If node doesn't eventually exit on it's own, it's telling you "hey, I can see that there are some things still running - you need to stop them before I'll exit".
Let's see what happens if we clear the interval:
const interval = setInterval(() => console.log('Hello'), 5000);
process.on('SIGINT', () => {
console.log('SIGINT received');
clearInterval(interval);
});
Now if you run this program and press Ctrl + C, you will see the "SIGINT received" message and the process will exit nicely. As soon as we clear the interval, node is smart enough to see that nothing is happening, and it exits. The important lesson here is that if you listen for SIGINT, it's on you to wait for any tasks to finish, and you should never need to call process.exit().
As far as how this relates to your code, you have 3 things going on:
http server listening for requests
an interval
outgoing https.get request
When your program exits, it's on you to clean up the above items. In the most simple of circumstances, you should do the following:
close the server: server.close();
clear the interval: clearInterval(anInterval);
destroy any outgoing request: request.destroy()
You may decide to wait for any incoming requests to finish before closing your server, or you may want to listen for the 'close' event on your outgoing request in order to detect any lost connection. That's on you. You should read about the methods and events which are available in the node http docs. Hopefully by now you are starting to see how SIGINT is a complicated matter in software. Good luck.

Passing data from server to html with Electron/Nodejs

I'm using preload and renderer js to pass data from html to server. I have a main window and I open another window (add window). I take data from add window and pass it to server. I receive the data on server, but I don't know how to send callback with data from server to main window html.
In preload I have:
contextBridge.exposeInMainWorld(
'windowControls',
{
add: (data)=> ipcRenderer.send('item:add',data),
received:(data)=> ipcRenderer.send('item:received',data)
In rendererAddwindow:
var input = document.getElementById("inputItem").value;
windowControls.add(input)
In app.js:
// Catch item:add
ipcMain.on('item:add',(e,item)=>{
console.log('item',item); // Here I can read item
mainWindow.webContents.on('did-finish-load',()=>{
mainWindow.webContents.send('item:received',item)
});
addWindow.close();
})
What should I write in rendererMain to get data as a callback in main window? The main renderer is executed at first run and not when callback is triggered (if I triggered callback with these lines at all).
The did-finish-load event is not what you are looking for. This event is fired once the webpage is loaded, it is emited only once if you stay on the same page.
You have 2 solutions to answer a message received in the main process.
Invoke the message instead of sending it
You should refer to the documentation to learn about invoking the message.
Here is the example from the documentation :
// Renderer process
ipcRenderer.invoke('some-name', someArgument).then((result) => {
// ...
})
// Main process
ipcMain.handle('some-name', async (event, someArgument) => {
const result = await doSomeWork(someArgument)
return result
})
Here is what it should look like in your example :
// Renderer process
ipcRenderer.invoke('item:add', item) // This sends the item to main process and wait for the answer
.then((data) => { // Callback triggered once the result comes back
console.log(data) // Do what you want with the data
})
// Main process
ipcMain.handle('item:add', async (event, item) => {
console.log(item)
return item // Or return whatever you want
})
Send a new message
This is not the best solution since it can become very complexe as the app grows. But you can send a new message from main to renderer :
// app.js file
ipcMain.on('item:add',(e,item)=>{
console.log({item})
if(yourWindow) { // It can throw an error if yourWindow is null or defined
yourWindow.webContents.send('item:received',item)
}
})
In app.js (when data is received from add window input):
// Catch item:add
ipcMain.on('item:add',(e,item)=>{
console.log('item',item); // Here I can read item
mainWindow.send('itemreceived',item)
addWindow.close();
})
In preload.js (outside contextBridge.exposeInMainWorld()):
const { contextBridge, ipcRenderer } = require('electron')
// Set up context bridge between the renderer process and the main process
contextBridge.exposeInMainWorld(
'windowControls',
{
close: () => ipcRenderer.send('windowControls:close'),
maximize: () => ipcRenderer.send('windowControls:maximize'),
minimize: () => ipcRenderer.send('windowControls:minimize'),
add: (data)=> ipcRenderer.send('item:add',data),
}
)
ipcRenderer.on('itemreceived',(event,message)=>{
console.log('item received message',message);
}
Similar example is here: https://gist.github.com/malept/3a8fcdc000fbd803d9a3d2b9f6944612

How to Launch/Focus electron app from browser link?

I need to launch the electron app or focus it ( if already launched ) from a browser link. I have searched and tried many solutions but not getting it to work, so if someone has any experience with it, can you please help?
Here is the code:
// Single instance app ==========
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
// Register private URI scheme for the current user when running for the first time
app.setAsDefaultProtocolClient('x-protocol');
When I try to launch using this code, I get the goTheLock value as false, but the second-instance event is not getting fired, not sure why.
Version Details:
platform: Windows 10
electron: 8.5.3
electron-builder: 21.2.0
Update:
I added a delay of 5 seconds before quitting the app inside !gotTheLock, and in that case, I'm getting the event.
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
delay(5000); // 5 seconds delay
app.quit();
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
I don't understand. If you want to launch the app from a browser link then why you're implementing a second-instance? second-instance will fire if you open an application for a second time.
like this,
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
if (win) {
app.quit();
}
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
if (win) {
win.show();
win.focus();
}
})
}

Chrome extension background script sometimes does not run after install or update

I have had recent reports of a chrome extension that I develop that stops working after an update or a fresh install. The background script seems to not start at all.
There is no response to messages sent to it from the content scripts.
There is no process for it in the task manager.
Opening background page from chrome://extensions does not show any activity in the console, or show any source files.
Profiling, memory snapshot buttons are disabled.
Once this issue appears, it persists for the chrome profile even after reloading or uninstalling/reinstalling the extension.
Restarting chrome resolves the problem.
The issue has been seen on chrome v79. But I cannot say for sure that it is exclusive to this version, as the issue is difficult to reproduce and seemingly random.
Has anyone seen such an issue, or has any ideas what to look for? I am happy to update my question with any new info I have or with any info you need.
Edit:
Here is my webNavigation listener, which is used to inject content scripts. This handler is wired up in the 'root' context of the background script (not asynchronously inside an event handler)
chrome.webNavigation.onCompleted.addListener((details) ⇒ {
if(details.frameId === 0) {
injectScript(
'js/contentScript.js',
details.tabId,
details.frameId,
details.url
).catch((e) ⇒ {});
}
}
The injectScript function is as follows
export const injectScript = ƒ (scriptPath,tab,frame,tabUrl) {
return new Promise((res,rej) ⇒ {
let options = {
file : scriptPath,
allFrames : false,
frameId : frame,
matchAboutBlank: false,
runAt : 'document_idle',
};
const cb = ƒ () {
if (chrome.runtime.lastError) {
let err = new Error('Could not inject script');
capture(err,{
...options,
tabUrl,
lastError : chrome.runtime.lastError.message,
});
rej(err);
}else{
res();
}
};
if (tabUrl.indexOf('.salesforce.com') !== -1) {
window.setTimeout(() => {
chrome.tabs.executeScript(tab,options,cb);
},500);
}else{
chrome.tabs.executeScript(tab,options,cb);
}
});
};
Note above, the capture function reports the error to a backend and I cannot see it being reported there as well. Cannot add a breakpoint in code because no source appears in the background page, as noted above.
A background service worker is loaded when it is needed, and unloaded when it goes idle.
https://developer.chrome.com/docs/extensions/mv3/service_workers/
You can use the following methods:
// Keep heartbeat
let heartTimer;
const keepAlive = () => {
heartTimer && clearTimeout(heartTimer);
heartTimer = setTimeout(() => {
chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) {
console.info('[heartbeat]')
tabs.length && chrome.tabs.sendMessage(
tabs[0].id,
{ action: "heartbeat" }
);
});
keepAlive();
}, 10000);
};
keepAlive();

Calling Set on a Firebase Database Reference Causes the Webpage to Hang

I'm using React and Node to build an web-based interface to modify data in a Firebase database. I've previously used the Firebase Web SDK in this app to load data from the database, but I've encountered a strange issue with saving a user's changes. When I call set on a database reference (i.e. firebase.database().ref('/path/to/object').set({abc: 'xyz'})), the webpage hangs. Oddly enough, the changes are saved to the database, but the callbacks specified with then are never called (depending on the browser, a This page is slowing down your browser message appears). I'm certain that the issue is related to set as removing the call removes the hang (see save() in my code below).
import React from 'react'
import * as firebase from 'firebase'
// additional (unrelated) imports
export default class Editor extends React.Component {
constructor(props) {
super(props)
this.state = {
savingModal: false,
errorModal: false,
cancelModal: false,
errors: []
}
}
save() {
// this.form is a Reactstrap Form
// validate is a function that returns an array of strings
var errors;
// validate the form, show the errors if any
if ((errors = this.form.validate()) && errors.length > 0)
this.setState({errorModal: true, errors: errors})
else {
// collect is a function that returns an object with the data that the user entered
var x = this.form.collect()
// getEditorInfo is a function that returns info such as the type of object being edited
var info = this.getEditorInfo()
firebase.database().ref(`/${info.category}/${x.id}/`).set(x).then(() => {
this.closeEditor()
}, e => {
alert(`An unexpected error occurred:\n${e}`)
})
this.setState({savingModal: true})
}
}
// closes the window or redirects to /
closeEditor() {
if (window.opener)
window.close()
else
window.location.href = '/'
}
render() {
// BasicModal is a custom component that renders a Reactstrap Modal
// IndeterminateModal is a custom component that renders a Reactstrap Modal with only a Progress element
// EditorToolbar and EditorForm are custom components that render the UI of the page (I don't think they're relevant to the issue)
var info = this.getEditorInfo()
if (!info)
return <BasicModal isOpen={true} onPrimary={this.closeEditor} primary="Close" header="Invalid Request" body="ERROR: The request is invalid."/>
else
return <div>
<EditorToolbar onSave={this.save.bind(this)} onCancel={() => this.setState({cancelModal: true})}/>
<EditorForm ref={f => this.form = f}/>
<BasicModal toggle={() => this.setState({cancelModal: !this.state.cancelModal})} isOpen={this.state.cancelModal} header="Unsaved Changes" body={<p>If you close this window, your changes will not be saved.<br/>Are you sure you want to continue?</p>} primary="Close Anyway" primaryColor="danger" secondary="Go Back" onPrimary={this.closeEditor}/>
<IndeterminateModal style={{
top: '50%',
transform: 'translateY(-50%)'
}} isOpen={this.state.savingModal} progressColor="primary" progressText="Processing..."/>
<BasicModal toggle={() => this.setState({errorModal: false, errors: []})} isOpen={this.state.errorModal} header="Validation Error" body={<div><p>Please resolve the following errors:<br/></p><ul>{(this.state.errors || []).map(e => <li key={e}>{e}</li>)}</ul></div>} primary="Dismiss" primaryColor="primary"/>
</div>
}
}
UPDATE 1/8/2018
I came across this article today and I decided to try a new solution involving JavaScript's setTimeout method. In my situation, the freeze occurred after calling this.setState in my app then calling firebase.database().ref(path).set(data). I suspect the freezing issue was caused by this. I guess JavaScript couldn't handle the state change and Firebase operation all at once. This new solution is functional, more secure, faster, and simpler. Take a look:
// to perform your desired Firebase operation:
var timeout = 50 // give JS some time (e.g. 50ms) to finish other operations first
setTimeout(() => firebase.database().ref(path).set(data).then(
() => {/* ... */},
error => {/* ... */}),
timeout)
OLD SOLUTION
I ended up finding a solution. I'm sure it could be improved, but it works. I used the Web Workers API to run my Firebase code.
Create a new JavaScript file in your public folder (Node.js)
Download a copy of the Firebase web SDK source and place it in public
I chose to communicate with my Worker with postMessage
FirebaseWorker.js
self.onmessage = event => {
importScripts('./firebase.js') // import the local Firebase script
firebase.initializeApp({/* your config */})
const promise = p => p.then(
() => self.postMessage({error: null}),
e => self.postMessage({error: e})
const doWork = () => {
switch (event.data.action) {
case 'get':
promise(firebase.database().ref(event.data.path).once('value'))
break;
case 'set':
promise(firebase.database().ref(event.data.path).set(event.data.data))
break;
case 'update':
promise(firebase.database().ref(event.data.path).update(event.data.data))
break;
case 'remove':
promise(firebase.database().ref(event.data.path).remove())
break;
}
}
if (!firebase.auth().currentUser)
firebase.auth().signInWithEmailAndPassword(event.data.email, event.data.password).then(() => doWork())
else
doWork()
}
To use the Worker:
var worker = new Worker('FirebaseWorker.js')
worker.onmessage = event => {
if (event.data.error)
alert(event.data.error)
// ...
}
worker.postMessage({
data: {/* your data (required if set or update is used) */},
path: '/path/to/reference',
action: 'get, set, update, or remove',
email: 'someone#example.com',
password: 'password123'
})

Resources