`Shared Worker` en TypeScript

`Shared Worker` en TypeScript

Cet article explique Shared Worker en TypeScript.

Nous expliquerons en détail le fonctionnement des Shared Workers et leur utilisation pratique, avec des exemples de code TypeScript.

YouTube Video

Shared Worker en TypeScript

Shared Worker est un processus de worker unique partagé entre plusieurs onglets, fenêtres et iframes sur la même origine. En l'utilisant, vous pouvez gérer l'état et les ressources partagés entre plusieurs onglets du navigateur.

Par exemple, vous pouvez mettre en œuvre efficacement une connexion WebSocket partagée, un cache et un traitement de file d'attente synchronisés entre onglets, ainsi que de l'exclusion mutuelle.

Contrairement à un Dedicated Worker, un Shared Worker reçoit plusieurs MessagePort via l'événement onconnect et peut multiplexer la communication avec plusieurs clients.

Cas où vous devriez choisir un Shared Worker

Dans les cas suivants, l'utilisation d'un Shared Worker est appropriée.

  • Lorsque vous avez besoin d'un état partagé ou d'exclusion mutuelle entre onglets
  • Lorsque vous souhaitez partager une seule connexion WebSocket ou l'accès à IndexedDB
  • Lorsque vous devez notifier tous les onglets (diffusion)
  • Lorsque vous voulez centraliser des traitements lourds pour économiser les ressources

À l'inverse, dans les cas suivants, d'autres approches sont plus adaptées.

  • Lorsqu'il vous faut un contrôle du cache ou la prise en charge hors ligne, vous pouvez utiliser un Service Worker.
  • Pour des traitements lourds confinés à un seul onglet, vous pouvez utiliser un Dedicated Worker.

Étapes d’implémentation de Shared Worker

Ici, nous allons implémenter pas à pas ce qui suit avec TypeScript.

  • Protocole de messages sûr au niveau des types
  • Modèle requête/réponse (RPC) basé sur des Promises
  • Diffusion à tous les onglets
  • Heartbeat et nettoyage des clients

Configuration de l'environnement

Créez la configuration pour compiler chaque fichier source qui utilise 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}
  • Dans tsconfig-src.json, activez les définitions de types du DOM et des Web Workers afin que le code puisse être compilé en toute sécurité.

Définir le protocole de messages

La base de la communication est un contrat de messages typé. Le définir dès le départ rend la communication ultérieure sûre et facile à étendre.

 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 est une union discriminée (tagged union) qui représente les types de requêtes envoyées au worker et définit des opérations telles que ping, get et broadcast.
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessage définit la structure des messages de requête envoyés du client vers le worker.
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessage définit la structure des messages de réponse renvoyés du worker au client.
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessage définit la structure des messages de diffusion que le worker envoie aux autres clients.
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessage est un type qui représente tous les messages que le worker reçoit, tels que les requêtes, les signaux de vie et les notifications de déconnexion.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessage est un type qui représente les messages de réponse ou de diffusion que le worker envoie au client.
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomId est une fonction qui génère une chaîne alphanumérique aléatoire à utiliser pour les identifiants de messages et des usages similaires.

Implémenter le Shared Worker

Dans shared-worker.ts, enregistrez les onglets qui se connectent via l'événement onconnect et gérez les messages.

1// shared-worker.ts
2/// <reference lib="webworker" />
  • Cette directive indique à TypeScript de charger les définitions de types pour les Web Workers.
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • Importe les définitions de types utilisées pour la communication avec le worker.
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • Déclare explicitement que self est la portée globale du Shared Worker.
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Client est un type qui représente l'identifiant de chaque client, son port de communication et l'horodatage de son dernier signal de vie.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • Gère la liste des clients connectés, un magasin clé-valeur, l'état des verrous et les délais d'expiration.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • send est une fonction utilitaire qui envoie un message au port spécifié.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2  return { kind: 'response', id: req.id, ok, result, error };
3}
  • respond génère un message de réponse pour une requête.
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 envoie un message sur un canal spécifié à tous les clients.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequest traite les requêtes entrantes par type et renvoie les résultats au 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;
  • Dans ce code, selon le type de requête reçue, il gère l’envoi et la réception de messages, la récupération et la sauvegarde des données, ainsi que la diffusion.
 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      }
  • Ce code implémente le processus permettant à un client d’acquérir un verrou pour la clé spécifiée. Si le verrou n’est pas déjà détenu, il est acquis immédiatement ; si le même client le redemande, la requête est également considérée comme réussie. Si un autre client détient déjà le verrou, il réessaie toutes les 25 millisecondes jusqu’à la libération du verrou et, si le délai d’attente spécifié (par défaut 5 secondes) est dépassé, il répond par une erreur.
 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}
  • Ce code libère le verrou détenu par le client et renvoie une réponse d’erreur si le client n’a pas l’autorisation requise ou si l’action est inconnue.
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 analyse les messages reçus des clients et gère les requêtes et les signaux de vie.
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 supprime les clients déconnectés du registre et de l'état des verrous.
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);
  • Utilise setInterval pour vérifier périodiquement les signaux de vie de tous les clients et nettoyer les connexions ayant expiré.
 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 est appelé lorsqu'un nouvel onglet ou une nouvelle page se connecte au Shared Worker, ce qui enregistre le client et démarre la communication.

  • Dans l'ensemble de ce fichier, les mécanismes fondamentaux d'un Shared Worker qui permettent la gestion d'état partagée et la communication entre plusieurs onglets du navigateur sont implémentés.

