Il `Shared Worker` in TypeScript

Il `Shared Worker` in TypeScript

Questo articolo spiega il Shared Worker in TypeScript.

Spiegheremo in dettaglio come funzionano i Shared Worker e come usarli in pratica, con esempi di codice TypeScript.

YouTube Video

Il Shared Worker in TypeScript

Un Shared Worker è un singolo processo worker condiviso tra più schede, finestre e iframe della stessa origine. Usandolo, puoi gestire stato e risorse condivisi tra più schede del browser.

Ad esempio, puoi implementare in modo efficiente una connessione WebSocket condivisa, cache e code di elaborazione sincronizzate tra schede e mutua esclusione.

A differenza di un Dedicated Worker, un Shared Worker riceve più MessagePort tramite l'evento onconnect e può effettuare il multiplexing della comunicazione con più client.

Casi in cui dovresti scegliere un Shared Worker

Nei seguenti casi, usare un Shared Worker è appropriato.

  • Quando ti serve stato condiviso o mutua esclusione tra schede
  • Quando vuoi condividere un'unica connessione WebSocket o l'accesso a IndexedDB
  • Quando devi notificare tutte le schede (broadcast)
  • Quando vuoi centralizzare elaborazioni pesanti per risparmiare risorse

Al contrario, nei seguenti casi sono più adatti altri approcci.

  • Quando hai bisogno di controllo della cache o del supporto offline, puoi usare un Service Worker.
  • Per elaborazioni pesanti confinate a una singola scheda, puoi usare un Dedicated Worker.

Fasi di implementazione per Shared Worker

Qui implementeremo passo dopo passo quanto segue usando TypeScript.

  • Protocollo di messaggistica type-safe
  • Richiesta/risposta basata su Promise (RPC)
  • Broadcast a tutte le schede
  • Heartbeat e pulizia dei client

Configurazione dell'ambiente

Crea la configurazione per compilare ogni file sorgente che usa 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}
  • In tsconfig-src.json, abilita le definizioni di tipo per DOM e Web Worker in modo che il codice possa essere compilato in sicurezza.

Definizione del protocollo di messaggistica

La base della comunicazione è un contratto di messaggistica tipizzato. Definirlo in anticipo rende la comunicazione successiva sicura e facile da estendere.

 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 è un'unione discriminata (tagged union) che rappresenta i tipi di richieste inviate al worker e definisce operazioni come ping, get e broadcast.
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessage definisce la struttura dei messaggi di richiesta inviati dal client al worker.
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessage definisce la struttura dei messaggi di risposta restituiti dal worker al client.
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessage definisce la struttura dei messaggi di broadcast che il worker invia agli altri client.
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessage è un tipo che rappresenta tutti i messaggi che il worker riceve, come richieste, heartbeat e notifiche di disconnessione.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessage è un tipo che rappresenta i messaggi di risposta o di broadcast che il worker invia al client.
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomId è una funzione che genera una stringa alfanumerica casuale da usare per gli ID dei messaggi e simili.

Implementazione dello Shared Worker

In shared-worker.ts, registra le schede che si collegano tramite l'evento onconnect e gestisci i messaggi.

1// shared-worker.ts
2/// <reference lib="webworker" />
  • Questa direttiva indica a TypeScript di caricare le definizioni di tipo per i Web Worker.
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • Importa le definizioni di tipo usate per la comunicazione con il worker.
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • Dichiara esplicitamente che self è l'ambito globale dello Shared Worker.
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Client è un tipo che rappresenta l'identificatore di ciascun client, la porta di comunicazione e il timestamp dell'ultimo heartbeat.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • Gestisce l'elenco dei client connessi, un archivio key-value, lo stato dei lock e le durate dei timeout.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • send è una funzione di utilità che invia un messaggio alla porta specificata.
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 messaggio di risposta per una richiesta.
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 invia un messaggio su un canale specificato a tutti i client.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequest elabora le richieste in arrivo per tipo e restituisce i risultati al client.
 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;
  • In questo codice, a seconda del tipo di richiesta ricevuta, gestisce l’invio e la ricezione dei messaggi, il recupero e il salvataggio dei dati e il broadcasting.
 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      }
  • Questo codice implementa il processo per consentire a un client di acquisire un lock per la chiave specificata. Se il lock non è già detenuto, viene acquisito immediatamente; se lo stesso client lo richiede di nuovo, la richiesta viene considerata riuscita. Se un altro client detiene già il lock, si riprova ogni 25 millisecondi finché il lock non viene rilasciato e, se viene superato il timeout specificato (predefinito di 5 secondi), viene restituito un errore.
 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}
  • Questo codice rilascia il lock detenuto dal client e restituisce una risposta di errore se al client mancano le autorizzazioni o l’azione è sconosciuta.
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 analizza i messaggi ricevuti dai client e gestisce richieste e heartbeat.
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 rimuove i client disconnessi dal registro e dallo stato dei lock.
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 per controllare periodicamente gli heartbeat di tutti i client e ripulire le connessioni scadute per timeout.
 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 viene chiamato quando una nuova scheda o pagina si connette allo Shared Worker, registrando il client e avviando la comunicazione.

  • In questo file sono implementati i meccanismi fondamentali di uno Shared Worker che abilitano la gestione dello stato condiviso e la comunicazione tra più schede del browser.

