Saltar al contenido

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.

5 min de lectura
Diagrama de la arquitectura de una extensión de navegador que muestra el popup, el content script y el service worker en segundo plano

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.

jsonjson
{
  "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.

tstypescript
// 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.

tstypescript
// 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.

tstypescript
// 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.

tstypescript
// ❌ 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.

tstypescript
// 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

  1. 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
  2. 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
  3. 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
  4. Usa mensajes tipados — define un tipo unión para todos los mensajes para evitar fallos de comunicación entre contextos
  5. Solicita permisos mínimos — activeTab es más seguro que <all_urls> como permiso; los usuarios confían más en las extensiones con menos permisos
  6. Compila cada punto de entrada por separado — el content script, el background y el popup son bundles distintos que se ejecutan en entornos aislados
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX