Service worker and messaging
Design message contracts
Use named message types, narrow payloads, clear responses, and error handling across popup, worker, and content script.
We now have three contexts sending messages to each other. Popup to content script, worker to content script, and soon content script to worker. Left alone, those messages become an internal API that nobody wrote down.
So let’s write it down.
One module for all message shapes
Define every allowed message in one place. With TypeScript, a union type does it:
type PageNoteMessage =
| { type: 'SHOW_PAGE_NOTE'; note: string }
| { type: 'HIDE_PAGE_NOTE' }
| { type: 'GET_ACTIVE_NOTE'; url: string }
type MessageResult =
| { ok: true }
| { ok: false; code: 'INVALID_MESSAGE' | 'UNAVAILABLE' }
Three rules follow from this. Only serializable data goes in. Every message gets an explicit result, success or a named failure code. And a receiver answers unknown types with INVALID_MESSAGE, never with silence.
If you write plain JavaScript, put the same list in a messages.js module as constants and a isPageNoteMessage() function. The point is the single source of truth, not the language.
Types don’t run at runtime
TypeScript checks your code at compile time. The message that arrives at runtime can come from an older version of the popup, from a page that found a way in, or from a plain JavaScript file that never saw your types.
So validate every incoming message with real code. Check type is one of the known strings and each field has the right shape. We did this in the content script already, and the worker needs the same guard.
Check who is talking
The listener receives a sender object. When trust differs by source, use it. sender.id tells you which extension sent the message, which matters if you ever accept messages from other extensions or web pages. sender.tab tells you which tab a content script lives in, so the worker can refuse to act on a tab the user didn’t invoke.
Never echo exception stacks or storage contents in an error response. { ok: false, code: 'UNAVAILABLE' } tells the sender enough.
Answering asynchronously
Messages are JSON-serialized, and a listener that wants to reply after an await has to say so. The widely compatible way is to call sendResponse() later and return the literal true from the listener, which keeps the channel open:
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
getNote(message.url).then(note => sendResponse({ ok: true, note }))
return true
})
Newer Chrome versions are adding support for returning a promise from the listener instead. Don’t mix the two styles. If you want promises, set a minimum Chrome version in the manifest, otherwise some users get a listener that never responds.
Now document the three message types in a short table: sender, receiver, payload, response, who may send it, and failure codes. Then attack your own listeners with an unknown type, a malformed payload, no receiver in the tab, two listeners both trying to answer, and a response containing a DOM node. Each one should fail with a clear code, not a stack trace in the console.
Lesson completed