DualRoute por dentro: abrir pestañas desde el service worker y que tres contextos no se pisen al guardar
Cómo funciona una extensión de Chrome que abre grupos de páginas en modo normal, incógnito o ambos y lleva la cuenta de cuándo se abrió cada una. Un planificador puro, lanzamientos que viven en el service worker, el fallback cuando no hay permiso de incógnito, recordatorios con chrome.alarms, escrituras serializadas con Web Locks y un estado que no puede crecer sin límite.
DualRoute abre con un clic las páginas que reviso cada día, cada una en modo normal, incógnito o en los dos, y apunta cuándo abrí cada una. Qué hace y cómo se usa está en el artículo para usuarios. Este cuenta cómo está hecha.
La primera versión funcionaba en una tarde: una lista de URLs en chrome.storage, un botón que hacía tabs.create por cada una y un contador. Los problemas salieron después, al usarla cada día y revisar el código con calma. Lanzamientos que podían quedarse a medias, recordatorios que solo avisaban con el popup abierto y escrituras que podían borrar una apertura. Casi todo este artículo trata de esos tres fallos.
La forma de la extensión
Es una extensión Manifest V3 hecha con WXT, la interfaz en React 19 con Tailwind CSS 4, todo en TypeScript y validado con Zod. Tiene tres superficies:
- El popup, para el uso diario: buscar, filtrar, marcar y abrir.
- El panel (una página de la extensión), para gestionar rutas, notas, recordatorios, historial, importación y ajustes.
- El service worker, sin interfaz, que abre las pestañas y revisa los recordatorios.
La lógica vive en módulos sin React: launch, reminders, notes, routes, search, exportImport, storage y un domain con los tipos, los esquemas de Zod y los límites. Los componentes solo llaman a esos módulos, y casi todos los tests van contra ellos.
Una decisión que condiciona todo lo demás: la extensión no lee el historial del navegador ni tiene content scripts. Solo sabe lo que se abre desde ella. Pide storage, alarms, notifications y activeTab, y nada más. Eso hace que el registro de aperturas sea fiable para lo que es (lo que abrí desde aquí) y que no haga falta explicar en la ficha de la tienda por qué una lista de enlaces quiere ver todo lo que navegas.
Abrir pestañas: planificar y ejecutar por separado
Abrir una selección de rutas tiene dos partes que conviene no mezclar: decidir qué se abre y dónde, y abrirlo de verdad.
El planificador es una función pura
planLaunch recibe las rutas y los ajustes y devuelve dos listas de URLs, normal e incógnito, y si hace falta confirmación. Una ruta en modo both entra en las dos:
for (const r of params.routes) {
if (r.openMode === "normal") {
normalUrls.push(r.url);
continue;
}
if (r.openMode === "incognito") {
incognitoUrls.push(r.url);
continue;
}
// both
normalUrls.push(r.url);
incognitoUrls.push(r.url);
}
const totalUrls = normalUrls.length + incognitoUrls.length;
const requiresConfirmation =
!params.settings.disableOpenWarning &&
totalUrls >= params.settings.warnOnOpenCount;
No toca ninguna API del navegador, así que se prueba con Vitest sin simular nada. El umbral cuenta pestañas reales, no rutas: tres rutas en both son seis pestañas, y es lo que se compara con el límite.
El ejecutor recibe el navegador desde fuera
executeLaunch sí abre pestañas, pero no llama a browser.tabs directamente. Recibe un adaptador:
export type ChromeApiAdapter = {
openNormal: (url: string) => Promise<void>;
openIncognito: (url: string) => Promise<void>;
openIncognitoMany?: (urls: readonly string[]) => Promise<void>;
sleep: (ms: number) => Promise<void>;
};
En producción, openNormal es un tabs.create con active: false, para que las pestañas se abran detrás sin quitarte la que estás mirando. En los tests es un objeto que apunta lo que le piden y puede fallar a propósito. sleep también va en el adaptador, para que el retardo entre pestañas (0, 250, 500 o 1000 ms) no haga esperar a los tests de verdad.
Las URLs de incógnito no se abren una a una. windows.create acepta un array de URLs, así que todas van en una única ventana de incógnito, sin foco:
await browser.windows.create({
url: urls.length === 1 ? urls[0] : [...urls],
incognito: true,
focused: false,
});
La primera versión creaba una ventana por URL. Con cinco rutas de incógnito acababas con cinco ventanas encima de la que estabas usando.
Sin permiso de incógnito
Chrome no deja que una extensión abra ventanas de incógnito si el usuario no lo ha permitido en chrome://extensions, y viene desactivado. En ese caso windows.create lanza una excepción. El ejecutor la captura, vuelve a abrir esas URLs como pestañas normales y añade un aviso al resultado:
if (incognitoRequested && incognitoFallbackUsed) {
warnings.push({
code: "INCOGNITO_UNAVAILABLE_FALLBACK_NORMAL",
message:
"Incognito access was unavailable. Opened requested incognito URLs in normal mode instead.",
});
}
Me pareció mejor que fallar en silencio o no abrir nada. Si pediste una página es porque querías verla, y el aviso te explica por qué no salió donde esperabas. El efecto raro es que una ruta en both acaba abierta dos veces en normal. Lo dejé así porque el aviso ya dice qué ha pasado.
Las estadísticas solo cuentan lo que se abrió. Si tabs.create falla, esa ruta no suma, y el resultado lleva un aviso OPEN_FAILED con cuántas no se pudieron abrir. Cada apertura que sí llega a abrirse se apunta con el modo real en que se abrió, no con el que se pidió.
El popup no abre pestañas
Este es el cambio que más me costó ver. Al principio el popup hacía todo: planificaba, abría las pestañas una a una y guardaba las estadísticas. Pero el popup de una extensión se cierra en cuanto pierde el foco, y con él se para su JavaScript. Con un retardo de 500 ms y diez pestañas, el lanzamiento tarda cinco segundos. Si en ese tiempo hacías clic en otra cosa, el lanzamiento se cortaba: unas pestañas abiertas, otras no, y las estadísticas sin guardar porque iban al final.
Ahora la interfaz solo manda un mensaje con los ids de las rutas, y el service worker hace todo lo demás:
Mandar ids y no URLs es a propósito. El service worker vuelve a leer las rutas del almacenamiento, así que el popup no puede pedirle que abra algo que no está guardado, ni con un modo distinto del que tiene la ruta.
La confirmación también vive en un solo sitio. El popup y el panel no calculan el umbral: mandan el mensaje con confirmed: false y, si el service worker contesta needsConfirmation, enseñan el diálogo y repiten con confirmed: true. runLaunchFlow encapsula ese ida y vuelta, y las dos interfaces lo usan igual.
Al pasar los lanzamientos al service worker hubo que cuidar un detalle de Chrome. Un listener de runtime.onMessage que devuelve una promesa no sirve de respuesta en todas las versiones de Chrome: lo fiable es devolver true para dejar el canal abierto y contestar después con sendResponse:
runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
if (!isLaunchMessage(message)) return false;
const pending = handleLaunchMessage(message);
if (typeof sendResponse === "function") {
const respond = sendResponse as (response: LaunchResponse) => void;
void pending.then(respond).catch((e: unknown) => {
respond({ ok: false, error: e instanceof Error ? e.message : String(e) });
});
return true;
}
return pending;
});
La rama que devuelve la promesa queda para los tests, que llaman al listener directamente. En el mismo cambio salió que el service worker no llegaba a arrancar bien: el archivo no seguía el formato que WXT espera para el background (export default defineBackground(...)). Los listeners se registran ahora de forma síncrona dentro de esa función, que es lo que necesita Chrome para poder despertar al worker con un mensaje o una alarma.
Recordatorios: una alarma, no un temporizador
Un recordatorio dice "si esta ruta lleva más de N horas sin abrirse desde la extensión, márcala como pendiente". La regla es una comparación de fechas:
export function isReminderDue(params: {
lastOpenedAt?: string;
intervalHours: number;
nowMs: number;
}): boolean {
const intervalMs = hoursToMs(normalizeIntervalHours(params.intervalHours));
const lastOpenedMs = parseIsoToMs(params.lastOpenedAt);
if (lastOpenedMs === null) return true;
return params.nowMs - lastOpenedMs >= intervalMs;
}
Una ruta que nunca se ha abierto está pendiente desde el principio. Si tiene recordatorio es porque quieres revisarla, y "nunca" es el caso más vencido de todos. Al abrirla desde la extensión, el mismo paso que guarda las estadísticas limpia el pendiente.
El problema no estaba en la regla sino en cuándo se ejecutaba. Al principio se evaluaba al abrir el popup o al planificar un lanzamiento. Es decir, la notificación de "tienes rutas pendientes" llegaba justo cuando ya estabas mirando la lista, que es cuando menos falta hace.
En Manifest V3 no sirve un setInterval: el service worker se duerme a los pocos segundos sin actividad y el temporizador se va con él. Lo que sobrevive es chrome.alarms. El worker crea una alarma periódica de un minuto y, cada vez que salta, evalúa los recordatorios y guarda los que pasan a pendientes:
const existing = await getAlarm(alarms, REMINDER_ALARM_NAME);
const existingPeriod =
typeof existing === "object" && existing !== null
? (existing as { periodInMinutes?: unknown }).periodInMinutes
: undefined;
if (!existing || existingPeriod !== REMINDER_PERIOD_MINUTES) {
alarms.create(REMINDER_ALARM_NAME, {
periodInMinutes: REMINDER_PERIOD_MINUTES,
});
}
ensureReminderAlarm se llama al instalar, al arrancar el navegador y cada vez que el worker despierta, porque una alarma no siempre sobrevive a un reinicio del navegador. Comprueba también el periodo: si una versión anterior la creó con otro intervalo, create con el mismo nombre la reemplaza.
La evaluación devuelve qué rutas acaban de pasar a pendientes en esa pasada, no todas las pendientes. La notificación se manda solo por esas, así que una ruta vencida no te avisa cada minuto. Y como la evaluación escribe dentro del lock del que hablo ahora, si el popup y la alarma evalúan a la vez, solo uno ve la transición y solo sale una notificación.
Tres contextos escribiendo el mismo estado
Todo el estado de la extensión (rutas, notas, registro de aperturas y ajustes) es un único objeto bajo la clave appState de chrome.storage.local. Es sencillo de exportar y de validar, pero lo escriben tres sitios a la vez: el popup, el panel y el service worker.
El fallo era el clásico de leer, modificar y guardar. El service worker leía el estado, abría las pestañas durante unos segundos y guardaba las estadísticas. Si mientras tanto editabas una nota en el panel, una de las dos escrituras borraba la otra. No es frecuente, pero basta para perder una apertura del historial o una nota recién escrita.
chrome.storage no tiene transacciones. Lo que sí tienen todos los contextos de una extensión es la Web Locks API, y un lock con nombre se comparte entre el popup, el panel y el service worker del mismo origen:
const STATE_WRITE_LOCK = "dualroute:app-state-write";
async function withWriteLock<T>(task: () => Promise<T>): Promise<T> {
const locks = (globalThis as { navigator?: Navigator }).navigator?.locks;
if (locks) {
return locks.request(STATE_WRITE_LOCK, task);
}
const result = localWriteQueue.then(task, task);
localWriteQueue = result.then(
() => undefined,
() => undefined,
);
return result;
}
Encima va un único método de escritura, update(transform), que dentro del lock lee el estado más reciente, le aplica la transformación, lo valida con Zod y lo guarda. Todas las mutaciones pasan por ahí. Si no hay Web Locks (en los tests unitarios), una cola de promesas en memoria hace lo mismo dentro de un contexto.
El lanzamiento usa una variante. Abrir las pestañas puede tardar segundos, y no quería tener el lock cogido todo ese tiempo, bloqueando cualquier edición en el panel. Así que las pestañas se abren fuera del lock y luego, dentro, se aplican las estadísticas sobre el estado que haya en ese momento:
await storage.update((latest) => {
const validRouteIds = new Set(latest.routes.map((r) => r.id));
const openedRoutes = execution.openedRoutes.filter((record) =>
validRouteIds.has(record.routeId),
);
return applyLaunchStatsAndLogs(latest, openedRoutes, source);
});
El filtro cubre el caso de borrar una ruta en el panel mientras se está abriendo: su apertura no se apunta a una ruta que ya no existe.
Lo que entra y lo que crece
Cada lectura del almacenamiento pasa por tres pasos: migrar si el formato es de una versión anterior, validar con Zod y recortar. Validar en la lectura y no solo en la escritura es lo que permite que la importación y el almacenamiento usen el mismo esquema. Un JSON importado pasa por las mismas migraciones y la misma validación que el estado guardado, y además se comprueba que no haya dos rutas con la misma URL. Para eso las URLs se normalizan antes de comparar: protocolo y dominio en minúsculas, sin el puerto por defecto y sin barra final, pero con la query y el fragmento intactos, porque ?id=1 y ?id=2 son páginas distintas.
Recortar hizo falta porque el registro de aperturas y el historial de revisiones de las notas solo crecían. El plan original decía "guardar el historial completo", y para una nota que editas a diario eso son cientos de versiones al año. Como el estado se lee y se escribe entero en cada operación, cada apertura hacía la siguiente un poco más lenta. Ahora hay dos límites:
export const MAX_LAUNCH_LOGS = 1000;
export const MAX_NOTE_REVISIONS = 20;
pruneAppState se queda con los más recientes y devuelve la misma referencia si no hay nada que recortar. El repositorio solo escribe si la migración o el recorte han cambiado algo. Así, que el worker despierte cada minuto por la alarma no supone una escritura cada minuto.
Tests
Hay 61 tests de Vitest, unitarios y de integración. Cubren el planificador (modos, umbral, el doble conteo de both), el ejecutor con el adaptador falso (fallos de tabs.create, el fallback sin incógnito, que las estadísticas solo cuenten lo abierto), los recordatorios con fechas fijas, las notas y sus revisiones, la normalización de URLs, la exportación y la importación, y el repositorio, incluidas escrituras concurrentes que no se pueden pisar. El manejador de mensajes del service worker se prueba llamándolo directamente con un almacenamiento en memoria.
Encima hay 14 tests de Playwright que cargan la extensión compilada en un Chromium real: un lanzamiento mezclando los tres modos desde el popup, el aviso cuando no hay permiso de incógnito, filtrar y seleccionar visibles, el historial de revisiones de una nota, el error al importar un JSON roto y el ciclo completo de un recordatorio (vence, queda pendiente y se limpia al abrir la ruta). Las capturas del artículo para usuarios las saqué con el mismo tipo de montaje: Playwright carga la extensión, escribe un estado de ejemplo en chrome.storage.local desde el service worker y fotografía cada página.
Lo que me llevo
La lógica de DualRoute cabe en unas pocas funciones puras: repartir URLs en dos listas, comparar fechas y normalizar una dirección. Lo difícil estaba en el modelo de ejecución de las extensiones. Un popup que muere cuando pierde el foco, un service worker que se duerme y se lleva sus temporizadores, y tres contextos que comparten un almacenamiento sin transacciones. Cada uno de los fallos que encontré venía de tratar alguna de esas piezas como si fuera una página web normal que sigue viva mientras la necesitas.
Está en la Chrome Web Store.