Wrapper client (RPC)

Poi, crea un client RPC basato su Promise.

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • Importa le definizioni di tipo e le funzioni di utilità usate per la comunicazione con il worker.
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • Qui definiamo il tipo della funzione di callback che viene eseguita quando si riceve un messaggio di broadcast.
1export class SharedWorkerClient {
  • SharedWorkerClient è una classe client che comunica con uno Shared Worker, inviando richieste e gestendo risposte.
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  }>();
  • Queste variabili sono l'istanza del worker, la porta di comunicazione con il worker e una mappa che traccia le richieste in attesa di risposta.
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • Queste variabili contengono l'identificatore del client, il timer per l'invio degli heartbeat e il gestore della ricezione dei broadcast.
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;
  • Nel costruttore inizializza la connessione allo Shared Worker e configura i listener dei messaggi e l'invio degli heartbeat.
 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();
  • Qui riceve i messaggi dal worker e gestisce le risposte o i broadcast.
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • Invia periodicamente messaggi di heartbeat per mantenere viva la connessione.
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  }
  • Invia una notifica di disconnessione al worker prima che la finestra si chiuda.
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  }
  • Il metodo request invia l'azione specificata al worker e riceve il risultato come Promise.
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  }
  • Questi sono metodi di utilità per test di comunicazione di base e per ottenere l'ora corrente.
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  }
  • Questi sono metodi per memorizzare e recuperare coppie chiave-valore.
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • Questo è un metodo che invia messaggi di broadcast ad altri client tramite il 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}
  • Questi sono metodi che acquisiscono e rilasciano lock per ottenere l'esclusione mutua sulle risorse condivise.
  • In questo file è implementata un'API client per una comunicazione sicura e asincrona da ogni scheda del browser allo Shared Worker.

Esempio d'uso

In demo.ts utilizziamo la classe SharedWorkerClient creata in precedenza e ne verifichiamo il comportamento. Esegue in sequenza una serie di funzioni, tra cui test di comunicazione, lettura e scrittura dei dati, broadcasting e gestione dei lock.

 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);
  • Questo codice è una demo che usa uno Shared Worker per condividere e sincronizzare dati e stato tra più schede del browser. Usando una comunicazione basata su messaggi, è possibile scambiare messaggi asincroni in modo sicuro con un accoppiamento debole, rendendo più semplice gestire la comunicazione tra contesti diversi. Inoltre, usando l'RPC, astrae la comunicazione con il worker in uno stile intuitivo simile alla chiamata di metodi, migliorando manutenibilità e leggibilità.

Test in 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>

Considerazioni di progettazione e operative

In fase di progettazione e durante l’operatività, tenere a mente i seguenti punti aiuterà a costruire un sistema più robusto ed estensibile.

  • Puoi adottare un'unione discriminata (tagged union) che consente di ramificare in base a kind o type.
  • Usa un correlation ID per abbinare correttamente le richieste alle risposte.
  • Heartbeat e pulizia automatica possono prevenire lock abbandonati.
  • Implementa il versioning per gestire in modo flessibile futuri cambiamenti del protocollo.
  • Definire codici di errore chiari rende più semplice la gestione lato UI e il debug.

Riepilogo

Uno Shared Worker è un meccanismo fondamentale per condividere dati e stato tra più schede del browser.

La struttura introdotta qui fornisce comunicazione RPC type-safe, monitoraggio della vitalità tramite heartbeat e un meccanismo di lock, risultando in un design robusto utilizzabile così com'è in produzione.

Su questo meccanismo puoi anche implementare le seguenti applicazioni.

  • Serializzare l'accesso a IndexedDB
  • Integrazione e condivisione delle connessioni WebSocket
  • Creare una coda di job tra più schede
  • Limitazione della frequenza e invio di notifiche di avanzamento

Come si vede, sfruttare uno Shared Worker rende possibile condividere dati ed elaborazioni in modo sicuro ed efficiente tra più schede.

Puoi seguire l'articolo sopra utilizzando Visual Studio Code sul nostro canale YouTube. Controlla anche il nostro canale YouTube.

YouTube Video