Wrapper client (RPC)

Ensuite, créez un client RPC basé sur des Promises.

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • Importe les définitions de types et les fonctions utilitaires utilisées pour la communication avec le worker.
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • Nous définissons ici le type de la fonction de rappel exécutée à la réception d'un message de diffusion.
1export class SharedWorkerClient {
  • SharedWorkerClient est une classe cliente qui communique avec un Shared Worker, en envoyant des requêtes et en gérant les réponses.
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  }>();
  • Ces variables sont l'instance du worker, le port de communication avec le worker et une map qui suit les requêtes en attente de réponse.
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • Ces variables contiennent l'identifiant client, le minuteur d'envoi des signaux de vie et le gestionnaire de réception des diffusions.
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;
  • Dans le constructeur, il initialise la connexion au Shared Worker et met en place les écouteurs de messages et l'envoi des signaux de vie.
 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();
  • Ici, il reçoit les messages du worker et gère les réponses ou les diffusions.
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • Envoie périodiquement des signaux de vie pour maintenir la connexion active.
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  }
  • Envoie une notification de déconnexion au worker avant la fermeture de la fenêtre.
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  }
  • La méthode request envoie l'action spécifiée au worker et reçoit le résultat sous forme de 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  }
  • Ce sont des méthodes utilitaires pour des tests de communication de base et pour obtenir l'heure actuelle.
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  }
  • Ce sont des méthodes pour stocker et récupérer des paires clé-valeur.
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • C'est une méthode qui envoie des messages de diffusion aux autres clients via le 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}
  • Ce sont des méthodes qui acquièrent et relâchent des verrous afin d'obtenir l'exclusion mutuelle sur des ressources partagées.
  • Dans l'ensemble de ce fichier, une API cliente est implémentée pour une communication sûre et asynchrone de chaque onglet du navigateur vers le Shared Worker.

Exemple d'utilisation

Dans demo.ts, nous utilisons la classe SharedWorkerClient créée précédemment et en vérifions le comportement. Il exécute séquentiellement une série de fonctions, notamment des tests de communication, la lecture et l’écriture de données, la diffusion et la gestion des verrous.

 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);
  • Ce code est une démo qui utilise un Shared Worker pour partager et synchroniser les données et l'état entre plusieurs onglets du navigateur. En utilisant une communication basée sur des messages, vous pouvez échanger des messages asynchrones en toute sécurité avec un couplage faible, ce qui facilite la gestion de la communication entre différents contextes. De plus, en utilisant l'RPC, cela abstrait la communication avec le worker dans un style intuitif proche d'un appel de méthode, ce qui améliore la maintenabilité et la lisibilité.

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

Considérations de conception et opérationnelles

Lors de la conception et de l’exploitation, garder les points suivants à l’esprit vous aidera à construire un système plus robuste et extensible.

  • Vous pouvez adopter une union discriminée (tagged union) qui permet de brancher sur kind ou type.
  • Utilisez un identifiant de corrélation pour faire correspondre correctement les requêtes et les réponses.
  • Les signaux de vie et le nettoyage automatique peuvent empêcher les verrous abandonnés.
  • Mettez en place un versionnage pour s'adapter avec souplesse aux évolutions futures du protocole.
  • Définir des codes d’erreur clairs facilite la gestion côté interface utilisateur et le débogage.

Résumé

Un Shared Worker est un mécanisme central pour partager les données et l'état entre plusieurs onglets du navigateur.

La structure présentée ici fournit une communication RPC typée en toute sécurité, une surveillance de vivacité via des signaux de vie et un mécanisme de verrouillage, ce qui en fait une conception robuste utilisable en l'état en production.

Sur cette base, vous pouvez également implémenter les applications suivantes.

  • Sérialiser l'accès à IndexedDB
  • Intégration et mutualisation des connexions WebSocket
  • Mise en place d’une file d’attente de tâches entre plusieurs onglets
  • Limitation de débit et envoi de notifications de progression

Comme vous pouvez le voir, exploiter un Shared Worker permet de partager les données et les traitements de façon sûre et efficace entre plusieurs onglets.

Vous pouvez suivre l'article ci-dessus avec Visual Studio Code sur notre chaîne YouTube. Veuillez également consulter la chaîne YouTube.

YouTube Video