TypeScriptにおける`Shared Worker`

TypeScriptにおける`Shared Worker`

この記事ではTypeScriptにおけるShared Workerについて説明します。

Shared Workerの仕組みとその実践的な活用方法について、TypeScriptのコード例を交えて詳しく説明します。

YouTube Video

TypeScriptにおけるShared Worker

Shared Workerは、同一オリジン上の複数タブ・ウィンドウ・iframeで共有される単一のワーカープロセスです。これを使うことで、複数のブラウザタブ間で共通の状態やリソースを扱うことができます。

たとえば、共通のWebSocket接続タブ間で同期するキャッシュやキュー処理排他制御などを効率的に実現可能です。

Dedicated Workerとの違いは、Shared Workeronconnectイベントで複数のMessagePortを受け取り、複数クライアントとの通信を多重化できる点にあります。

Shared Workerを選ぶべきケース

以下のような場合、Shared Workerの利用が適しています。

  • タブ間で共有状態や排他制御が必要な場合
  • 単一のWebSocket接続やIndexedDBアクセスを共有したい場合
  • 全タブへの通知(ブロードキャスト)が必要な場合
  • 重い処理を集中管理してリソースを節約したい場合

逆に以下のケースでは別手段が適切です。

  • キャッシュ制御やオフライン対応が必要な場合には、Service Workerを利用できます。
  • 単一タブ内で完結する重い処理を行う場合には、Dedicated Workerを利用できます。

Shared Workerの実装ステップ

ここでは、TypeScriptを用いて以下を段階的に実装します。

  • 型安全なメッセージプロトコル
  • Promiseベースのリクエスト/レスポンス(RPC)
  • 全タブへのブロードキャスト
  • ハートビートとクライアントクリーンアップ

環境準備

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}
  • tsconfig-src.jsonでは、DOMやWeb Workerの型情報を有効にして安全にコンパイルできるように設定します。

メッセージプロトコルの定義

通信の基本は型付きのメッセージ契約です。これを定義しておくことで、後の通信が安全かつ拡張しやすくなります。

 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は、ワーカーに送るリクエストの種類を表す判別可能な共用型(タグ付きユニオン)で、pingget, broadcast などの操作を定義しています。
1export interface RequestMessage {
2  kind: 'request';
3  id: string;
4  from: string;
5  action: RequestAction;
6}
  • RequestMessageは、クライアントからワーカーに送られるリクエストメッセージの構造を定義しています。
1export interface ResponseMessage {
2  kind: 'response';
3  id: string;
4  ok: boolean;
5  result?: unknown;
6  error?: string;
7}
  • ResponseMessageは、ワーカーからクライアントに返される応答メッセージの構造を定義しています。
1export interface BroadcastMessage {
2  kind: 'broadcast';
3  channel: string;
4  payload: unknown;
5  from: string;
6}
  • BroadcastMessageは、ワーカーが他のクライアントへ送信するブロードキャストメッセージの構造を定義しています。
1export type WorkerInMessage =
2  | RequestMessage
3  | { kind: 'heartbeat'; from: string }
4  | { kind: 'bye'; from: string };
  • WorkerInMessageは、リクエストやハートビート、切断通知などワーカーが受け取るすべてのメッセージを表す型です。
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
  • WorkerOutMessageは、ワーカーがクライアントに送信する応答またはブロードキャストメッセージを表す型です。
1export const randomId = () => Math.random().toString(36).slice(2);
  • randomIdは、ランダムな英数字文字列を生成してメッセージIDなどに利用する関数です。

Shared Workerの実装

shared-worker.tsでは、onconnectイベントで接続してきたタブを登録し、メッセージをハンドリングします。

1// shared-worker.ts
2/// <reference lib="webworker" />
  • このディレクティブは、TypeScriptにWeb Worker用の型定義を読み込ませるためのものです。
1import {
2  WorkerInMessage,
3  WorkerOutMessage,
4  RequestMessage,
5  ResponseMessage,
6} from './worker-protocol.js';
  • ワーカー間通信で使用する型定義をインポートしています。
1export default {};
2declare const self: SharedWorkerGlobalScope;
  • selfShared Worker のグローバルスコープであることを明示的に宣言しています。
1type Client = {
2  id: string;
3  port: MessagePort;
4  lastBeat: number;
5};
  • Clientは、クライアントごとの識別情報と通信ポート、最終ハートビート時刻を表す型です。
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
  • 接続中のクライアント一覧、キー・バリューストア、ロック状態、およびタイムアウト時間を管理しています。
