Los mensajes suelen atascar a quien empieza a crear extensiones. El popup, el worker y los scripts de contenido viven en contextos distintos. Los mensajes permiten comunicar esos contextos de forma explícita.
Sin tipos, es fácil acabar con objetos any y fallos durante la ejecución. Aquí construimos un sistema tipado con uniones discriminadas de TypeScript.
Si aún no tienes un proyecto funcional, empieza por crear tu primera extensión con TypeScript.
Las tres piezas del sistema de mensajes
Hay dos métodos de envío y un evento de recepción:
chrome.runtime.sendMessage: envía un mensaje a otros contextos de la extensión, incluido el worker. Lo usan el popup y los scripts de contenido.
chrome.tabs.sendMessage: envía a un script de contenido de una pestaña identificada por su ID.
chrome.runtime.onMessage: escucha mensajes entrantes en los contextos correspondientes.
Los flujos del ejemplo:
Popup ──runtime.sendMessage──► Service Worker
Content Script ──runtime.sendMessage──► Service Worker
Service Worker ──tabs.sendMessage──► Content Script (specific tab)
Esta guía usa el worker como coordinador. El popup también puede llamar directamente a chrome.tabs.sendMessage; pasar por el worker es una decisión de arquitectura. La introducción a Manifest V3 explica cómo encajan estos componentes.
Qué ocurre sin tipos
Muchos ejemplos empiezan así:
// popup.ts, sending
chrome.runtime.sendMessage({ type: "GET_DATA" }, (response) => {
console.log(response.data); // What's in response? Who knows.
});
// background.ts, receiving
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === "GET_DATA") {
sendResponse({ data: "something" });
}
// Did I forget a message type? TypeScript can't tell me.
});
Compila, pero no te protege al renombrar un mensaje, cambiar un campo u olvidar un caso.
Paso 1: define los tipos de mensaje
Crea un archivo compartido entre quien envía y quien recibe:
// src/types/messages.ts
// Each message type is a separate interface
interface GetTabInfoMessage {
type: "GET_TAB_INFO";
}
interface SaveSettingsMessage {
type: "SAVE_SETTINGS";
payload: {
blockedSites: string[];
challengeLevel: "easy" | "medium" | "hard";
};
}
interface InjectContentMessage {
type: "INJECT_CONTENT";
payload: {
tabId: number;
cssSelector: string;
};
}
// Discriminated union, TypeScript can narrow on `type`
export type ExtensionMessage =
| GetTabInfoMessage
| SaveSettingsMessage
| InjectContentMessage;
// Response types, one per message type
export type MessageResponse<T extends ExtensionMessage["type"]> =
T extends "GET_TAB_INFO"
? { title: string; url: string }
: T extends "SAVE_SETTINGS"
? { success: boolean }
: T extends "INJECT_CONTENT"
? { injected: boolean }
: never;
type es el discriminante. Al comprobar message.type, TypeScript puede concretar la variante y la forma de payload.
Paso 2: tipa la función de envío
Envuelve chrome.runtime.sendMessage en una función genérica:
// src/utils/messaging.ts
import type { ExtensionMessage, MessageResponse } from "../types/messages";
export function sendMessage<T extends ExtensionMessage["type"]>(
message: Extract<ExtensionMessage, { type: T }>
): Promise<MessageResponse<T>> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(message, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(response as MessageResponse<T>);
});
});
}
Uso desde el popup:
// src/popup.ts
import { sendMessage } from "./utils/messaging";
async function loadTabInfo() {
// TypeScript knows the response is { title: string; url: string }
const info = await sendMessage({ type: "GET_TAB_INFO" });
document.getElementById("title")!.textContent = info.title;
}
async function saveSettings() {
// TypeScript enforces the payload shape
const result = await sendMessage({
type: "SAVE_SETTINGS",
payload: {
blockedSites: ["reddit.com", "twitter.com"],
challengeLevel: "medium",
},
});
console.log("Saved:", result.success);
}
TypeScript detecta una carga de datos con forma incorrecta y el acceso a un campo que no existe en el tipo de respuesta. Los tipos no sustituyen la validación de datos externos durante la ejecución.
Paso 3: tipa el receptor
El listener del service worker:
// src/background.ts
import type { ExtensionMessage, MessageResponse } from "./types/messages";
chrome.runtime.onMessage.addListener(
(
message: ExtensionMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response: MessageResponse<ExtensionMessage["type"]>) => void
): boolean | undefined => {
switch (message.type) {
case "GET_TAB_INFO": {
// Handle async, MUST return true
chrome.tabs
.query({ active: true, currentWindow: true })
.then(([tab]) => {
sendResponse({
title: tab.title ?? "Unknown",
url: tab.url ?? "Unknown",
});
});
return true; // <-- keeps the message channel open
}
case "SAVE_SETTINGS": {
// message.payload is typed as { blockedSites: string[], challengeLevel: ... }
chrome.storage.sync.set(message.payload).then(() => {
sendResponse({ success: true });
});
return true;
}
case "INJECT_CONTENT": {
// Handle content injection
sendResponse({ injected: true });
return true;
}
}
}
);
El detalle de return true
Con el patrón de callback sendResponse, el listener debe devolver el valor literal true para mantener abierto el canal durante trabajo asíncrono. Devolver nada no lo mantiene abierto.
Chrome admite también listeners que devuelven promesas desde Chrome 148, con condiciones de despliegue y de contextos DevTools descritas en la documentación actual de mensajes. El patrón de callback de esta guía conserva compatibilidad más amplia.
La regla para este patrón: si haces trabajo asíncrono antes de llamar a sendResponse, devuelve true desde el listener.
// BAD, sendResponse called after channel closes
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
fetch("https://api.example.com/data")
.then((res) => res.json())
.then((data) => sendResponse(data)); // Too late, channel is closed
// implicitly returns undefined
});
// GOOD, return true keeps the channel open
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
fetch("https://api.example.com/data")
.then((res) => res.json())
.then((data) => sendResponse(data));
return true; // Channel stays open
});
Olvidarlo puede causar un error de canal cerrado o una respuesta ausente. Revísalo cuando fallen las respuestas asíncronas.
Enviar a scripts de contenido
Para enviar desde el worker a una pestaña, usa su ID con chrome.tabs.sendMessage:
// src/background.ts
async function notifyContentScript(tabId: number, data: unknown) {
try {
const response = await chrome.tabs.sendMessage(tabId, {
type: "UPDATE_UI",
payload: data,
});
console.log("Content script responded:", response);
} catch (error) {
// Content script might not be injected in this tab
console.warn("No content script in tab", tabId);
}
}
El script escucha con chrome.runtime.onMessage.addListener. Cambia el método de envío, no el evento de recepción.
Cuándo usar chrome.runtime.connect
sendMessage sirve para una petición con una respuesta. Si necesitas comunicación continua, como transmitir datos en varios mensajes, abre un puerto:
// popup.ts, open a long-lived connection
const port = chrome.runtime.connect({ name: "popup-channel" });
port.onMessage.addListener((message) => {
console.log("Received:", message);
});
port.postMessage({ type: "SUBSCRIBE_UPDATES" });
Usa sendMessage para petición y respuesta; connect para canales bidireccionales. En muchas extensiones basta el primero.
Cómo depurar los mensajes
En chrome://extensions, abre la inspección del service worker para ver su consola. El popup tiene sus propias herramientas de desarrollo y los scripts de contenido registran en la consola de la página.
Si un mensaje no llega:
- Comprueba que el listener se registró antes del envío.
- Revisa
return truesi usas una respuesta asíncrona con callback. - Confirma que el script de contenido está inyectado en la pestaña.
- Comprueba
chrome.runtime.lastErroren el callback del emisor.
Para Could not establish connection. Receiving end does not exist., consulta las causas de ese error. La guía distingue cinco situaciones reproducidas en Chromium.
Qué viene después
Con la comunicación tipada, los siguientes retos son:
- Conservar el estado: el worker puede terminar y perder su memoria. La guía de almacenamiento explica cómo persistir datos.
- Construir la interfaz: la guía de popup con React y TypeScript ayuda a decidir cuándo compensa usar React.
- Controlar el acceso: la guía de permisos explica qué pueden hacer los distintos contextos.
Esta nota forma parte de la serie Extensiones de Chrome: de cero a producto, donde explico lo que aprendí construyendo ResistGate y Amethyst, desde el primer popup hasta la publicación.
Un pequeño voto de confianza
¿Valió la pena leer esta nota?