Creación de una extensión de navegador desde cero
Tutorial paso a paso para una extensión de Chrome/Firefox con TypeScript: manifest, content scripts, service workers, UI del popup y mensajería.

Las extensiones de navegador son pequeños programas que modifican el comportamiento del navegador. Pueden inyectar UI en páginas web, interceptar peticiones de red, modificar el DOM e interactuar con las APIs del navegador: pestañas, marcadores, almacenamiento, notificaciones. Si has usado un bloqueador de anuncios, un gestor de contraseñas o una herramienta de desarrollo como React DevTools, has usado una extensión de navegador.
Este tutorial construye una extensión práctica desde cero: un estimador de tiempo de lectura que muestra cuánto tardarás en leer cualquier página web.
El archivo manifest
Toda extensión empieza con manifest.json. Declara el nombre de la extensión, los permisos y qué scripts se ejecutan dónde.
{
"manifest_version": 3,
"name": "Reading Time Estimator",
"version": "1.0.0",
"description": "Shows estimated reading time for any web page",
"permissions": [
"activeTab",
"storage"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-script.js"],
"css": ["content-style.css"],
"run_at": "document_idle"
}
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}Los tres contextos de ejecución —content script, popup y service worker en segundo plano— se ejecutan de forma aislada. Se comunican a través de la API de mensajería.
Content script: analizando la página
El content script se ejecuta en el contexto de cada página web que visita el usuario. Tiene acceso al DOM, pero no a las variables JavaScript de la página ni a las APIs en segundo plano de la extensión.
// content-script.ts — Injected into every web page
interface ReadingAnalysis {
wordCount: number;
readingTimeMinutes: number;
headingCount: number;
imageCount: number;
codeBlockCount: number;
}
function analyzePageContent(): ReadingAnalysis {
// Get the main content area — prefer <article> or <main> over <body>
const contentElement =
document.querySelector('article') ??
document.querySelector('main') ??
document.querySelector('[role="main"]') ??
document.body;
// Extract visible text content
const textContent = contentElement.innerText;
const words = textContent.trim().split(/\s+/).filter(w => w.length > 0);
const wordCount = words.length;
// Average reading speed: 238 words per minute (research-based)
const wordsPerMinute = 238;
// Add time for images: ~12 seconds for the first, decreasing after
const images = contentElement.querySelectorAll('img');
const imageTime = Array.from(images).reduce((total, _, index) => {
return total + Math.max(12 - index, 3); // Min 3 seconds per image
}, 0) / 60; // Convert to minutes
// Add time for code blocks: readers slow down for code
const codeBlocks = contentElement.querySelectorAll('pre code, pre');
const codeTime = codeBlocks.length * 0.5; // ~30 seconds per block
const readingTimeMinutes = Math.ceil(
(wordCount / wordsPerMinute) + imageTime + codeTime
);
return {
wordCount,
readingTimeMinutes,
headingCount: contentElement.querySelectorAll('h1, h2, h3').length,
imageCount: images.length,
codeBlockCount: codeBlocks.length,
};
}
// Inject the reading time badge into the page
function injectBadge(analysis: ReadingAnalysis): void {
// Don't inject on pages with very little content
if (analysis.wordCount < 100) return;
const badge = document.createElement('div');
badge.id = 'reading-time-badge';
badge.innerHTML = `
<span class="reading-time-icon">📖</span>
<span class="reading-time-text">${analysis.readingTimeMinutes} min read</span>
<span class="reading-time-words">${analysis.wordCount.toLocaleString()} words</span>
`;
document.body.appendChild(badge);
}
// Run analysis when the page is ready
const analysis = analyzePageContent();
injectBadge(analysis);
// Listen for messages from the popup
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'GET_ANALYSIS') {
sendResponse(analysis);
}
return true; // Keep the message channel open for async response
});Background service worker
El background service worker gestiona eventos y coordina las distintas partes de la extensión. En Manifest V3 es un service worker orientado a eventos que puede ser terminado cuando está inactivo.
// background.ts — Runs as a service worker
interface PageStats {
url: string;
readingTime: number;
visitedAt: number;
}
// Track reading statistics across pages
chrome.runtime.onMessage.addListener(
(message, sender, sendResponse) => {
if (message.type === 'SAVE_STATS') {
savePageStats(message.stats).then(() => {
sendResponse({ success: true });
});
return true; // Indicates async response
}
if (message.type === 'GET_HISTORY') {
getReadingHistory().then((history) => {
sendResponse({ history });
});
return true;
}
}
);
async function savePageStats(stats: PageStats): Promise<void> {
const { readingHistory = [] } = await chrome.storage.local.get('readingHistory');
readingHistory.push(stats);
// Keep only the last 100 entries
const trimmed = readingHistory.slice(-100);
await chrome.storage.local.set({ readingHistory: trimmed });
}
async function getReadingHistory(): Promise<PageStats[]> {
const { readingHistory = [] } = await chrome.storage.local.get('readingHistory');
return readingHistory;
}
// Update the extension badge with reading time for the active tab
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => {
if (changeInfo.status === 'complete') {
try {
const response = await chrome.tabs.sendMessage(tabId, {
type: 'GET_ANALYSIS',
});
if (response?.readingTimeMinutes) {
await chrome.action.setBadgeText({
text: `${response.readingTimeMinutes}m`,
tabId,
});
await chrome.action.setBadgeBackgroundColor({
color: '#2563eb',
tabId,
});
}
} catch {
// Content script not loaded on this page (chrome://, etc.)
}
}
});UI del popup
El popup es una pequeña página HTML que se abre cuando el usuario hace clic en el icono de la extensión. Se comunica con el content script a través de la API de mensajería.
// popup.ts — Logic for the popup UI
document.addEventListener('DOMContentLoaded', async () => {
const timeEl = document.getElementById('reading-time')!;
const wordsEl = document.getElementById('word-count')!;
const detailsEl = document.getElementById('details')!;
try {
// Get the active tab
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
if (!tab.id) {
timeEl.textContent = 'No active tab';
return;
}
// Send a message to the content script in the active tab
const analysis = await chrome.tabs.sendMessage(tab.id, {
type: 'GET_ANALYSIS',
});
if (analysis) {
timeEl.textContent = `${analysis.readingTimeMinutes} min read`;
wordsEl.textContent = `${analysis.wordCount.toLocaleString()} words`;
detailsEl.innerHTML = `
<div class="detail-row">
<span>Headings</span>
<span>${analysis.headingCount}</span>
</div>
<div class="detail-row">
<span>Images</span>
<span>${analysis.imageCount}</span>
</div>
<div class="detail-row">
<span>Code blocks</span>
<span>${analysis.codeBlockCount}</span>
</div>
`;
}
} catch (error) {
timeEl.textContent = 'Cannot analyze this page';
detailsEl.textContent = 'Extension does not have access to this page.';
}
});Mensajería entre contextos
Los tres contextos —content script, popup y background— están aislados y se comunican exclusivamente a través de la API de mensajería de Chrome.
// ❌ Trying to access content script variables from the popup
// This does NOT work — different execution contexts
function popup() {
// Cannot access `analysis` variable from content-script.ts
// const data = analysis; // ReferenceError!
}
// ✅ Using the messaging API to communicate between contexts
// Content script → Background
chrome.runtime.sendMessage({ type: 'SAVE_STATS', stats: pageStats });
// Background → Content script (in a specific tab)
chrome.tabs.sendMessage(tabId, { type: 'GET_ANALYSIS' });
// Popup → Content script (active tab)
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const response = await chrome.tabs.sendMessage(tab.id!, { type: 'GET_ANALYSIS' });
// Type-safe message handling
type ExtensionMessage =
| { type: 'GET_ANALYSIS' }
| { type: 'SAVE_STATS'; stats: PageStats }
| { type: 'GET_HISTORY' };
type ExtensionResponse =
| ReadingAnalysis
| { success: boolean }
| { history: PageStats[] };Configuración del build con TypeScript
Las extensiones necesitan un paso de build para compilar TypeScript y empaquetar los módulos. Una configuración mínima de Webpack o esbuild se encarga de esto.
// build.mjs — esbuild config for the extension
import { build } from 'esbuild';
const shared = {
bundle: true,
minify: process.env.NODE_ENV === 'production',
sourcemap: process.env.NODE_ENV !== 'production',
target: 'chrome100',
};
// Build each entry point separately — they run in different contexts
await Promise.all([
build({
...shared,
entryPoints: ['src/content-script.ts'],
outfile: 'dist/content-script.js',
}),
build({
...shared,
entryPoints: ['src/background.ts'],
outfile: 'dist/background.js',
format: 'esm',
}),
build({
...shared,
entryPoints: ['src/popup.ts'],
outfile: 'dist/popup.js',
}),
]);
console.log('Extension built successfully');Puntos clave
- Tres contextos de ejecución — los content scripts acceden al DOM, los background service workers gestionan eventos y los popups proporcionan la UI; se comunican mediante mensajería
- Manifest V3 usa service workers — no hay páginas de fondo persistentes; diseña para una arquitectura orientada a eventos donde el worker en segundo plano puede ser terminado
- Los content scripts están aislados — pueden leer y modificar el DOM, pero no pueden acceder a las variables JavaScript de la página ni a las APIs de la extensión directamente
- Usa mensajes tipados — define un tipo unión para todos los mensajes para evitar fallos de comunicación entre contextos
- Solicita permisos mínimos —
activeTabes más seguro que<all_urls>como permiso; los usuarios confían más en las extensiones con menos permisos - Compila cada punto de entrada por separado — el content script, el background y el popup son bundles distintos que se ejecutan en entornos aislados