1function send(port: MessagePort, msg: WorkerOutMessage) {
2  port.postMessage(msg);
3}
  • sendは、指定されたポートにメッセージを送信するユーティリティ関数です。
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2  return { kind: 'response', id: req.id, ok, result, error };
3}
  • respondは、リクエストに対するレスポンスメッセージを生成します。
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は、全クライアントに指定チャンネルのメッセージを一斉送信します。
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
  • handleRequestは、受け取ったリクエストを種類ごとに処理し、結果をクライアントへ返します。
 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;
  • このコードでは、受け取ったリクエストの種類に応じてメッセージの送受信やデータの取得、保存、ブロードキャストを処理しています。
 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      }
  • このコードは、指定されたキーに対してクライアントがロックを取得する処理を実装しています。ロックが未取得の場合は即座に取得し、同一クライアントが再度要求した場合も成功として応答します。他のクライアントがすでにロックを保持している場合は、ロックが解放されるまで25ミリ秒ごとに再試行し、指定されたタイムアウト(デフォルト5秒)を超えるとエラーとして応答します。
 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}
  • このコードは、クライアントが保持するロックを解放し、権限がない場合や不明なアクションにはエラーレスポンスを返す処理を行います。
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は、クライアントから届いたメッセージを解析し、リクエストやハートビートを処理します。
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は、切断されたクライアントを登録リストやロック状態から削除します。
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);
  • setIntervalを用いて、定期的に全クライアントのハートビートを確認し、タイムアウトした接続をクリーンアップします。
 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は、新しいタブやページがShared Workerに接続されたときに呼び出され、クライアントを登録して通信を開始します。

  • このファイル全体で、複数のブラウザタブ間で共有される状態管理と通信を実現するShared Workerの基本的な仕組みが実装されています。

クライアントラッパー(RPC)

次に、PromiseベースのRPCクライアントを作成します。

1// shared-worker-client.ts
2import {
3  RequestAction,
4  RequestMessage,
5  WorkerOutMessage,
6  randomId
7} from './worker-protocol.js';
  • ワーカー通信で使用する型定義とユーティリティ関数をインポートしています。
1export type BroadcastHandler = (msg: {
2  channel: string;
3  payload: unknown;
4  from: string
5}) => void;
  • ここでは、ブロードキャストメッセージを受信したときに実行されるコールバック関数の型を定義しています。
1export class SharedWorkerClient {
  • SharedWorkerClientは、Shared Worker と通信し、リクエスト送信や応答処理を行うクライアントクラスです。
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  }>();
  • これらの変数は、ワーカーインスタンス、ワーカーとの通信ポート、応答待ちのリクエストを管理するマップです。
1  private clientId = randomId();
2  private heartbeatTimer?: number;
3  private onBroadcast?: BroadcastHandler;
  • これらの変数は、クライアント識別子、ハートビート送信用タイマー、およびブロードキャスト受信ハンドラを保持しています。
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;
  • コンストラクタでは、Shared Worker への接続を初期化し、メッセージリスナーやハートビート送信を設定します。
 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();
  • ここでは、ワーカーからのメッセージを受信し、応答またはブロードキャストを処理します。
1    this.heartbeatTimer = window.setInterval(() => {
2      this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3    }, 10_000);
  • 定期的にハートビートメッセージを送信して接続状態を維持します。
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  }
  • ウィンドウが閉じられる前に、ワーカーに切断通知を送信します。
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  }
  • requestメソッドは、指定したアクションをワーカーに送信し、結果を 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  }
  • これらは、基本的な通信テストや時刻取得のためのユーティリティメソッドです。
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  }
  • これらは、キーと値の保存および取得を行うためのメソッドです。
1  broadcast(channel: string, payload: unknown) {
2    return this.request<boolean>({ type: 'broadcast', channel, payload });
3  }
  • これは、ワーカーを通じて他のクライアントにブロードキャストメッセージを送信するメソッドです。
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}
  • これらは、ロックの取得と解放を行い、共有リソースへの排他制御を実現するためのメソッドです。
  • このファイル全体で、ブラウザの各タブから Shared Worker へ安全かつ非同期に通信するためのクライアントAPI が実装されています。

実際の利用例

demo.tsでは、先ほど作成したSharedWorkerClientクラスを実際に使用し、その動作を確認します。通信テスト、データの読み書き、ブロードキャスト送信、ロック処理など、一連の機能を順に実行します。

 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);
  • このコードは、Shared Workerを利用して複数のブラウザタブ間でデータや状態を共有・同期するデモです。メッセージ型通信を用いることで、疎結合で安全に非同期メッセージをやり取りでき、異なるコンテキスト間の通信管理が容易になります。さらにRPCを利用することで、メソッド呼び出しのような直感的な書き方でWorkerとの通信を抽象化し、保守性と可読性を高めています。

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>

設計・運用のポイント

設計や運用を行う際には、次のような点を意識するとより堅牢で拡張性の高い仕組みを構築できます。

  • kindtypeによる分岐が可能な**判別可能な共用型(タグ付きユニオン)**を採用できます。
  • **相関ID(correlation ID)**でリクエストとレスポンスを正確に対応付けられます。
  • ハートビートと自動クリーンアップでロックの取り残しを防止できます。
  • バージョン管理を行うと、将来的なプロトコル変更に柔軟に対応できます。
  • 明確なエラーコードを定義すると、UI側での制御やデバッグが容易になります。

まとめ

Shared Workerは、複数のブラウザタブ間でデータや状態を共有するための中核的な仕組みです。

ここで紹介した構成は、型安全なRPC通信ハートビートによる死活監視、そしてロック機構を備えており、実務環境でもそのまま利用できる堅牢な設計です。

また、この仕組みを基盤として、次のような応用も実現できます。

  • IndexedDBアクセスの直列化
  • WebSocket接続の統合・共有
  • 複数タブ間でのジョブキューの構築
  • スロットリング処理や進捗通知の配信

このように、Shared Workerを活用すると、複数タブ間で安全かつ効率的にデータや処理を共有することが可能です。

YouTubeチャンネルでは、Visual Studio Codeを用いて上記の記事を見ながら確認できます。 ぜひYouTubeチャンネルもご覧ください。

YouTube Video