FE_Dev / lib /service-worker.ts
GitHub Actions
Deploy from GitHub Actions [dev] - 2025-10-31 07:28:50
68f7925
'use client';
/**
* ServiceWorker管理モジュール
* ベストプラクティスに基づいた初期化と状態管理を提供
*/
export interface ServiceWorkerState {
isSupported: boolean;
isRegistered: boolean;
isReady: boolean;
registration: ServiceWorkerRegistration | null;
hasUpdate: boolean;
}
export interface ServiceWorkerConfig {
swUrl: string;
scope?: string;
enableDevMode?: boolean;
updateCheckInterval?: number;
}
type ServiceWorkerEventHandler = (state: ServiceWorkerState) => void;
class ServiceWorkerManager {
private config: ServiceWorkerConfig;
private state: ServiceWorkerState;
private eventHandlers: Set<ServiceWorkerEventHandler> = new Set();
private updateCheckTimer: NodeJS.Timeout | null = null;
constructor(config: Partial<ServiceWorkerConfig>) {
this.config = Object.assign({
swUrl: '/sw.js',
scope: '/',
enableDevMode: process.env.NODE_ENV === 'development',
updateCheckInterval: 30 * 60 * 1000, // 30分
}, config);
this.state = {
isSupported: false,
isRegistered: false,
isReady: false,
registration: null,
hasUpdate: false,
};
}
/**
* ServiceWorkerの初期化
* 遅延初期化でパフォーマンスを最適化
*/
async initialize(): Promise<ServiceWorkerState> {
// サポート確認
if (!this.checkSupport()) {
this.log('warn', 'ServiceWorker is not supported in this browser');
return this.state;
}
try {
// 既存の登録をチェック
const existingRegistration = await navigator.serviceWorker.getRegistration(this.config.scope);
if (existingRegistration) {
this.log('info', 'Existing ServiceWorker registration found');
await this.handleExistingRegistration(existingRegistration);
} else {
this.log('info', 'No existing registration, registering new ServiceWorker');
await this.register();
}
// 定期的な更新チェックを開始
this.startUpdateCheck();
return this.state;
} catch (error) {
this.log('error', 'ServiceWorker initialization failed:', error);
throw error;
}
}
/**
* ServiceWorkerの登録
*/
private async register(): Promise<ServiceWorkerRegistration> {
try {
const registration = await navigator.serviceWorker.register(this.config.swUrl, {
scope: this.config.scope,
});
this.log('info', 'ServiceWorker registered successfully:', registration.scope);
this.state.registration = registration;
this.state.isRegistered = true;
await this.setupRegistrationEventHandlers(registration);
await this.checkReadyState(registration);
this.notifyStateChange();
return registration;
} catch (error) {
this.log('error', 'ServiceWorker registration failed:', error);
throw new Error(`ServiceWorker registration failed: ${error}`);
}
}
/**
* 既存の登録を処理
*/
private async handleExistingRegistration(registration: ServiceWorkerRegistration): Promise<void> {
this.state.registration = registration;
this.state.isRegistered = true;
await this.setupRegistrationEventHandlers(registration);
await this.checkReadyState(registration);
await this.checkForUpdates(registration);
this.notifyStateChange();
}
/**
* 登録イベントハンドラーの設定
*/
private async setupRegistrationEventHandlers(registration: ServiceWorkerRegistration): Promise<void> {
// 更新があった場合
registration.addEventListener('updatefound', () => {
this.log('info', 'ServiceWorker update found');
const newWorker = registration.installing;
if (newWorker) {
this.state.hasUpdate = true;
this.notifyStateChange();
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
this.log('info', 'New ServiceWorker installed, update available');
this.state.hasUpdate = true;
this.notifyStateChange();
}
});
}
});
// ServiceWorkerがアクティブになった場合
navigator.serviceWorker.addEventListener('controllerchange', () => {
this.log('info', 'ServiceWorker controller changed');
this.checkReadyState(registration);
this.notifyStateChange();
});
}
/**
* 準備状態の確認
*/
private async checkReadyState(registration: ServiceWorkerRegistration): Promise<void> {
const isReady = !!registration.active || !!navigator.serviceWorker.controller;
this.state.isReady = isReady;
if (isReady) {
this.log('info', 'ServiceWorker is ready');
}
}
/**
* 更新の確認
*/
private async checkForUpdates(registration: ServiceWorkerRegistration): Promise<void> {
try {
await registration.update();
this.log('info', 'ServiceWorker update check completed');
} catch (error) {
this.log('warn', 'ServiceWorker update check failed:', error);
}
}
/**
* 定期的な更新チェックを開始
*/
private startUpdateCheck(): void {
if (!this.config.updateCheckInterval || this.updateCheckTimer) {
return;
}
this.updateCheckTimer = setInterval(async () => {
if (this.state.registration) {
await this.checkForUpdates(this.state.registration);
}
}, this.config.updateCheckInterval);
this.log('info', `Update check scheduled every ${this.config.updateCheckInterval}ms`);
}
/**
* 更新チェックを停止
*/
private stopUpdateCheck(): void {
if (this.updateCheckTimer) {
clearInterval(this.updateCheckTimer);
this.updateCheckTimer = null;
}
}
/**
* ServiceWorkerの更新を適用
*/
async applyUpdate(): Promise<void> {
if (!this.state.hasUpdate || !this.state.registration) {
throw new Error('No update available');
}
const waitingWorker = this.state.registration.waiting;
if (waitingWorker) {
this.log('info', 'Applying ServiceWorker update');
// 新しいServiceWorkerにメッセージを送信
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
// ページリロードを促す
return new Promise((resolve) => {
navigator.serviceWorker.addEventListener(
'controllerchange',
() => {
this.log('info', 'ServiceWorker update applied, reloading page');
window.location.reload();
resolve();
},
{ once: true },
);
});
}
}
/**
* ServiceWorkerの登録解除
*/
async unregister(): Promise<boolean> {
if (!this.state.registration) {
return false;
}
try {
const unregistered = await this.state.registration.unregister();
if (unregistered) {
this.log('info', 'ServiceWorker unregistered successfully');
this.state.isRegistered = false;
this.state.isReady = false;
this.state.registration = null;
this.state.hasUpdate = false;
this.stopUpdateCheck();
this.notifyStateChange();
}
return unregistered;
} catch (error) {
this.log('error', 'ServiceWorker unregistration failed:', error);
throw error;
}
}
/**
* ブラウザサポートの確認
*/
private checkSupport(): boolean {
const isSupported = 'serviceWorker' in navigator;
this.state.isSupported = isSupported;
return isSupported;
}
/**
* 状態変更の通知
*/
private notifyStateChange(): void {
this.eventHandlers.forEach((handler) => {
try {
handler(this.state);
} catch (error) {
this.log('error', 'Error in state change handler:', error);
}
});
}
/**
* イベントハンドラーの追加
*/
onStateChange(handler: ServiceWorkerEventHandler): () => void {
this.eventHandlers.add(handler);
// 現在の状態を即座に通知
handler(this.state);
// アンサブスクライブ関数を返す
return () => {
this.eventHandlers.delete(handler);
};
}
/**
* 現在の状態を取得
*/
getState(): ServiceWorkerState {
return { ...this.state };
}
/**
* ログ出力(開発モード対応)
*/
private log(level: 'info' | 'warn' | 'error', message: string, ...args: unknown[]): void {
if (!this.config.enableDevMode && level === 'info') {
return;
}
const prefix = '[ServiceWorkerManager]';
switch (level) {
case 'info':
console.log(prefix, message, ...args);
break;
case 'warn':
console.warn(prefix, message, ...args);
break;
case 'error':
console.error(prefix, message, ...args);
break;
}
}
/**
* リソースのクリーンアップ
*/
destroy(): void {
this.stopUpdateCheck();
this.eventHandlers.clear();
}
}
// シングルトンインスタンス
let serviceWorkerManager: ServiceWorkerManager | null = null;
/**
* ServiceWorkerマネージャーのインスタンスを取得
*/
export function getServiceWorkerManager(config?: Partial<ServiceWorkerConfig>): ServiceWorkerManager {
if (!serviceWorkerManager) {
serviceWorkerManager = new ServiceWorkerManager({ swUrl: '/sw.js', ...config });
}
return serviceWorkerManager;
}
/**
* ServiceWorkerの初期化(遅延実行)
*/
export function initializeServiceWorker(config?: Partial<ServiceWorkerConfig>): Promise<ServiceWorkerState> {
return new Promise((resolve) => {
// window.loadイベント後に実行してパフォーマンスを最適化
const initialize = () => {
const manager = getServiceWorkerManager(config);
resolve(manager.initialize());
};
if (document.readyState === 'complete') {
// 既にロード完了している場合は即座に実行
setTimeout(initialize, 0);
} else {
// ロード完了を待つ
window.addEventListener('load', initialize, { once: true });
}
});
}
/**
* ServiceWorkerが利用可能かチェック
*/
export function isServiceWorkerSupported(): boolean {
return 'serviceWorker' in navigator;
}