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 comeping
,get
ebroadcast
.
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 delloShared 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 alloShared 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 unoShared 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 comePromise
.
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
otype
. - 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.