`Shared Worker` en TypeScript
Este artículo explica Shared Worker
en TypeScript.
Explicaremos en detalle cómo funcionan los Shared Workers y cómo usarlos en la práctica, con ejemplos de código TypeScript.
YouTube Video
Shared Worker
en TypeScript
Shared Worker
es un único proceso de worker compartido entre varias pestañas, ventanas e iframes del mismo origen. Con ello puedes manejar estado y recursos compartidos entre múltiples pestañas del navegador.
Por ejemplo, puedes implementar eficientemente una conexión WebSocket compartida, caché y procesamiento en cola sincronizados entre pestañas, y exclusión mutua.
A diferencia de un Dedicated Worker
, un Shared Worker
recibe múltiples MessagePort
a través del evento onconnect
y puede multiplexar la comunicación con varios clientes.
Casos en los que conviene elegir un Shared Worker
En los siguientes casos, usar un Shared Worker
es apropiado.
- Cuando necesitas estado compartido o exclusión mutua entre pestañas
- Cuando quieres compartir una única conexión WebSocket o el acceso a IndexedDB
- Cuando necesitas notificar a todas las pestañas (broadcast)
- Cuando quieres centralizar el procesamiento pesado para ahorrar recursos
Por el contrario, en los siguientes casos, otros enfoques son más adecuados.
- Cuando necesites control de caché o soporte sin conexión, puedes usar un
Service Worker
. - Para procesamiento pesado que esté confinado a una sola pestaña, puedes usar un
Dedicated Worker
.
Pasos de implementación para Shared Worker
Aquí implementaremos lo siguiente paso a paso utilizando TypeScript.
- Protocolo de mensajes con tipado seguro
- Solicitud/respuesta (RPC) basada en promesas
- Difusión a todas las pestañas
- Heartbeat y limpieza de clientes
Configuración del entorno
Crea la configuración para compilar cada archivo fuente que utiliza un Shared Worker
.
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ES2020",
5 "lib": ["ES2020", "dom", "WebWorker"],
6 "moduleResolution": "Bundler",
7 "strict": true,
8 "noEmitOnError": true,
9 "outDir": "out",
10 "skipLibCheck": true
11 },
12 "include": ["src"]
13}
- En
tsconfig-src.json
, habilita las definiciones de tipos de DOM y Web Worker para que el código pueda compilarse de forma segura.
Definición del protocolo de mensajes
La base de la comunicación es un contrato de mensajes tipado. Definir esto por adelantado hace que la comunicación posterior sea segura y fácil de ampliar.
1// worker-protocol.ts
2// Define discriminated unions for structured messages
3
4export type RequestAction =
5 | { type: 'ping' }
6 | { type: 'echo'; payload: string }
7 | { type: 'getTime' }
8 | { type: 'set'; key: string; value: unknown }
9 | { type: 'get'; key: string }
10 | { type: 'broadcast'; channel: string; payload: unknown }
11 | { type: 'lock.acquire'; key: string; timeoutMs?: number }
12 | { type: 'lock.release'; key: string };
RequestAction
es una unión discriminada (unión etiquetada) que representa los tipos de solicitudes enviadas al worker y define operaciones comoping
,get
ybroadcast
.
1export interface RequestMessage {
2 kind: 'request';
3 id: string;
4 from: string;
5 action: RequestAction;
6}
RequestMessage
define la estructura de los mensajes de solicitud enviados desde el cliente al worker.
1export interface ResponseMessage {
2 kind: 'response';
3 id: string;
4 ok: boolean;
5 result?: unknown;
6 error?: string;
7}
ResponseMessage
define la estructura de los mensajes de respuesta devueltos del worker al cliente.
1export interface BroadcastMessage {
2 kind: 'broadcast';
3 channel: string;
4 payload: unknown;
5 from: string;
6}
BroadcastMessage
define la estructura de los mensajes de difusión que el worker envía a otros clientes.
1export type WorkerInMessage =
2 | RequestMessage
3 | { kind: 'heartbeat'; from: string }
4 | { kind: 'bye'; from: string };
WorkerInMessage
es un tipo que representa todos los mensajes que el worker recibe, como solicitudes, latidos (heartbeats) y notificaciones de desconexión.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
WorkerOutMessage
es un tipo que representa los mensajes de respuesta o difusión que el worker envía al cliente.
1export const randomId = () => Math.random().toString(36).slice(2);
randomId
es una función que genera una cadena alfanumérica aleatoria para usar como ID de mensajes y similares.
Implementación del Shared Worker
En shared-worker.ts
, registra las pestañas que se conectan mediante el evento onconnect
y maneja los mensajes.
1// shared-worker.ts
2/// <reference lib="webworker" />
- Esta directiva le indica a TypeScript que cargue las definiciones de tipos para Web Workers.
1import {
2 WorkerInMessage,
3 WorkerOutMessage,
4 RequestMessage,
5 ResponseMessage,
6} from './worker-protocol.js';
- Importa las definiciones de tipos usadas para la comunicación con el worker.
1export default {};
2declare const self: SharedWorkerGlobalScope;
- Declara explícitamente que
self
es el ámbito global delShared Worker
.
1type Client = {
2 id: string;
3 port: MessagePort;
4 lastBeat: number;
5};
Client
es un tipo que representa el identificador de cada cliente, el puerto de comunicación y la marca de tiempo del último latido.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
- Gestiona la lista de clientes conectados, un almacén clave-valor, el estado de los bloqueos y las duraciones de los tiempos de espera.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2 port.postMessage(msg);
3}
send
es una función de utilidad que envía un mensaje al puerto especificado.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2 return { kind: 'response', id: req.id, ok, result, error };
3}
respond
genera un mensaje de respuesta para una solicitud.
1function broadcast(from: string, channel: string, payload: unknown) {
2 for (const [id, c] of clients) {
3 send(c.port, { kind: 'broadcast', channel, payload, from });
4 }
5}
broadcast
envía un mensaje en un canal especificado a todos los clientes.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
handleRequest
procesa las solicitudes entrantes por tipo y devuelve los resultados al cliente.
1 const { action } = req;
2 try {
3 switch (action.type) {
4 case 'ping':
5 send(port, respond(req, true, 'pong'));
6 break;
7 case 'echo':
8 send(port, respond(req, true, action.payload));
9 break;
10 case 'getTime':
11 send(port, respond(req, true, new Date().toISOString()));
12 break;
13 case 'set':
14 kv.set(action.key, action.value);
15 send(port, respond(req, true, true));
16 break;
17 case 'get':
18 send(port, respond(req, true, kv.get(action.key)));
19 break;
20 case 'broadcast':
21 broadcast(clientId, action.channel, action.payload);
22 send(port, respond(req, true, true));
23 break;
- En este código, según el tipo de solicitud recibida, se encarga de enviar y recibir mensajes, obtener y guardar datos, y realizar difusión.
1 case 'lock.acquire': {
2 const owner = locks.get(action.key);
3 if (!owner) {
4 locks.set(action.key, clientId);
5 send(port, respond(req, true, { owner: clientId }));
6 } else if (owner === clientId) {
7 send(port, respond(req, true, { owner }));
8 } else {
9 const start = Date.now();
10 const tryWait = () => {
11 const current = locks.get(action.key);
12 if (!current) {
13 locks.set(action.key, clientId);
14 send(port, respond(req, true, { owner: clientId }));
15 } else if ((action.timeoutMs ?? 5000) < Date.now() - start) {
16 send(port, respond(req, false, undefined, 'lock-timeout'));
17 } else {
18 setTimeout(tryWait, 25);
19 }
20 };
21 tryWait();
22 }
23 break;
24 }
- Este código implementa el proceso para que un cliente adquiera un bloqueo para la clave especificada. Si el bloqueo aún no está retenido, se adquiere inmediatamente; si el mismo cliente lo solicita de nuevo, la solicitud también se considera satisfactoria. Si otro cliente ya tiene el bloqueo, vuelve a intentarlo cada 25 milisegundos hasta que se libere el bloqueo y, si se supera el tiempo de espera especificado (predeterminado de 5 segundos), responde con un error.
1 case 'lock.release':
2 if (locks.get(action.key) === clientId) {
3 locks.delete(action.key);
4 send(port, respond(req, true, true));
5 } else {
6 send(port, respond(req, false, undefined, 'not-owner'));
7 }
8 break;
9 default:
10 send(port, respond(req, false, undefined, 'unknown-action'));
11 }
12 } catch (e: any) {
13 send(port, respond(req, false, undefined, e?.message ?? 'error'));
14 }
15}
- Este código libera el bloqueo que mantiene el cliente y devuelve una respuesta de error si el cliente carece de permisos o si la acción es desconocida.
1function handleInMessage(c: Client, msg: WorkerInMessage) {
2 if (msg.kind === 'heartbeat') {
3 c.lastBeat = Date.now();
4 } else if (msg.kind === 'bye') {
5 cleanupClient(msg.from);
6 } else if (msg.kind === 'request') {
7 handleRequest(c.id, c.port, msg);
8 }
9}
handleInMessage
analiza los mensajes recibidos de los clientes y maneja las solicitudes y los latidos.
1function cleanupClient(clientId: string) {
2 for (const [key, owner] of locks) {
3 if (owner === clientId) locks.delete(key);
4 }
5 clients.delete(clientId);
6}
cleanupClient
elimina los clientes desconectados del registro y del estado de bloqueos.
1setInterval(() => {
2 const now = Date.now();
3 for (const [id, c] of clients) {
4 if (now - c.lastBeat > HEARTBEAT_TIMEOUT) cleanupClient(id);
5 }
6}, 10_000);
- Usa
setInterval
para comprobar periódicamente los latidos de todos los clientes y limpiar las conexiones que han excedido el tiempo de espera.
1self.onconnect = (e: MessageEvent) => {
2 const port = (e.ports && e.ports[0]) as MessagePort;
3 const clientId = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
4 const client: Client = { id: clientId, port, lastBeat: Date.now() };
5 clients.set(clientId, client);
6
7 port.addEventListener('message', (ev) => handleInMessage(client, ev.data as WorkerInMessage));
8 send(port, { kind: 'broadcast', channel: 'system', payload: { hello: true, clientId }, from: 'worker' });
9 port.start();
10};
-
onconnect
se llama cuando una nueva pestaña o página se conecta alShared Worker
, registrando al cliente e iniciando la comunicación. -
A lo largo de este archivo se implementan los mecanismos fundamentales de un
Shared Worker
que permiten la gestión de estado compartido y la comunicación entre múltiples pestañas del navegador.
Envoltura de cliente (RPC)
A continuación, crea un cliente RPC basado en promesas.
1// shared-worker-client.ts
2import {
3 RequestAction,
4 RequestMessage,
5 WorkerOutMessage,
6 randomId
7} from './worker-protocol.js';
- Importa las definiciones de tipos y las funciones de utilidad utilizadas para la comunicación con el worker.
1export type BroadcastHandler = (msg: {
2 channel: string;
3 payload: unknown;
4 from: string
5}) => void;
- Aquí definimos el tipo de la función de retorno de llamada (callback) que se ejecuta cuando se recibe un mensaje de difusión.
1export class SharedWorkerClient {
SharedWorkerClient
es una clase cliente que se comunica con unShared Worker
, enviando solicitudes y manejando respuestas.
1 private worker: SharedWorker;
2 private port: MessagePort;
3 private pending = new Map<string, {
4 resolve: (v: any) => void;
5 reject: (e: any) => void
6 }>();
- Estas variables son la instancia del worker, el puerto de comunicación con el worker y un mapa que rastrea las solicitudes en espera de respuesta.
1 private clientId = randomId();
2 private heartbeatTimer?: number;
3 private onBroadcast?: BroadcastHandler;
- Estas variables almacenan el identificador del cliente, el temporizador para enviar latidos y el manejador de recepción de difusión.
1 constructor(url: URL, name = 'app-shared', onBroadcast?: BroadcastHandler) {
2 this.worker = new SharedWorker(url, { name, type: 'module' as any });
3 this.port = this.worker.port;
4 this.onBroadcast = onBroadcast;
- En el constructor, inicializa la conexión al
Shared Worker
y configura los escuchas de mensajes y el envío de latidos.
1 this.port.addEventListener('message', (ev) => {
2 const msg = ev.data as WorkerOutMessage;
3 if (msg.kind === 'response') {
4 const p = this.pending.get(msg.id);
5 if (p) {
6 this.pending.delete(msg.id);
7 msg.ok ? p.resolve(msg.result) : p.reject(new Error(msg.error || 'error'));
8 }
9 } else if (msg.kind === 'broadcast') {
10 this.onBroadcast?.(msg);
11 }
12 });
13 this.port.start();
- Aquí recibe mensajes del worker y maneja respuestas o difusiones.
1 this.heartbeatTimer = window.setInterval(() => {
2 this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3 }, 10_000);
- Envía mensajes de latido periódicamente para mantener la conexión viva.
1 window.addEventListener('beforeunload', () => {
2 try {
3 this.port.postMessage({ kind: 'bye', from: this.clientId });
4 } catch {}
5 if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
6 });
7 }
- Envía una notificación de desconexión al worker antes de que se cierre la ventana.
1 request<T = unknown>(action: RequestAction): Promise<T> {
2 const id = randomId();
3 return new Promise<T>((resolve, reject) => {
4 this.pending.set(id, { resolve, reject });
5 this.port.postMessage({ kind: 'request', id, from: this.clientId, action });
6 });
7 }
- El método
request
envía la acción especificada al worker y recibe el resultado como unPromise
.
1 ping() {
2 return this.request<string>({ type: 'ping' });
3 }
4 echo(payload: string) {
5 return this.request<string>({ type: 'echo', payload });
6 }
7 getTime() {
8 return this.request<string>({ type: 'getTime' });
9 }
- Estos son métodos de utilidad para pruebas básicas de comunicación y obtener la hora actual.
1 set(key: string, value: unknown) {
2 return this.request<boolean>({ type: 'set', key, value });
3 }
4 get<T = unknown>(key: string) {
5 return this.request<T>({ type: 'get', key });
6 }
- Estos son métodos para almacenar y recuperar pares clave-valor.
1 broadcast(channel: string, payload: unknown) {
2 return this.request<boolean>({ type: 'broadcast', channel, payload });
3 }
- Este es un método que envía mensajes de difusión a otros clientes a través del worker.
1 lockAcquire(key: string, timeoutMs?: number) {
2 return this.request<{ owner: string }>({ type: 'lock.acquire', key, timeoutMs });
3 }
4 lockRelease(key: string) {
5 return this.request<boolean>({ type: 'lock.release', key });
6 }
7}
- Estos son métodos que adquieren y liberan bloqueos para lograr exclusión mutua sobre recursos compartidos.
- A lo largo de este archivo se implementa una API de cliente para una comunicación segura y asíncrona desde cada pestaña del navegador hacia el Shared Worker.
Ejemplo de uso
En demo.ts
, usamos la clase SharedWorkerClient
creada anteriormente y verificamos su comportamiento. Ejecuta secuencialmente una serie de funciones que incluyen pruebas de comunicación, lectura y escritura de datos, difusión y manejo de bloqueos.
1// demo.ts
2import { SharedWorkerClient } from './shared-worker-client.js';
3
4const client = new SharedWorkerClient(new URL('./shared-worker.js', import.meta.url), 'app-shared', (b) => {
5 console.log('[BROADCAST]', b.channel, JSON.stringify(b.payload));
6});
7
8async function demo() {
9 console.log('ping:', await client.ping());
10 console.log('echo:', await client.echo('hello'));
11 console.log('time:', await client.getTime());
12
13 // Counter update
14 await client.set('counter', (await client.get<number>('counter')) ?? 0);
15 const c1 = await client.get<number>('counter');
16 await client.set('counter', (c1 ?? 0) + 1);
17 console.log('counter:', await client.get<number>('counter'));
18
19 // Broadcast test
20 await client.broadcast('notify', { msg: 'Counter updated' });
21
22 // Lock test
23 const key = 'critical';
24 console.log(`[lock] Trying to acquire lock: "${key}"`);
25 const lockResult = await client.lockAcquire(key, 2000);
26 console.log(`[lock] Lock acquired for key "${key}":`, lockResult);
27
28 try {
29 console.log(`[lock] Simulating critical section for key "${key}"...`);
30 await new Promise((r) => setTimeout(r, 250));
31 console.log(`[lock] Critical section completed for key "${key}"`);
32 } finally {
33 console.log(`[lock] Releasing lock: "${key}"`);
34 const releaseResult = await client.lockRelease(key);
35 console.log(`[lock] Lock released for key "${key}":`, releaseResult);
36 }
37}
38
39demo().catch(console.error);
- Este código es una demostración que usa un
Shared Worker
para compartir y sincronizar datos y estado entre múltiples pestañas del navegador. Al usar comunicación basada en mensajes, puedes intercambiar mensajes asíncronos de forma segura con acoplamiento débil, lo que facilita gestionar la comunicación entre diferentes contextos. Además, mediante el uso de RPC, se abstrae la comunicación con el worker en un estilo intuitivo similar a llamadas de métodos, mejorando la mantenibilidad y la legibilidad.
Pruebas en HTML
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Shared Worker Demo</title>
7</head>
8<body>
9 <h1>SharedWorker Demo</h1>
10 <p>Open the browser console to see the output.</p>
11
12 <script type="module">
13 import './demo.js';
14 </script>
15</body>
16</html>
Consideraciones de diseño y operación
Al diseñar y operar, tener en cuenta los siguientes puntos le ayudará a crear un sistema más robusto y extensible.
- Puedes adoptar una unión discriminada (unión etiquetada) que permite ramificar según
kind
otype
. - Usa un ID de correlación para emparejar correctamente las solicitudes con las respuestas.
- Latidos y limpieza automática pueden prevenir bloqueos abandonados.
- Implementa versionado para acomodar con flexibilidad futuros cambios del protocolo.
- Definir códigos de error claros facilita el manejo del lado de la UI y la depuración.
Resumen
Un Shared Worker
es un mecanismo clave para compartir datos y estado entre múltiples pestañas del navegador.
La estructura presentada aquí proporciona comunicación RPC con seguridad de tipos, supervisión de actividad mediante latidos y un mecanismo de bloqueo, lo que la convierte en un diseño robusto que puede usarse tal cual en producción.
Sobre este mecanismo, también puedes implementar las siguientes aplicaciones.
- Serializar el acceso a IndexedDB
- Integración y uso compartido de conexiones WebSocket
- Construir una cola de trabajos entre varias pestañas
- Limitación de velocidad y entrega de notificaciones de progreso
Como puedes ver, aprovechar un Shared Worker
permite compartir datos y procesamiento de forma segura y eficiente entre múltiples pestañas.
Puedes seguir el artículo anterior utilizando Visual Studio Code en nuestro canal de YouTube. Por favor, también revisa nuestro canal de YouTube.