TypeScriptにおける`Shared Worker`
この記事ではTypeScriptにおけるShared Worker
について説明します。
Shared Workerの仕組みとその実践的な活用方法について、TypeScriptのコード例を交えて詳しく説明します。
YouTube Video
TypeScriptにおけるShared Worker
Shared Worker
は、同一オリジン上の複数タブ・ウィンドウ・iframeで共有される単一のワーカープロセスです。これを使うことで、複数のブラウザタブ間で共通の状態やリソースを扱うことができます。
たとえば、共通のWebSocket接続、タブ間で同期するキャッシュやキュー処理、排他制御などを効率的に実現可能です。
Dedicated Worker
との違いは、Shared Worker
がonconnect
イベントで複数の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
は、ワーカーに送るリクエストの種類を表す判別可能な共用型(タグ付きユニオン)で、ping
やget
,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;
self
がShared 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>
設計・運用のポイント
設計や運用を行う際には、次のような点を意識するとより堅牢で拡張性の高い仕組みを構築できます。
kind
やtype
による分岐が可能な**判別可能な共用型(タグ付きユニオン)**を採用できます。- **相関ID(correlation ID)**でリクエストとレスポンスを正確に対応付けられます。
- ハートビートと自動クリーンアップでロックの取り残しを防止できます。
- バージョン管理を行うと、将来的なプロトコル変更に柔軟に対応できます。
- 明確なエラーコードを定義すると、UI側での制御やデバッグが容易になります。
まとめ
Shared Worker
は、複数のブラウザタブ間でデータや状態を共有するための中核的な仕組みです。
ここで紹介した構成は、型安全なRPC通信、ハートビートによる死活監視、そしてロック機構を備えており、実務環境でもそのまま利用できる堅牢な設計です。
また、この仕組みを基盤として、次のような応用も実現できます。
- IndexedDBアクセスの直列化
- WebSocket接続の統合・共有
- 複数タブ間でのジョブキューの構築
- スロットリング処理や進捗通知の配信
このように、Shared Worker
を活用すると、複数タブ間で安全かつ効率的にデータや処理を共有することが可能です。
YouTubeチャンネルでは、Visual Studio Codeを用いて上記の記事を見ながら確認できます。 ぜひYouTubeチャンネルもご覧ください。