Typing Browser Extension Background Script Communication

Using TypeScript to prevent bugs in the communication between content and background scripts

Posted on 18.08.2023

While refactoring the flashkill browser extension, I found myself, once more, debugging the communication between content and background scripts and decided to do something about it.

In this blog post I present the template, which enables full typing of messages, with type errors as soon as you try to send/receive a message with a name that does not exist or a payload or return type, that does not match the receiving/sending part.

The problem

The flashkill extension parses the page’s content, runs http requests accordingly, processes the returned html and injects selected details into the original page. For that, several different messages need to be communicated between content and background scripts.

The go-to way to differentiate between messages, is to introduce some sort of parameter like a message name. Like this you can choose which routine you want to trigger in the background script, by simply switching the name.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// background.js
chrome.runtime.onMessage.addListener(
    (request: { name, payload }, _, callback): => {
      if (request.name === 'Load page X') {
        fetch(`www.pageX.com/${payload}`)
            .then(callback)
      }

      if (request.name === 'Load page Y') {
        fetch(`www.pageY.com/${payload}`)
            .then(callback)
      }

      return true;
    },
  );


// content.js
chrome.runtime.sendMessage(
    { name: 'Load pag X', payload: { path: 'www.did-you-spot-both-issues.com' } },
    (html) => { /* process result */ },
);

This can lead to several issues - misspelled name values or wrong payload or callback types. If you stumbled upon this blog post, you will already know that the debugging of any of these issues leads to a lot of headaches.

The solution

The template is split into two parts, but all contained in a single file. The first part of the template is the definition of the message types. A message is defined with a name a payload type and a response type. To profit from the strict typing all the way through the message handling, the methods sendMessage and receiveMessage are introduced, which wrap the chrome.runtime calls. So this solution only has its full effect, when all message handling is done using these wrapper methods.

To add a new message, simply add a new member to the MessageName enum. Immediately TypeScript should complain, that the Messages interface is incomplete. This means you can only use the sendMessage and receiveMessage functions with strictly typed messages. When you later decide to update the name of the message or the payload or response types, it will be much easier to refactor and TypeScript should let you know if you forgot to adjust one of the elements in the chain.

Unfortunately this solution does not prevent you from forgetting to send/receive a message, that you are trying to receive/send… but I think those errors are significantly easier to spot.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// messages.ts
export enum MessageName {
  GetPersonInformations = 'getPersonInformations',
}

interface Message {
  payload: unknown;
  response: unknown;
}

interface Messages extends Partial<Record<MessageName, Message>> {
  [MessageName.GetPersonInformations]: {
    payload: {
      ids: string[];
    };
    response: PersonInformation[];
  };
}

type MessageTypes = keyof Messages;
type MessagePayload<T extends MessageTypes> = Messages[T]['payload']
type MessageResponse<T extends MessageTypes> = Messages[T]['response']
type MessageCallback<T extends MessageTypes> = (response: MessageResponse<T>) => void;

export const sendMessage = <T extends MessageTypes>(
  name: T,
  payload: MessagePayload<T>,
  callback: MessageCallback<T>,
): void => {
  chrome.runtime.sendMessage(
    { name, payload },
    callback,
  );
};

export const receiveMessage = <T extends MessageTypes>(
  name: T,
  responder: (payload: MessagePayload<T>) => Promise<MessageResponse<T>>,
): void => {
  chrome.runtime.onMessage.addListener(
    (request: { name: T, payload: MessagePayload<T> }, _, callback: MessageCallback<T>): boolean => {
      if (request.name !== name) return false;

      responder(request.payload)
        .then(callback)
        .catch(console.log);

      return true;
    },
  );
};

To see this concept in action, check out the flashkill browser extension.