Simple TTS por dentro: mi primera extensión, de JavaScript a WXT con Vue
Cómo pasó Simple TTS de tres archivos de JavaScript a una extensión Manifest V3 con WXT, Vue 3 y TypeScript. Qué me hizo cambiar en cada etapa, cómo se mantiene vivo un service worker durante una lectura larga, por qué leía la frase del día anterior y cómo se consigue que el resaltado vaya a la par de la voz.
Leía muchos artículos, foros e hilos de Reddit, y a menudo prefería escucharlos. Quería algo simple, privado, sin APIs de terceros y a mi gusto, así que hice Simple TTS, una extensión que lee en voz alta cualquier texto de una web. Qué hace y cómo se usa está en el artículo para usuarios. Este cuenta cómo está hecha y, sobre todo, cómo llegó hasta aquí.
Porque fue mi primera extensión, y la he rehecho varias veces. Empezó como tres archivos de JavaScript, pasó a Vue con Vite, de ahí a WXT y acabó en TypeScript con tests. Cada cambio vino de un problema concreto, y con cada uno aprendí cómo se hacen hoy las extensiones de navegador.
Una regla no ha cambiado en ninguna etapa: no hay motor de voz propio ni API externa. Toda la voz sale de chrome.tts, la API de síntesis del navegador. Por eso en Edge están sus voces naturales y en Chrome las de Google, y por eso el texto no pasa por ningún servidor mío.
Etapa 1: JavaScript a pelo (febrero)
El primer commit, del 1 de febrero de 2026, tenía un manifest.json, un background.js, un content.js, un popup.html con su popup.js y tres iconos. Unas 680 líneas en total. El background creaba la opción del menú contextual y llamaba a chrome.tts.speak con el texto seleccionado; el content script dibujaba un reproductor encima de la página.
Ese mismo día ya había reproductor arrastrable, atajos de teclado, modo oscuro, cola de lectura y ventana independiente. Escribir sin framework tiene eso: nada te frena al principio. Al día siguiente cambié el troceo del texto en frases por Intl.Segmenter, porque partir por puntos con una expresión regular rompía en abreviaturas y decimales.
El problema llegó con el crecimiento. Un mes después, content.js tenía más de 800 líneas: HTML montado a mano con cadenas, estilos, eventos de arrastre y redimensionado, y la sincronización con el background, todo junto. El popup y la ventana independiente repetían buena parte de la interfaz por su cuenta. Cada cambio en los controles había que hacerlo en tres sitios.
Etapa 2: Vue y Vite (marzo)
El 4 de marzo pasé la interfaz a Vue 3 con Vite y el plugin CRXJS. Vue lo uso en otros proyectos, y era lo natural para tener componentes compartidos: los controles de reproducción y la barra de progreso pasaron a ser componentes que usan el popup, la ventana y el reproductor flotante.
Meter Vue en una extensión no fue tan directo como en una web. Tres problemas de esa semana:
- El servidor de desarrollo y el CORS. En desarrollo, Vite sirve los módulos desde
localhost, y las páginas de la extensión los piden desdechrome-extension://. Hubo que abrir el CORS del servidor de desarrollo para que la recarga en caliente funcionara. - Vue dentro de un Shadow DOM. El reproductor flotante vive dentro de páginas ajenas, así que va dentro de un Shadow DOM para que los estilos de la web no lo rompan, ni los suyos rompan la web. Pero los estilos de un componente Vue normal se inyectan en el
<head>del documento, fuera del Shadow DOM. La solución fue compilarlo como custom element: con la extensión.ce.vue, Vue entrega los estilos del componente enFloatingPlayer.styles, y el content script los mete a mano dentro de la raíz sombra. - La inyección del content script en producción. En desarrollo funcionaba, pero en la build de producción CRXJS no conseguía inyectarlo dinámicamente y el reproductor salía roto o invisible. Lo resolví declarándolo estático en el manifest, para que se cargara en todas las páginas.
Funcionaba, pero ese último arreglo no me gustaba: una extensión que lee lo que tú le pides no debería cargarse en todas las webs que visitas.
Etapa 3: WXT (abril)
El 21 de abril migré a WXT, un framework para extensiones construido sobre Vite. Es la etapa en la que más aprendí, porque WXT trae de serie las convenciones con las que hoy se hacen las extensiones:
- Cada pieza es un entrypoint en su carpeta:
background.ts,content-script.ts,offscreen-script.ts,popup/yplayer/. WXT los empaqueta por separado y genera el manifest a partir de ellos. - El manifest se escribe en
wxt.config.ts, en TypeScript, con los textos traducibles como__MSG_extensionName__que resuelve_locales/. - Cada entrypoint envuelve su código en
defineBackgroundodefineUnlistedScript. WXT importa esos archivos durante la build para leer su configuración, así que nada puede ejecutarse al nivel superior del módulo. - Vue entra como módulo (
@wxt-dev/module-vue), sin configurar Vite a mano.
Y me permitió deshacer el arreglo que no me gustaba. El content script volvió a inyectarse bajo demanda: es un unlisted script, no aparece en el manifest como content script, y el background lo inyecta con chrome.scripting.executeScript solo en la pestaña donde pides leer. Para eso basta el permiso activeTab, que se concede al usar el menú contextual o el atajo. La extensión no tiene permisos de host: no puede leer ninguna web que no le hayas pedido.
permissions: ["contextMenus", "storage", "tts", "scripting", "activeTab", "offscreen"],
Después vinieron la traducción (primero inglés y español, y en mayo francés, alemán, chino y japonés), el selector de voces agrupado por idioma y el modo «Leer artículo completo». Los entrypoints ya eran .ts desde la migración; en mayo terminé de pasar a TypeScript los composables y las utilidades.
Cómo encaja todo
Simple TTS es una extensión Manifest V3, y casi todas sus decisiones salen de una restricción de esa plataforma: el service worker no vive para siempre. Chrome lo suspende tras unos 30 segundos sin actividad, y con él se pierde todo lo que haya en memoria.
El service worker es el cerebro. Registra el menú contextual y los atajos, recibe todos los mensajes y lleva el motor de lectura. Guarda en memoria el texto, las frases, el índice de la frase actual y el estado (sonando, en pausa, parado, terminado).
El content script monta el reproductor flotante en el Shadow DOM y aplica el resaltado en la página. El popup y la ventana independiente son páginas Vue que hablan con el service worker por mensajes. Una ventana recién abierta no sabe nada de la lectura en curso, así que al montarse pide el estado con GET_STATUS.
Los ajustes van a chrome.storage.sync, que se sincroniza entre los ordenadores donde tienes sesión. El estado de la lectura y las estadísticas van a chrome.storage.local.
Lectura frase a frase
La voz no lee el texto de una vez. El service worker lo trocea en frases con Intl.Segmenter y las manda a chrome.tts.speak de una en una. Cuando una termina (el evento end), pasa a la siguiente. Así se puede saltar a la frase anterior o a la siguiente, y cambiar la velocidad o la voz sin volver al principio: basta con repetir la frase actual con el ajuste nuevo.
Para resaltar lo que suena hace falta saber dónde empieza cada frase dentro del texto completo. Al principio se calculaba sumando la longitud de las frases anteriores, y el resaltado se iba desplazando: los trozos que solo eran espacios o saltos de línea se descartaban al trocear, y sin ellos la suma no cuadraba. Ahora cada frase se busca en el texto original a partir de la anterior, y su posición se guarda una sola vez:
export function splitWithOffsets(text: string): SentenceSegment[] {
if (!text) return [];
const segments = splitIntoSentences(text);
const result: SentenceSegment[] = [];
let searchFrom = 0;
for (const seg of segments) {
const found = text.indexOf(seg, searchFrom);
if (found !== -1) {
result.push({ segment: seg, offset: found });
searchFrom = found + seg.length;
} else {
result.push({ segment: seg, offset: searchFrom });
searchFrom += seg.length;
}
}
return result;
}
Con eso, cada evento word de chrome.tts trae la posición de la palabra dentro de la frase. Sumándole la posición de la frase sale la posición global, y esa es la que reciben el reproductor y la página. splitIntoSentences envuelve Intl.Segmenter en un try/catch con un troceo por puntuación de reserva, por si algún navegador no lo tiene.
La cola de lectura reutiliza todo esto. Añadir un texto lo concatena al actual, trocea solo el trozo nuevo y desplaza sus posiciones sumando la longitud de lo que ya había.
Que el service worker no se duerma a mitad de un artículo
Un artículo largo puede tardar varios minutos en leerse, y el service worker tiene que seguir despierto para recibir los eventos de la voz y encadenar las frases. Si Chrome lo suspende entre dos eventos, la lectura se para.
La solución es un documento offscreen: una página oculta que la extensión puede crear con chrome.offscreen.createDocument. Su único código manda un mensaje al service worker cada 20 segundos, y cada mensaje entrante reinicia el contador de inactividad:
export default defineUnlistedScript(() => {
setInterval(() => {
chrome.runtime.sendMessage({ action: 'KEEP_ALIVE' }).catch(() => {});
}, 20000);
});
Se crea al empezar a hablar y se cierra al pausar, parar o terminar, para no mantener nada vivo sin motivo. Como varias frases pueden pedirlo a la vez, la creación guarda la promesa en curso y un indicador de «ya creado», y antes de crear comprueba con chrome.runtime.getContexts si ya existe uno.
La lectura que salía del día anterior
Aun con el offscreen, el service worker se suspende en cuanto dejas de usar la extensión. Por eso el estado se guarda en chrome.storage.local y se restaura al despertar. Al guardar, una lectura que estaba sonando o había terminado se guarda en pausa, para que al volver se pueda reanudar en vez de fingir una voz que ya no existe.
Un día empezó a pasar algo raro: la primera vez tras encender el ordenador, seleccionaba un texto, pedía leerlo y sonaba la última frase del día anterior. La segunda vez funcionaba bien.
La causa era el orden de los eventos. La restauración es asíncrona, y no todos los puntos de entrada la esperaban: los atajos sí, pero el menú contextual no. Y el clic en «Leer texto seleccionado» es precisamente lo que despierta al service worker dormido. La secuencia real era esta:
- El clic despierta el service worker.
- Arranca la lectura asíncrona del estado guardado.
- Se dispara el clic y empieza a leer el texto nuevo.
- Llega el estado guardado de ayer y lo sobrescribe todo.
El arreglo fue que todos los puntos de entrada esperen a la misma promesa:
let readyPromise = restorePlaybackState();
chrome.contextMenus.onClicked.addListener((info, tab) => {
readyPromise.then(() => {
// ...
});
});
Un efecto secundario: como el listener de mensajes ya no puede responder de forma síncrona en ningún caso, ahora devuelve siempre true para dejar abierto el canal de respuesta.
Lo interesante fue el test. Para reproducir el fallo había que controlar cuándo se resuelve la lectura de chrome.storage, y el navegador falso de wxt/testing no lo permite. Escribí un mock de chrome a mano con un método que resuelve esa lectura cuando el test quiere. El test dispara el clic en «Leer texto seleccionado» con un texto nuevo, resuelve después el almacenamiento con el estado de ayer y pregunta al service worker qué está leyendo: tiene que responder con el texto nuevo. Lo escribí antes del arreglo y lo vi fallar.
Cuando la voz se queda muda
Algunas voces, a veces, dejan de mandar eventos: ni word ni end. La lectura se queda colgada en una frase, sin error, y el reproductor dice que está sonando mientras no suena nada.
Para detectarlo, el service worker lleva la cuenta del ritmo de lectura. Cada evento word mide cuántos caracteres han avanzado y en cuánto tiempo, y lo suaviza con una media móvil exponencial. Con eso calcula el porcentaje leído y el tiempo restante que enseña el reproductor. Hasta tener la primera medida usa una estimación de 15 caracteres por segundo a velocidad 1×.
Con el mismo ritmo calcula también un plazo para cada frase: el tiempo que debería tardar en terminar, multiplicado por 2,5, y nunca menos de 6 segundos.
function computeStallDeadline(remainingChars: number, rate: number) {
const effectiveRate = rate || 1.0;
const expectedMs = (remainingChars / (BASELINE_CHARS_PER_SECOND * effectiveRate)) * 1000;
return Date.now() + Math.max(6000, expectedMs * 2.5);
}
Un vigilante revisa cada 2 segundos si se ha pasado el plazo sin progreso. Si es así, repite la frase. Si la misma frase se atasca cuatro veces seguidas, la salta, o da la lectura por terminada si era la última. Al pausar, el plazo se alarga lo que haya durado la pausa, para no confundir una pausa con un atasco.
El resaltado en la página
La primera versión del resaltado en la página envolvía la palabra en un <span>. Funcionaba, pero modificaba el HTML de una web ajena, y eso podía romper su diseño o sus scripts. El mismo día lo cambié por la CSS Custom Highlight API. Un TreeWalker recorre los nodos de texto de la página, se calcula un Range sobre ellos y se registra en CSS.highlights, sin añadir ni quitar un solo nodo.
Es una opción desactivada por defecto, que se activa en los ajustes.
En la versión 2.4.6 rehíce cómo se encuentra el texto en la página. El texto que lee la voz viene de la selección o del extractor de artículos, que colapsan los espacios y meten saltos de línea entre bloques; el DOM conserva el formato del HTML original. Al compararlos carácter a carácter, el resaltado acababa en otra palabra. Ahora:
- La comparación ignora los espacios y se vuelve a alinear cuando las dos versiones se separan.
- La búsqueda parte de donde estaba tu selección, para no resaltar otro párrafo que diga lo mismo.
- Con «Leer artículo completo» busca dentro del artículo.
- La correspondencia se construye una vez por lectura, no en cada palabra.
Y la página se desplaza sola para seguir la lectura, pero deja de hacerlo en cuanto haces scroll tú, hasta que reanudas tras una pausa.
Una deuda que dejé a propósito
El extractor de artículos está duplicado: existe en utils/articleExtractor.ts y otra vez, casi igual, dentro de background.ts. La función que se pasa a chrome.scripting.executeScript se serializa y se ejecuta dentro de la página, así que tiene que ser autocontenida: no puede importar nada. Busca el cuerpo del artículo con trece selectores ([itemprop="articleBody"], article, main…), quita la navegación, los laterales y los anuncios, y se queda con el primero que tenga al menos 100 palabras.
Podría generar esa función en la build, pero de momento la duplicación está documentada y los tests cubren la versión de utils/.
Etapa 4: TypeScript, tests y un método (de mayo en adelante)
Hasta mediados de mayo apenas tenía tests: un test de Playwright de la etapa de Vite que nunca llegó a funcionar y otro del composable de reproducción. El 19 de mayo hice una revisión completa del código y salió una lista de diez fallos confirmados y nueve mejoras. Entre los fallos estaban una doble pausa (el popup llamaba a chrome.tts.pause() y además se lo pedía al background, que volvía a pausar) y el desfase del resaltado que conté antes.
Ese día dejé montado Vitest con happy-dom y fui arreglando la lista con un plan por fase. Desde junio, los cambios grandes siguen un ciclo más estricto: especificación, plan, test que falla y arreglo, y los documentos de cada uno se quedan en el repositorio. Así se hizo, por ejemplo, el arreglo de la lectura del día anterior.
Hoy hay 112 tests en 14 archivos. Cubren el troceo en frases, el motor del background (incluida la condición de carrera), el resaltado, el seguimiento del scroll, el extractor, el formato del tiempo restante y los componentes del reproductor.
En agosto llegaron los tests de extremo a extremo con Playwright, que cargan la extensión de verdad en Chromium y comprueban que se carga, que se puede reproducir, pausar, reanudar y parar, y que se abre la ventana independiente. Tuvieron su truco: Playwright no puede pulsar el icono de la barra de herramientas, ni abrir el menú contextual nativo, y los atajos que envía no llegan a chrome.commands. Sin esos gestos, activeTab nunca se concede y el content script no se puede inyectar. La solución fue que la build de pruebas añada permisos de host, y solo ella:
...(process.env.E2E ? { host_permissions: ["<all_urls>"] } : {}),
La build normal, la que se publica, no los lleva.
Lo que no hace
Firefox no está soportado, y no es un cambio pequeño: el código usa chrome.* directamente y, sobre todo, Firefox no tiene un equivalente de chrome.tts. Habría que plantear la voz de otra forma, probablemente con la Web Speech API desde la propia página, y eso cambia buena parte de la arquitectura.
Lo que me llevo
Cada etapa me dejó algo. Con JavaScript a pelo entendí qué hace cada pieza de una extensión sin que nada lo esconda. Con Vite y CRXJS me peleé con el CORS, el Shadow DOM y la inyección de scripts, y entendí por qué existen frameworks como WXT. Con WXT aprendí cómo se hace hoy una extensión: entrypoints, manifest tipado, permisos mínimos e inyección bajo demanda. Y encajé todo eso con Vue, que es lo que uso en el resto de mis proyectos.
Y el service worker me obligó a pensar de otra manera: que te lo puedan apagar en cualquier momento obliga a pensar qué estado guardas, cuándo lo restauras y en qué orden llegan las cosas. La lectura de ayer que sonaba hoy fue el mejor recordatorio.