Back to skills
SkillHub ClubShip Full StackFull StackFrontend

react-pwa

A skill for building Progressive Web Apps (PWAs) using React and Vite. It guides users through setup, manifest configuration, service worker generation, offline support, and installability features.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
35
Hot score
90
Updated
March 20, 2026
Overall rating
C3.3
Composite score
3.3
Best-practice grade
N/A

Install command

npx @skill-hub/cli install jwynia-agent-skills-react-pwa
PWAReactViteofflineservice-worker

Repository

jwynia/agent-skills

Skill path: skills/frontend/pwa/react-pwa

A skill for building Progressive Web Apps (PWAs) using React and Vite. It guides users through setup, manifest configuration, service worker generation, offline support, and installability features.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Frontend.

Target audience: everyone.

License: MIT.

Original source

Catalog source: SkillHub Club.

Repository owner: jwynia.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install react-pwa into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/jwynia/agent-skills before adding react-pwa to shared team environments
  • Use react-pwa for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: react-pwa
description: Build Progressive Web Apps with React and Vite. This skill should be used when the user asks to "create a PWA", "add offline support", "make app installable", "generate service worker", "configure workbox", "add push notifications", or mentions PWA, progressive web app, or offline-first development. Keywords: PWA, progressive web app, service worker, manifest, offline, installable, workbox, vite-plugin-pwa, React.
license: MIT
compatibility: Requires Deno for scripts. Works with React 18+ and Vite 5+.
metadata:
  author: agent-skills
  version: "1.0"
---

# React PWA

Build Progressive Web Apps with React and Vite using vite-plugin-pwa. This skill covers the complete PWA lifecycle: manifest configuration, service worker strategies, offline support, installability, and push notifications.

## When to Use This Skill

**Use when**:
- Creating a new PWA from scratch with React + Vite
- Converting an existing React app to a PWA
- Adding offline capabilities to a web application
- Implementing install prompts and app-like experiences
- Setting up push notifications
- Optimizing caching strategies for performance

**Don't use when**:
- Building server-rendered apps without client-side caching needs
- Working with Next.js (use next-pwa instead)
- Simple static sites without offline requirements

## Prerequisites

- **Node.js** 18+ and npm/pnpm/yarn
- **Vite** 5+ with React template
- **Deno** runtime (for skill scripts)
- **Source icon**: 512x512 PNG or SVG for icon generation

## Quick Start

### 1. Install Dependencies

```bash
npm install -D vite-plugin-pwa workbox-window
```

### 2. Configure Vite

```typescript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
      manifest: {
        name: 'My React PWA',
        short_name: 'ReactPWA',
        description: 'A Progressive Web App built with React',
        theme_color: '#ffffff',
        background_color: '#ffffff',
        display: 'standalone',
        icons: [
          { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
        ]
      }
    })
  ]
})
```

### 3. Generate Icons

```bash
deno run --allow-read --allow-write scripts/generate-icons.ts --input logo.png --output public/
```

### 4. Add Update Prompt (Optional)

```tsx
// src/components/PWAUpdatePrompt.tsx
import { useRegisterSW } from 'virtual:pwa-register/react'

export function PWAUpdatePrompt() {
  const {
    needRefresh: [needRefresh, setNeedRefresh],
    updateServiceWorker
  } = useRegisterSW()

  if (!needRefresh) return null

  return (
    <div className="pwa-toast">
      <span>New content available!</span>
      <button onClick={() => updateServiceWorker(true)}>Reload</button>
      <button onClick={() => setNeedRefresh(false)}>Close</button>
    </div>
  )
}
```

---

## Instructions

### Phase 1: Project Setup

#### 1a. Create New Vite React Project

```bash
npm create vite@latest my-pwa -- --template react-ts
cd my-pwa
npm install
npm install -D vite-plugin-pwa workbox-window
```

#### 1b. Add to Existing Project

```bash
npm install -D vite-plugin-pwa workbox-window
```

Add TypeScript types for virtual modules:

```typescript
// src/vite-env.d.ts (add to existing file)
/// <reference types="vite-plugin-pwa/client" />
```

---

### Phase 2: Manifest Configuration

The web app manifest defines how the PWA appears when installed.

#### Required Fields

```json
{
  "name": "Full Application Name",
  "short_name": "ShortName",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "icons": []
}
```

#### Display Modes

| Mode | Description | Use Case |
|------|-------------|----------|
| `fullscreen` | No browser UI, fills entire screen | Games, immersive experiences |
| `standalone` | App-like, with system UI only | Most PWAs |
| `minimal-ui` | Minimal browser controls | Apps needing navigation |
| `browser` | Standard browser tab | Fallback only |

#### Generate Manifest

```bash
deno run --allow-read --allow-write scripts/generate-manifest.ts \
  --name "My App" \
  --short-name "App" \
  --theme "#3b82f6" \
  --output public/manifest.webmanifest
```

---

### Phase 3: Service Worker Strategies

vite-plugin-pwa uses Workbox under the hood. Choose a strategy based on your needs.

#### Strategy Options

| Strategy | Behavior | Best For |
|----------|----------|----------|
| `generateSW` | Auto-generates SW from config | Most projects |
| `injectManifest` | Injects precache into custom SW | Custom caching logic |

#### Caching Strategies

**CacheFirst** - Serve from cache, fall back to network:
```typescript
runtimeCaching: [
  {
    urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
    handler: 'CacheFirst',
    options: {
      cacheName: 'google-fonts-cache',
      expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 }
    }
  }
]
```

**NetworkFirst** - Try network, fall back to cache:
```typescript
{
  urlPattern: /^https:\/\/api\.example\.com\/.*/i,
  handler: 'NetworkFirst',
  options: {
    cacheName: 'api-cache',
    expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 }
  }
}
```

**StaleWhileRevalidate** - Serve cache immediately, update in background:
```typescript
{
  urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
  handler: 'StaleWhileRevalidate',
  options: {
    cacheName: 'images-cache',
    expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 }
  }
}
```

#### Complete Vite Config with Caching

```typescript
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\..*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 }
            }
          },
          {
            urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
            handler: 'CacheFirst',
            options: {
              cacheName: 'images',
              expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 }
            }
          }
        ]
      },
      manifest: {
        name: 'My React PWA',
        short_name: 'ReactPWA',
        theme_color: '#3b82f6',
        background_color: '#ffffff',
        display: 'standalone',
        icons: [
          { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
        ]
      }
    })
  ]
})
```

---

### Phase 4: Offline Support

#### 4a. Offline Fallback Page

Create an offline page that displays when network and cache both fail:

```typescript
// vite.config.ts - add to VitePWA options
workbox: {
  navigateFallback: '/offline.html',
  navigateFallbackDenylist: [/^\/api/]
}
```

Create `public/offline.html`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Offline</title>
  <style>
    body { font-family: system-ui; display: flex; align-items: center;
           justify-content: center; min-height: 100vh; margin: 0; }
    .offline { text-align: center; }
  </style>
</head>
<body>
  <div class="offline">
    <h1>You're offline</h1>
    <p>Please check your internet connection and try again.</p>
    <button onclick="window.location.reload()">Retry</button>
  </div>
</body>
</html>
```

#### 4b. Offline Detection Hook

```tsx
// src/hooks/useOnlineStatus.ts
import { useSyncExternalStore } from 'react'

function subscribe(callback: () => void) {
  window.addEventListener('online', callback)
  window.addEventListener('offline', callback)
  return () => {
    window.removeEventListener('online', callback)
    window.removeEventListener('offline', callback)
  }
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,
    () => true // SSR fallback
  )
}
```

Usage:
```tsx
function App() {
  const isOnline = useOnlineStatus()

  return (
    <div>
      {!isOnline && <Banner>You are offline. Some features may be limited.</Banner>}
      {/* ... */}
    </div>
  )
}
```

---

### Phase 5: Install Prompt

#### 5a. Install Button Component

```tsx
// src/components/InstallButton.tsx
import { useState, useEffect } from 'react'

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}

export function InstallButton() {
  const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
  const [isInstalled, setIsInstalled] = useState(false)

  useEffect(() => {
    // Check if already installed
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setIsInstalled(true)
      return
    }

    const handler = (e: Event) => {
      e.preventDefault()
      setDeferredPrompt(e as BeforeInstallPromptEvent)
    }

    window.addEventListener('beforeinstallprompt', handler)
    window.addEventListener('appinstalled', () => setIsInstalled(true))

    return () => window.removeEventListener('beforeinstallprompt', handler)
  }, [])

  const handleInstall = async () => {
    if (!deferredPrompt) return

    deferredPrompt.prompt()
    const { outcome } = await deferredPrompt.userChoice

    if (outcome === 'accepted') {
      setDeferredPrompt(null)
    }
  }

  if (isInstalled || !deferredPrompt) return null

  return (
    <button onClick={handleInstall} className="install-btn">
      Install App
    </button>
  )
}
```

---

### Phase 6: Push Notifications

#### 6a. Request Permission

```typescript
async function requestNotificationPermission(): Promise<boolean> {
  if (!('Notification' in window)) {
    console.warn('Notifications not supported')
    return false
  }

  if (Notification.permission === 'granted') return true
  if (Notification.permission === 'denied') return false

  const permission = await Notification.requestPermission()
  return permission === 'granted'
}
```

#### 6b. Subscribe to Push

```typescript
async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
  const registration = await navigator.serviceWorker.ready

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
  })

  // Send subscription to your backend
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  })

  return subscription
}

function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - base64String.length % 4) % 4)
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
  const rawData = window.atob(base64)
  return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
}
```

#### 6c. Handle Push in Service Worker (injectManifest mode)

```typescript
// src/sw.ts
import { precacheAndRoute } from 'workbox-precaching'

declare const self: ServiceWorkerGlobalScope

precacheAndRoute(self.__WB_MANIFEST)

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? { title: 'Notification', body: 'New update available' }

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/pwa-192x192.png',
      badge: '/badge-72x72.png'
    })
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  event.waitUntil(
    self.clients.openWindow('/')
  )
})
```

---

## Examples

### Example 1: Basic PWA Setup

**Scenario**: Convert a Vite React app to an installable PWA with offline support.

**Steps**:
1. Install dependencies: `npm install -D vite-plugin-pwa`
2. Add VitePWA plugin to vite.config.ts
3. Generate icons from source image
4. Build and test: `npm run build && npm run preview`
5. Verify in Chrome DevTools > Application > Manifest

**Verification**:
- Lighthouse PWA audit passes
- App is installable (install icon in address bar)
- Works offline after first load

### Example 2: API-Heavy App with Smart Caching

**Scenario**: E-commerce app with product API calls that should work offline after browsing.

```typescript
VitePWA({
  workbox: {
    runtimeCaching: [
      // Cache product images aggressively
      {
        urlPattern: /\/products\/images\/.*/,
        handler: 'CacheFirst',
        options: {
          cacheName: 'product-images',
          expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 }
        }
      },
      // Cache API responses with network-first
      {
        urlPattern: /\/api\/products\/.*/,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'product-api',
          expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 },
          networkTimeoutSeconds: 3
        }
      }
    ]
  }
})
```

### Example 3: Periodic Background Sync

**Scenario**: News app that syncs content in background.

```typescript
// Register periodic sync
const registration = await navigator.serviceWorker.ready
await registration.periodicSync.register('sync-articles', {
  minInterval: 60 * 60 * 1000 // 1 hour
})

// Handle in service worker
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'sync-articles') {
    event.waitUntil(syncArticles())
  }
})
```

---

## Script Reference

| Script | Purpose | Permissions |
|--------|---------|-------------|
| `generate-manifest.ts` | Create manifest.webmanifest | `--allow-read --allow-write` |
| `generate-icons.ts` | Generate icon set from source | `--allow-read --allow-write --allow-run` |
| `generate-sw-config.ts` | Create service worker config | `--allow-read --allow-write` |
| `audit-pwa.ts` | Validate PWA compliance | `--allow-read --allow-net` |

### Generate Manifest

```bash
deno run --allow-read --allow-write scripts/generate-manifest.ts \
  --name "My Application" \
  --short-name "MyApp" \
  --description "A great PWA" \
  --theme "#3b82f6" \
  --background "#ffffff" \
  --display standalone \
  --output public/manifest.webmanifest
```

### Generate Icons

```bash
deno run --allow-read --allow-write --allow-run scripts/generate-icons.ts \
  --input logo.svg \
  --output public/ \
  --sizes 192,512 \
  --maskable
```

### Audit PWA

```bash
deno run --allow-read --allow-net scripts/audit-pwa.ts \
  --url http://localhost:5173 \
  --format summary
```

---

## Common Issues

### Issue: Service Worker Not Updating

**Symptoms**: Old content served after deployment.

**Solution**:
1. Ensure `registerType: 'autoUpdate'` is set
2. Add update prompt component to notify users
3. For immediate updates in development: Chrome DevTools > Application > Service Workers > Update on reload

### Issue: App Not Installable

**Symptoms**: No install prompt, Lighthouse fails installability.

**Solution**:
1. Verify manifest has all required fields (name, short_name, icons, start_url, display)
2. Ensure icons are at least 192x192 and 512x512
3. Serve over HTTPS (or localhost for development)
4. Check for service worker registration errors in console

### Issue: Caching API Responses Causes Stale Data

**Symptoms**: Users see outdated data.

**Solution**:
1. Use `NetworkFirst` strategy for dynamic API endpoints
2. Add `networkTimeoutSeconds` for faster fallback
3. Implement cache versioning with expiration

### Issue: Push Notifications Not Working

**Symptoms**: Subscription succeeds but notifications don't arrive.

**Solution**:
1. Verify VAPID keys match between frontend and backend
2. Check service worker is active (not waiting)
3. Ensure `userVisibleOnly: true` in subscription
4. Test push delivery with web-push CLI tool

---

## Additional Resources

### Reference Files
- **`references/caching-strategies.md`** - Deep dive into Workbox caching patterns
- **`references/push-notifications.md`** - Complete push notification backend setup
- **`references/testing-pwas.md`** - Testing strategies for PWA features

### Assets
- **`assets/manifest-template.json`** - Complete manifest with all optional fields
- **`assets/sw-template.ts`** - Custom service worker template for injectManifest

---

## Limitations

- Push notifications require a backend server with web-push
- Background sync has limited browser support (Chrome/Edge)
- iOS Safari has PWA limitations (no push notifications, limited storage)
- Service worker debugging can be complex

## Related Skills

- **frontend-design** - Design systems and component styling
- **web-search** - Research PWA best practices and browser support


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### scripts/generate-icons.ts

```typescript
#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env

/**
 * Generate PWA Icon Set from Source Image
 *
 * Usage:
 *   deno run --allow-read --allow-write --allow-run --allow-env generate-icons.ts \
 *     --input logo.png \
 *     --output public/
 *
 * Permissions: --allow-read --allow-write --allow-run --allow-env
 * Dependencies: sharp (installed via npm)
 */

import { parseArgs } from "jsr:@std/[email protected]/parse-args";
import { ensureDir } from "jsr:@std/[email protected]/ensure-dir";
import { join, extname } from "jsr:@std/[email protected]";

interface IconConfig {
  name: string;
  size: number;
  purpose?: "any" | "maskable";
}

const DEFAULT_ICONS: IconConfig[] = [
  { name: "pwa-64x64.png", size: 64 },
  { name: "pwa-192x192.png", size: 192 },
  { name: "pwa-512x512.png", size: 512 },
  { name: "apple-touch-icon.png", size: 180 },
  { name: "maskable-icon-512x512.png", size: 512, purpose: "maskable" },
];

const FAVICON_SIZES = [16, 32, 48];

function printHelp(): void {
  console.log(`
Generate PWA Icon Set

USAGE:
  deno run --allow-read --allow-write --allow-run --allow-env generate-icons.ts [OPTIONS]

OPTIONS:
  --input <path>          Source image (PNG or SVG, min 512x512) (required)
  --output <dir>          Output directory (required)
  --sizes <list>          Comma-separated sizes (default: 64,192,512)
  --maskable              Also generate maskable icons
  --favicon               Generate favicon.ico
  --apple-touch           Generate apple-touch-icon.png
  --all                   Generate all icon types
  --help                  Show this help message

REQUIREMENTS:
  - Source image should be at least 512x512 pixels
  - For maskable icons, use an image with padding (safe zone is center 80%)
  - Requires sharp: npm install sharp

EXAMPLES:
  # Basic icons
  generate-icons.ts --input logo.png --output public/

  # All icon types
  generate-icons.ts --input logo.svg --output public/ --all

  # Custom sizes
  generate-icons.ts --input logo.png --output public/ --sizes 128,256,512
`);
}

async function checkSharpAvailable(): Promise<boolean> {
  const command = new Deno.Command("npm", {
    args: ["list", "sharp"],
    stdout: "null",
    stderr: "null",
  });

  try {
    const { success } = await command.output();
    return success;
  } catch {
    return false;
  }
}

async function resizeImage(
  inputPath: string,
  outputPath: string,
  size: number,
  _purpose?: string
): Promise<void> {
  // Use Node.js sharp via subprocess since Deno doesn't have native image processing
  const script = `
    const sharp = require('sharp');
    sharp('${inputPath}')
      .resize(${size}, ${size}, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
      .png()
      .toFile('${outputPath}')
      .then(() => console.log('Generated: ${outputPath}'))
      .catch(err => { console.error(err); process.exit(1); });
  `;

  const command = new Deno.Command("node", {
    args: ["-e", script],
    stdout: "inherit",
    stderr: "inherit",
  });

  const { success } = await command.output();
  if (!success) {
    throw new Error(`Failed to generate ${outputPath}`);
  }
}

async function generateFavicon(
  inputPath: string,
  outputPath: string
): Promise<void> {
  // Generate favicon.ico with multiple sizes
  const script = `
    const sharp = require('sharp');
    const fs = require('fs');
    const path = require('path');

    async function generateFavicon() {
      const sizes = [16, 32, 48];
      const buffers = await Promise.all(
        sizes.map(size =>
          sharp('${inputPath}')
            .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
            .png()
            .toBuffer()
        )
      );

      // For simplicity, just use 32x32 as ico (proper ICO generation needs a library)
      await sharp('${inputPath}')
        .resize(32, 32, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
        .png()
        .toFile('${outputPath.replace(".ico", ".png")}');

      console.log('Generated: ${outputPath.replace(".ico", ".png")} (use as favicon)');
    }

    generateFavicon().catch(err => { console.error(err); process.exit(1); });
  `;

  const command = new Deno.Command("node", {
    args: ["-e", script],
    stdout: "inherit",
    stderr: "inherit",
  });

  const { success } = await command.output();
  if (!success) {
    throw new Error(`Failed to generate favicon`);
  }
}

export async function generateIcons(options: {
  input: string;
  output: string;
  sizes?: number[];
  maskable?: boolean;
  favicon?: boolean;
  appleTouch?: boolean;
  all?: boolean;
}): Promise<void> {
  // Check if input file exists
  try {
    await Deno.stat(options.input);
  } catch {
    console.error(`Input file not found: ${options.input}`);
    Deno.exit(1);
  }

  // Check for sharp
  const hasSharp = await checkSharpAvailable();
  if (!hasSharp) {
    console.error("Error: sharp is not installed.");
    console.error("Install it with: npm install sharp");
    Deno.exit(1);
  }

  // Ensure output directory exists
  await ensureDir(options.output);

  const sizes = options.sizes || [64, 192, 512];
  const generateAll = options.all;
  const generateMaskable = generateAll || options.maskable;
  const generateFaviconFile = generateAll || options.favicon;
  const generateAppleTouch = generateAll || options.appleTouch;

  console.log(`Generating icons from: ${options.input}`);
  console.log(`Output directory: ${options.output}`);
  console.log(`Sizes: ${sizes.join(", ")}`);
  console.log("");

  // Generate standard PWA icons
  for (const size of sizes) {
    const outputPath = join(options.output, `pwa-${size}x${size}.png`);
    await resizeImage(options.input, outputPath, size);
  }

  // Generate maskable icons
  if (generateMaskable) {
    for (const size of sizes.filter((s) => s >= 192)) {
      const outputPath = join(options.output, `maskable-icon-${size}x${size}.png`);
      await resizeImage(options.input, outputPath, size, "maskable");
    }
  }

  // Generate apple-touch-icon
  if (generateAppleTouch) {
    const outputPath = join(options.output, "apple-touch-icon.png");
    await resizeImage(options.input, outputPath, 180);
  }

  // Generate favicon
  if (generateFaviconFile) {
    const outputPath = join(options.output, "favicon.ico");
    await generateFavicon(options.input, outputPath);
  }

  console.log("");
  console.log("Icon generation complete!");
  console.log("");
  console.log("Add to your manifest.json icons array:");
  console.log(
    JSON.stringify(
      sizes.map((size) => ({
        src: `pwa-${size}x${size}.png`,
        sizes: `${size}x${size}`,
        type: "image/png",
      })),
      null,
      2
    )
  );

  if (generateMaskable) {
    console.log("");
    console.log("Maskable icon for manifest:");
    console.log(
      JSON.stringify(
        {
          src: "maskable-icon-512x512.png",
          sizes: "512x512",
          type: "image/png",
          purpose: "maskable",
        },
        null,
        2
      )
    );
  }
}

if (import.meta.main) {
  const args = parseArgs(Deno.args, {
    string: ["input", "output", "sizes"],
    boolean: ["help", "maskable", "favicon", "apple-touch", "all"],
    default: {
      sizes: "64,192,512",
    },
  });

  if (args.help) {
    printHelp();
    Deno.exit(0);
  }

  if (!args.input || !args.output) {
    console.error("Error: --input and --output are required");
    printHelp();
    Deno.exit(1);
  }

  const sizes = args.sizes.split(",").map((s: string) => parseInt(s.trim(), 10));

  await generateIcons({
    input: args.input,
    output: args.output,
    sizes,
    maskable: args.maskable,
    favicon: args.favicon,
    appleTouch: args["apple-touch"],
    all: args.all,
  });
}

```

### scripts/generate-manifest.ts

```typescript
#!/usr/bin/env -S deno run --allow-read --allow-write

/**
 * Generate a Web App Manifest for PWA
 *
 * Usage:
 *   deno run --allow-read --allow-write generate-manifest.ts \
 *     --name "My App" \
 *     --short-name "App" \
 *     --output public/manifest.webmanifest
 *
 * Permissions: --allow-read --allow-write
 */

import { parseArgs } from "jsr:@std/[email protected]/parse-args";

interface ManifestIcon {
  src: string;
  sizes: string;
  type: string;
  purpose?: string;
}

interface WebAppManifest {
  name: string;
  short_name: string;
  description?: string;
  start_url: string;
  display: "fullscreen" | "standalone" | "minimal-ui" | "browser";
  orientation?: "any" | "natural" | "landscape" | "portrait";
  background_color: string;
  theme_color: string;
  icons: ManifestIcon[];
  categories?: string[];
  screenshots?: Array<{ src: string; sizes: string; type: string }>;
  shortcuts?: Array<{
    name: string;
    short_name?: string;
    url: string;
    icons?: ManifestIcon[];
  }>;
  related_applications?: Array<{
    platform: string;
    url: string;
    id?: string;
  }>;
  prefer_related_applications?: boolean;
}

const DEFAULT_ICONS: ManifestIcon[] = [
  { src: "pwa-64x64.png", sizes: "64x64", type: "image/png" },
  { src: "pwa-192x192.png", sizes: "192x192", type: "image/png" },
  { src: "pwa-512x512.png", sizes: "512x512", type: "image/png" },
  {
    src: "maskable-icon-512x512.png",
    sizes: "512x512",
    type: "image/png",
    purpose: "maskable",
  },
];

function printHelp(): void {
  console.log(`
Generate Web App Manifest

USAGE:
  deno run --allow-read --allow-write generate-manifest.ts [OPTIONS]

OPTIONS:
  --name <name>           Full application name (required)
  --short-name <name>     Short name for home screen (required)
  --description <desc>    Application description
  --theme <color>         Theme color (default: #ffffff)
  --background <color>    Background color (default: #ffffff)
  --display <mode>        Display mode: fullscreen|standalone|minimal-ui|browser (default: standalone)
  --orientation <mode>    Orientation: any|natural|landscape|portrait
  --start-url <url>       Start URL (default: /)
  --icons <path>          JSON file with custom icons array
  --output <path>         Output file path (default: manifest.webmanifest)
  --pretty                Pretty-print JSON output
  --help                  Show this help message

EXAMPLES:
  # Basic manifest
  generate-manifest.ts --name "My PWA" --short-name "PWA" --output public/manifest.webmanifest

  # Full configuration
  generate-manifest.ts \\
    --name "My Application" \\
    --short-name "MyApp" \\
    --description "A progressive web application" \\
    --theme "#3b82f6" \\
    --background "#ffffff" \\
    --display standalone \\
    --pretty \\
    --output public/manifest.webmanifest
`);
}

function validateColor(color: string): boolean {
  return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color);
}

function validateDisplayMode(
  mode: string
): mode is "fullscreen" | "standalone" | "minimal-ui" | "browser" {
  return ["fullscreen", "standalone", "minimal-ui", "browser"].includes(mode);
}

async function loadIconsFromFile(path: string): Promise<ManifestIcon[]> {
  try {
    const content = await Deno.readTextFile(path);
    const icons = JSON.parse(content);
    if (!Array.isArray(icons)) {
      throw new Error("Icons file must contain an array");
    }
    return icons;
  } catch (error) {
    console.error(`Error loading icons from ${path}:`, error);
    return DEFAULT_ICONS;
  }
}

export async function generateManifest(options: {
  name: string;
  shortName: string;
  description?: string;
  themeColor?: string;
  backgroundColor?: string;
  display?: string;
  orientation?: string;
  startUrl?: string;
  iconsPath?: string;
  output: string;
  pretty?: boolean;
}): Promise<void> {
  const themeColor = options.themeColor || "#ffffff";
  const backgroundColor = options.backgroundColor || "#ffffff";
  const display = options.display || "standalone";

  if (!validateColor(themeColor)) {
    console.error(`Invalid theme color: ${themeColor}. Use hex format (#fff or #ffffff)`);
    Deno.exit(1);
  }

  if (!validateColor(backgroundColor)) {
    console.error(`Invalid background color: ${backgroundColor}. Use hex format`);
    Deno.exit(1);
  }

  if (!validateDisplayMode(display)) {
    console.error(`Invalid display mode: ${display}`);
    Deno.exit(1);
  }

  const icons = options.iconsPath
    ? await loadIconsFromFile(options.iconsPath)
    : DEFAULT_ICONS;

  const manifest: WebAppManifest = {
    name: options.name,
    short_name: options.shortName,
    start_url: options.startUrl || "/",
    display,
    background_color: backgroundColor,
    theme_color: themeColor,
    icons,
  };

  if (options.description) {
    manifest.description = options.description;
  }

  if (
    options.orientation &&
    ["any", "natural", "landscape", "portrait"].includes(options.orientation)
  ) {
    manifest.orientation = options.orientation as WebAppManifest["orientation"];
  }

  const jsonContent = options.pretty
    ? JSON.stringify(manifest, null, 2)
    : JSON.stringify(manifest);

  await Deno.writeTextFile(options.output, jsonContent);
  console.log(`Manifest generated: ${options.output}`);
  console.log(`  Name: ${manifest.name}`);
  console.log(`  Short name: ${manifest.short_name}`);
  console.log(`  Display: ${manifest.display}`);
  console.log(`  Theme: ${manifest.theme_color}`);
  console.log(`  Icons: ${manifest.icons.length}`);
}

if (import.meta.main) {
  const args = parseArgs(Deno.args, {
    string: [
      "name",
      "short-name",
      "description",
      "theme",
      "background",
      "display",
      "orientation",
      "start-url",
      "icons",
      "output",
    ],
    boolean: ["help", "pretty"],
    default: {
      output: "manifest.webmanifest",
      theme: "#ffffff",
      background: "#ffffff",
      display: "standalone",
      "start-url": "/",
    },
  });

  if (args.help) {
    printHelp();
    Deno.exit(0);
  }

  if (!args.name || !args["short-name"]) {
    console.error("Error: --name and --short-name are required");
    printHelp();
    Deno.exit(1);
  }

  await generateManifest({
    name: args.name,
    shortName: args["short-name"],
    description: args.description,
    themeColor: args.theme,
    backgroundColor: args.background,
    display: args.display,
    orientation: args.orientation,
    startUrl: args["start-url"],
    iconsPath: args.icons,
    output: args.output,
    pretty: args.pretty,
  });
}

```

### scripts/audit-pwa.ts

```typescript
#!/usr/bin/env -S deno run --allow-read --allow-net

/**
 * Audit PWA Compliance
 *
 * Usage:
 *   deno run --allow-read --allow-net audit-pwa.ts --manifest public/manifest.webmanifest
 *   deno run --allow-read --allow-net audit-pwa.ts --url http://localhost:5173
 *
 * Permissions: --allow-read --allow-net
 */

import { parseArgs } from "jsr:@std/[email protected]/parse-args";

interface AuditResult {
  category: string;
  check: string;
  status: "pass" | "fail" | "warn" | "skip";
  message: string;
  fix?: string;
}

interface ManifestData {
  name?: string;
  short_name?: string;
  start_url?: string;
  display?: string;
  background_color?: string;
  theme_color?: string;
  icons?: Array<{
    src: string;
    sizes: string;
    type?: string;
    purpose?: string;
  }>;
  description?: string;
  orientation?: string;
  categories?: string[];
  screenshots?: unknown[];
  shortcuts?: unknown[];
}

function printHelp(): void {
  console.log(`
PWA Compliance Audit

USAGE:
  deno run --allow-read --allow-net audit-pwa.ts [OPTIONS]

OPTIONS:
  --manifest <path>       Path to manifest.webmanifest file
  --url <url>             URL to fetch manifest from (alternative to --manifest)
  --format <type>         Output format: summary|json|detailed (default: summary)
  --strict                Treat warnings as failures
  --help                  Show this help message

CHECKS:
  - Manifest presence and validity
  - Required fields (name, short_name, start_url, display, icons)
  - Icon requirements (192x192, 512x512, maskable)
  - Color values
  - Display modes
  - Installability criteria

EXAMPLES:
  # Audit local manifest
  audit-pwa.ts --manifest public/manifest.webmanifest

  # Audit running app
  audit-pwa.ts --url http://localhost:5173

  # Detailed output
  audit-pwa.ts --manifest public/manifest.json --format detailed
`);
}

function validateColor(color: string): boolean {
  return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color);
}

async function loadManifest(
  path?: string,
  url?: string
): Promise<ManifestData | null> {
  try {
    if (path) {
      const content = await Deno.readTextFile(path);
      return JSON.parse(content);
    } else if (url) {
      const manifestUrl = url.endsWith("/")
        ? `${url}manifest.webmanifest`
        : `${url}/manifest.webmanifest`;
      const response = await fetch(manifestUrl);
      if (!response.ok) {
        // Try alternative name
        const altResponse = await fetch(
          url.endsWith("/") ? `${url}manifest.json` : `${url}/manifest.json`
        );
        if (!altResponse.ok) {
          console.error("Could not fetch manifest from URL");
          return null;
        }
        return await altResponse.json();
      }
      return await response.json();
    }
  } catch (error) {
    console.error("Error loading manifest:", error);
    return null;
  }
  return null;
}

function auditManifest(manifest: ManifestData): AuditResult[] {
  const results: AuditResult[] = [];

  // Required fields
  if (!manifest.name) {
    results.push({
      category: "Required Fields",
      check: "name",
      status: "fail",
      message: "Missing required 'name' field",
      fix: "Add 'name' field with full application name",
    });
  } else if (manifest.name.length > 45) {
    results.push({
      category: "Required Fields",
      check: "name",
      status: "warn",
      message: `Name is ${manifest.name.length} chars (recommended max 45)`,
    });
  } else {
    results.push({
      category: "Required Fields",
      check: "name",
      status: "pass",
      message: `Name: "${manifest.name}"`,
    });
  }

  if (!manifest.short_name) {
    results.push({
      category: "Required Fields",
      check: "short_name",
      status: "fail",
      message: "Missing required 'short_name' field",
      fix: "Add 'short_name' field (12 chars or less recommended)",
    });
  } else if (manifest.short_name.length > 12) {
    results.push({
      category: "Required Fields",
      check: "short_name",
      status: "warn",
      message: `Short name is ${manifest.short_name.length} chars (may be truncated on home screen)`,
    });
  } else {
    results.push({
      category: "Required Fields",
      check: "short_name",
      status: "pass",
      message: `Short name: "${manifest.short_name}"`,
    });
  }

  if (!manifest.start_url) {
    results.push({
      category: "Required Fields",
      check: "start_url",
      status: "fail",
      message: "Missing 'start_url' field",
      fix: "Add 'start_url': '/' or your app's entry point",
    });
  } else {
    results.push({
      category: "Required Fields",
      check: "start_url",
      status: "pass",
      message: `Start URL: ${manifest.start_url}`,
    });
  }

  // Display mode
  const validDisplayModes = ["fullscreen", "standalone", "minimal-ui", "browser"];
  if (!manifest.display) {
    results.push({
      category: "Display",
      check: "display",
      status: "fail",
      message: "Missing 'display' field",
      fix: "Add 'display': 'standalone' for app-like experience",
    });
  } else if (!validDisplayModes.includes(manifest.display)) {
    results.push({
      category: "Display",
      check: "display",
      status: "fail",
      message: `Invalid display mode: ${manifest.display}`,
      fix: `Use one of: ${validDisplayModes.join(", ")}`,
    });
  } else if (manifest.display === "browser") {
    results.push({
      category: "Display",
      check: "display",
      status: "warn",
      message: "Using 'browser' display mode - app will open in browser tab",
    });
  } else {
    results.push({
      category: "Display",
      check: "display",
      status: "pass",
      message: `Display mode: ${manifest.display}`,
    });
  }

  // Colors
  if (!manifest.background_color) {
    results.push({
      category: "Colors",
      check: "background_color",
      status: "warn",
      message: "Missing 'background_color' - splash screen may look inconsistent",
      fix: "Add 'background_color': '#ffffff' (or your preferred color)",
    });
  } else if (!validateColor(manifest.background_color)) {
    results.push({
      category: "Colors",
      check: "background_color",
      status: "fail",
      message: `Invalid background_color: ${manifest.background_color}`,
      fix: "Use hex format: #fff or #ffffff",
    });
  } else {
    results.push({
      category: "Colors",
      check: "background_color",
      status: "pass",
      message: `Background color: ${manifest.background_color}`,
    });
  }

  if (!manifest.theme_color) {
    results.push({
      category: "Colors",
      check: "theme_color",
      status: "warn",
      message: "Missing 'theme_color' - browser UI may not match app",
      fix: "Add 'theme_color': '#ffffff' (or your brand color)",
    });
  } else if (!validateColor(manifest.theme_color)) {
    results.push({
      category: "Colors",
      check: "theme_color",
      status: "fail",
      message: `Invalid theme_color: ${manifest.theme_color}`,
      fix: "Use hex format: #fff or #ffffff",
    });
  } else {
    results.push({
      category: "Colors",
      check: "theme_color",
      status: "pass",
      message: `Theme color: ${manifest.theme_color}`,
    });
  }

  // Icons
  if (!manifest.icons || manifest.icons.length === 0) {
    results.push({
      category: "Icons",
      check: "icons_present",
      status: "fail",
      message: "No icons defined",
      fix: "Add icons array with at least 192x192 and 512x512 PNG icons",
    });
  } else {
    results.push({
      category: "Icons",
      check: "icons_present",
      status: "pass",
      message: `${manifest.icons.length} icon(s) defined`,
    });

    // Check for required sizes
    const sizes = manifest.icons.map((i) => i.sizes);
    const has192 = sizes.some((s) => s?.includes("192x192"));
    const has512 = sizes.some((s) => s?.includes("512x512"));
    const hasMaskable = manifest.icons.some((i) =>
      i.purpose?.includes("maskable")
    );

    if (!has192) {
      results.push({
        category: "Icons",
        check: "icon_192",
        status: "fail",
        message: "Missing 192x192 icon (required for Android)",
        fix: "Add icon with sizes: '192x192'",
      });
    } else {
      results.push({
        category: "Icons",
        check: "icon_192",
        status: "pass",
        message: "192x192 icon present",
      });
    }

    if (!has512) {
      results.push({
        category: "Icons",
        check: "icon_512",
        status: "fail",
        message: "Missing 512x512 icon (required for splash screen)",
        fix: "Add icon with sizes: '512x512'",
      });
    } else {
      results.push({
        category: "Icons",
        check: "icon_512",
        status: "pass",
        message: "512x512 icon present",
      });
    }

    if (!hasMaskable) {
      results.push({
        category: "Icons",
        check: "icon_maskable",
        status: "warn",
        message: "No maskable icon - app icon may be cropped on some devices",
        fix: "Add icon with purpose: 'maskable' (ensure 80% safe zone)",
      });
    } else {
      results.push({
        category: "Icons",
        check: "icon_maskable",
        status: "pass",
        message: "Maskable icon present",
      });
    }
  }

  // Optional enhancements
  if (!manifest.description) {
    results.push({
      category: "Optional",
      check: "description",
      status: "warn",
      message: "No description - useful for app stores",
    });
  } else {
    results.push({
      category: "Optional",
      check: "description",
      status: "pass",
      message: "Description present",
    });
  }

  if (!manifest.screenshots || manifest.screenshots.length === 0) {
    results.push({
      category: "Optional",
      check: "screenshots",
      status: "warn",
      message: "No screenshots - improves install prompt on desktop",
    });
  } else {
    results.push({
      category: "Optional",
      check: "screenshots",
      status: "pass",
      message: `${manifest.screenshots.length} screenshot(s) present`,
    });
  }

  return results;
}

function formatSummary(results: AuditResult[]): string {
  const passed = results.filter((r) => r.status === "pass").length;
  const warned = results.filter((r) => r.status === "warn").length;
  const failed = results.filter((r) => r.status === "fail").length;

  const lines: string[] = [];
  lines.push("\n=== PWA Audit Summary ===\n");

  // Group by category
  const categories = [...new Set(results.map((r) => r.category))];

  for (const category of categories) {
    const categoryResults = results.filter((r) => r.category === category);
    lines.push(`\n${category}:`);

    for (const result of categoryResults) {
      const icon =
        result.status === "pass"
          ? "✓"
          : result.status === "warn"
          ? "⚠"
          : result.status === "fail"
          ? "✗"
          : "○";
      lines.push(`  ${icon} ${result.check}: ${result.message}`);
      if (result.fix && result.status !== "pass") {
        lines.push(`    → Fix: ${result.fix}`);
      }
    }
  }

  lines.push("\n---");
  lines.push(
    `Results: ${passed} passed, ${warned} warnings, ${failed} failed`
  );

  if (failed === 0 && warned === 0) {
    lines.push("✓ PWA is fully compliant!");
  } else if (failed === 0) {
    lines.push("⚠ PWA is installable but has recommendations");
  } else {
    lines.push("✗ PWA has issues that prevent installation");
  }

  return lines.join("\n");
}

function formatDetailed(results: AuditResult[]): string {
  const lines: string[] = [];
  lines.push("\n=== PWA Audit Detailed Report ===\n");

  const categories = [...new Set(results.map((r) => r.category))];

  for (const category of categories) {
    const categoryResults = results.filter((r) => r.category === category);
    lines.push(`\n## ${category}\n`);

    for (const result of categoryResults) {
      lines.push(`### ${result.check}`);
      lines.push(`Status: ${result.status.toUpperCase()}`);
      lines.push(`Message: ${result.message}`);
      if (result.fix) {
        lines.push(`Fix: ${result.fix}`);
      }
      lines.push("");
    }
  }

  return lines.join("\n");
}

if (import.meta.main) {
  const args = parseArgs(Deno.args, {
    string: ["manifest", "url", "format"],
    boolean: ["help", "strict"],
    default: {
      format: "summary",
    },
  });

  if (args.help) {
    printHelp();
    Deno.exit(0);
  }

  if (!args.manifest && !args.url) {
    console.error("Error: --manifest or --url is required");
    printHelp();
    Deno.exit(1);
  }

  const manifest = await loadManifest(args.manifest, args.url);

  if (!manifest) {
    console.error("Failed to load manifest");
    Deno.exit(1);
  }

  const results = auditManifest(manifest);

  if (args.format === "json") {
    console.log(JSON.stringify(results, null, 2));
  } else if (args.format === "detailed") {
    console.log(formatDetailed(results));
  } else {
    console.log(formatSummary(results));
  }

  const failed = results.filter((r) => r.status === "fail").length;
  const warned = results.filter((r) => r.status === "warn").length;

  if (failed > 0 || (args.strict && warned > 0)) {
    Deno.exit(1);
  }
}

```

### references/caching-strategies.md

```markdown
# Caching Strategies Deep Dive

Comprehensive guide to Workbox caching strategies for PWAs.

## Strategy Overview

| Strategy | When to Use | Trade-off |
|----------|-------------|-----------|
| CacheFirst | Static assets, fonts, images | Fast but potentially stale |
| NetworkFirst | API data, dynamic content | Fresh but slower offline |
| StaleWhileRevalidate | Frequently updated assets | Balance of speed and freshness |
| NetworkOnly | Real-time data, auth | No offline support |
| CacheOnly | Precached assets | Fully offline, never updates |

## CacheFirst

Serve from cache immediately, fall back to network only if not cached.

```typescript
{
  urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/,
  handler: 'CacheFirst',
  options: {
    cacheName: 'images-cache',
    expiration: {
      maxEntries: 100,
      maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
    },
    cacheableResponse: {
      statuses: [0, 200]
    }
  }
}
```

**Best for:**
- Images that rarely change
- Font files (woff2, ttf)
- Static JavaScript/CSS (with cache busting)
- CDN assets

**Considerations:**
- Use cache busting (filename hashing) for updateable assets
- Set appropriate `maxAgeSeconds` based on update frequency
- `statuses: [0, 200]` handles opaque responses from CDNs

## NetworkFirst

Try network, fall back to cache if offline or slow.

```typescript
{
  urlPattern: /^https:\/\/api\.example\.com\/products/,
  handler: 'NetworkFirst',
  options: {
    cacheName: 'api-products',
    expiration: {
      maxEntries: 50,
      maxAgeSeconds: 60 * 60 * 24 // 24 hours
    },
    networkTimeoutSeconds: 3, // Fall back to cache after 3s
    cacheableResponse: {
      statuses: [0, 200]
    }
  }
}
```

**Best for:**
- API responses that should be fresh
- User-specific data
- Frequently updated content
- Social feeds, news

**Key option: `networkTimeoutSeconds`**
- Without it: waits for network indefinitely before cache fallback
- With it: falls back to cache if network takes too long
- Recommended: 3-5 seconds for API calls

## StaleWhileRevalidate

Serve from cache immediately, update cache in background.

```typescript
{
  urlPattern: /^https:\/\/fonts\.googleapis\.com/,
  handler: 'StaleWhileRevalidate',
  options: {
    cacheName: 'google-fonts-stylesheets',
    expiration: {
      maxEntries: 10,
      maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
    }
  }
}
```

**Best for:**
- Assets that update occasionally
- Font stylesheets
- Analytics scripts
- User avatars
- Non-critical API data

**Trade-off:**
- First visit: same as NetworkFirst
- Subsequent visits: instant from cache, updates in background
- User sees stale data briefly on each visit

## Combining Strategies

Real-world apps need multiple strategies:

```typescript
workbox: {
  runtimeCaching: [
    // Critical API: NetworkFirst
    {
      urlPattern: /^https:\/\/api\.example\.com\/user/,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'user-api',
        networkTimeoutSeconds: 5
      }
    },

    // Product catalog: StaleWhileRevalidate
    {
      urlPattern: /^https:\/\/api\.example\.com\/products/,
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'products-api',
        expiration: { maxAgeSeconds: 60 * 60 }
      }
    },

    // Static assets: CacheFirst
    {
      urlPattern: /\.(?:js|css)$/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'static-resources',
        expiration: { maxAgeSeconds: 60 * 60 * 24 * 30 }
      }
    },

    // Images: CacheFirst with generous expiration
    {
      urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'images',
        expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 365 }
      }
    }
  ]
}
```

## Cache Management

### Expiration Options

```typescript
expiration: {
  maxEntries: 100,        // Max items in cache
  maxAgeSeconds: 86400,   // Max age of cached items
  purgeOnQuotaError: true // Delete cache if storage quota exceeded
}
```

### Cache Naming

Use descriptive, unique cache names:
- `api-v1-products` - version-specific API cache
- `images-cdn` - specific asset source
- `fonts-google` - external resource cache

### Clearing Caches

```typescript
// In service worker
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => !allowedCaches.includes(name))
          .map((name) => caches.delete(name))
      );
    })
  );
});
```

## Offline Fallbacks

### Navigation Fallback

```typescript
workbox: {
  navigateFallback: '/offline.html',
  navigateFallbackDenylist: [
    /^\/api/,      // Don't intercept API routes
    /\.[^/]+$/     // Don't intercept file requests
  ]
}
```

### Runtime Fallback Images

```typescript
// Custom plugin for image fallback
{
  urlPattern: /\.(?:png|jpg|jpeg|webp)$/,
  handler: 'CacheFirst',
  options: {
    cacheName: 'images',
    plugins: [
      {
        handlerDidError: async () => {
          return caches.match('/fallback-image.png');
        }
      }
    ]
  }
}
```

## Advanced Patterns

### Conditional Caching

```typescript
// Only cache successful responses
{
  urlPattern: /api/,
  handler: 'NetworkFirst',
  options: {
    cacheableResponse: {
      statuses: [200], // Only cache 200 OK
      headers: {
        'x-cache-ok': 'true' // Only if header present
      }
    }
  }
}
```

### Request Modification

```typescript
// Add auth header to API requests
{
  urlPattern: /api/,
  handler: 'NetworkFirst',
  options: {
    fetchOptions: {
      credentials: 'include'
    }
  }
}
```

### Background Sync Queue

```typescript
import { Queue } from 'workbox-background-sync';

const queue = new Queue('api-requests');

// If network fails, queue for retry
self.addEventListener('fetch', (event) => {
  if (event.request.method === 'POST' && event.request.url.includes('/api/')) {
    const clonedRequest = event.request.clone();
    event.respondWith(
      fetch(event.request).catch(() => {
        queue.pushRequest({ request: clonedRequest });
        return new Response(JSON.stringify({ queued: true }));
      })
    );
  }
});
```

## Debugging Tips

### Chrome DevTools

1. Application > Cache Storage - view cached items
2. Application > Service Workers - check SW status
3. Network tab - enable "Offline" to test
4. Console - Workbox logs routing decisions

### Force Update

```typescript
// In development, skip waiting
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});
```

### Logging

```typescript
// Enable Workbox debug logging
import { setLogLevel } from 'workbox-core';
setLogLevel('debug');
```

```

### references/push-notifications.md

```markdown
# Push Notifications Complete Guide

End-to-end implementation of Web Push Notifications for React PWAs.

## Architecture Overview

```
┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   Browser   │◄───│ Push Service│◄───│ Your Server │
│ (SW + User) │    │ (FCM/APNS)  │    │ (web-push)  │
└─────────────┘    └─────────────┘    └─────────────┘
     │                    │                  │
     │  1. Subscribe      │                  │
     ├───────────────────►│                  │
     │  2. Subscription   │                  │
     │◄───────────────────┤                  │
     │  3. Send to server │                  │
     ├──────────────────────────────────────►│
     │                    │  4. Push message │
     │                    │◄─────────────────┤
     │  5. Notification   │                  │
     │◄───────────────────┤                  │
```

## Setup Steps

### 1. Generate VAPID Keys

```bash
# Using web-push CLI
npx web-push generate-vapid-keys

# Output:
# Public Key: BNb...
# Private Key: hZr...
```

Store these securely:
- Public key: Frontend (can be exposed)
- Private key: Backend only (secret!)

### 2. Frontend: Request Permission

```tsx
// src/hooks/usePushNotifications.ts
import { useState, useCallback } from 'react';

interface PushSubscriptionState {
  subscription: PushSubscription | null;
  isSupported: boolean;
  permission: NotificationPermission;
  isLoading: boolean;
  error: string | null;
}

export function usePushNotifications() {
  const [state, setState] = useState<PushSubscriptionState>({
    subscription: null,
    isSupported: 'Notification' in window && 'serviceWorker' in navigator,
    permission: Notification.permission,
    isLoading: false,
    error: null,
  });

  const subscribe = useCallback(async () => {
    if (!state.isSupported) {
      setState(s => ({ ...s, error: 'Push not supported' }));
      return null;
    }

    setState(s => ({ ...s, isLoading: true, error: null }));

    try {
      // Request permission
      const permission = await Notification.requestPermission();
      setState(s => ({ ...s, permission }));

      if (permission !== 'granted') {
        throw new Error('Permission denied');
      }

      // Get service worker registration
      const registration = await navigator.serviceWorker.ready;

      // Subscribe to push
      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          import.meta.env.VITE_VAPID_PUBLIC_KEY
        ),
      });

      // Send subscription to backend
      await fetch('/api/push/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(subscription),
      });

      setState(s => ({ ...s, subscription, isLoading: false }));
      return subscription;
    } catch (error) {
      setState(s => ({
        ...s,
        isLoading: false,
        error: error instanceof Error ? error.message : 'Unknown error',
      }));
      return null;
    }
  }, [state.isSupported]);

  const unsubscribe = useCallback(async () => {
    if (!state.subscription) return;

    try {
      await state.subscription.unsubscribe();
      await fetch('/api/push/unsubscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ endpoint: state.subscription.endpoint }),
      });
      setState(s => ({ ...s, subscription: null }));
    } catch (error) {
      console.error('Unsubscribe failed:', error);
    }
  }, [state.subscription]);

  return { ...state, subscribe, unsubscribe };
}

function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const rawData = window.atob(base64);
  return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}
```

### 3. Frontend: Notification UI

```tsx
// src/components/NotificationSettings.tsx
import { usePushNotifications } from '../hooks/usePushNotifications';

export function NotificationSettings() {
  const {
    isSupported,
    permission,
    subscription,
    isLoading,
    error,
    subscribe,
    unsubscribe,
  } = usePushNotifications();

  if (!isSupported) {
    return <p>Push notifications are not supported in this browser.</p>;
  }

  if (permission === 'denied') {
    return (
      <div>
        <p>Notifications are blocked.</p>
        <p>Enable them in your browser settings to receive updates.</p>
      </div>
    );
  }

  return (
    <div>
      {error && <p className="error">{error}</p>}

      {subscription ? (
        <button onClick={unsubscribe} disabled={isLoading}>
          Disable Notifications
        </button>
      ) : (
        <button onClick={subscribe} disabled={isLoading}>
          {isLoading ? 'Enabling...' : 'Enable Notifications'}
        </button>
      )}
    </div>
  );
}
```

### 4. Service Worker: Handle Push

```typescript
// src/sw.ts (for injectManifest mode)
import { precacheAndRoute } from 'workbox-precaching';

declare const self: ServiceWorkerGlobalScope;

precacheAndRoute(self.__WB_MANIFEST);

// Handle push events
self.addEventListener('push', (event) => {
  if (!event.data) return;

  const data = event.data.json();

  const options: NotificationOptions = {
    body: data.body,
    icon: '/pwa-192x192.png',
    badge: '/badge-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/',
      timestamp: Date.now(),
    },
    actions: data.actions || [
      { action: 'open', title: 'Open' },
      { action: 'dismiss', title: 'Dismiss' },
    ],
    tag: data.tag || 'default',
    renotify: data.renotify || false,
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Handle notification click
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const action = event.action;
  const url = event.notification.data?.url || '/';

  if (action === 'dismiss') return;

  event.waitUntil(
    self.clients.matchAll({ type: 'window' }).then((clients) => {
      // Focus existing window if available
      for (const client of clients) {
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      // Open new window
      return self.clients.openWindow(url);
    })
  );
});

// Handle notification close (for analytics)
self.addEventListener('notificationclose', (event) => {
  const notification = event.notification;
  // Send analytics
  fetch('/api/notifications/closed', {
    method: 'POST',
    body: JSON.stringify({
      tag: notification.tag,
      timestamp: notification.data?.timestamp,
    }),
  });
});
```

### 5. Vite Config for Custom SW

```typescript
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    VitePWA({
      strategies: 'injectManifest',
      srcDir: 'src',
      filename: 'sw.ts',
      injectManifest: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
      },
    }),
  ],
});
```

### 6. Backend: Send Notifications

```typescript
// Node.js backend example
import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
);

// Store subscriptions (use database in production)
const subscriptions = new Map<string, PushSubscription>();

// Subscribe endpoint
app.post('/api/push/subscribe', (req, res) => {
  const subscription = req.body;
  subscriptions.set(subscription.endpoint, subscription);
  res.status(201).json({ success: true });
});

// Unsubscribe endpoint
app.post('/api/push/unsubscribe', (req, res) => {
  const { endpoint } = req.body;
  subscriptions.delete(endpoint);
  res.json({ success: true });
});

// Send notification
async function sendNotification(
  subscription: PushSubscription,
  payload: { title: string; body: string; url?: string }
) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify(payload)
    );
  } catch (error: any) {
    if (error.statusCode === 410) {
      // Subscription expired, remove it
      subscriptions.delete(subscription.endpoint);
    }
    throw error;
  }
}

// Broadcast to all subscribers
async function broadcastNotification(payload: object) {
  const promises = Array.from(subscriptions.values()).map(sub =>
    sendNotification(sub, payload).catch(console.error)
  );
  await Promise.all(promises);
}
```

## Notification Types

### Basic Notification

```typescript
{
  title: 'New Message',
  body: 'You have a new message from John',
  icon: '/icons/message.png'
}
```

### Actionable Notification

```typescript
{
  title: 'New Order',
  body: 'Order #1234 received',
  actions: [
    { action: 'view', title: 'View Order' },
    { action: 'ship', title: 'Mark Shipped' }
  ],
  data: { orderId: 1234 }
}
```

### Grouped Notifications

```typescript
// Use same tag to replace
{
  title: 'Chat Messages',
  body: '3 new messages',
  tag: 'chat-messages',  // Replaces existing
  renotify: true         // Vibrate again
}
```

## Best Practices

### Permission Timing

- Don't ask immediately on page load
- Show notification value before asking
- Provide settings to change preference

### Notification Content

- Keep titles under 50 characters
- Body under 120 characters
- Use meaningful icons
- Include relevant actions

### Rate Limiting

- Don't spam users
- Group related notifications
- Allow notification preferences
- Implement quiet hours

## iOS Limitations

As of 2024, iOS Safari supports Web Push with limitations:
- Requires user to "Add to Home Screen" first
- No badge updates
- Notifications may be delayed
- No background sync

Check support:
```typescript
const isIOSSafari = /iPad|iPhone|iPod/.test(navigator.userAgent);
const canPush = 'PushManager' in window;
```

## Testing

### Local Testing

```bash
# Send test notification via curl
curl -X POST http://localhost:3000/api/push/test \
  -H "Content-Type: application/json" \
  -d '{"title": "Test", "body": "Hello!"}'
```

### Browser DevTools

1. Application > Service Workers > Push
2. Enter payload JSON
3. Click "Push" to simulate

### web-push CLI

```bash
npx web-push send-notification \
  --endpoint="https://..." \
  --key="..." \
  --auth="..." \
  --payload='{"title":"Test"}'
```

```

### references/testing-pwas.md

```markdown
# Testing PWAs

Comprehensive testing strategies for Progressive Web Apps.

## Testing Checklist

### Installability
- [ ] Manifest loads without errors
- [ ] Icons display correctly at all sizes
- [ ] Install prompt appears on eligible browsers
- [ ] App launches from home screen
- [ ] Splash screen displays correctly

### Offline Functionality
- [ ] App shell loads offline
- [ ] Cached data displays offline
- [ ] Appropriate fallback for uncached content
- [ ] Network status indicator works
- [ ] Queued actions sync when online

### Service Worker
- [ ] SW registers successfully
- [ ] Updates apply correctly
- [ ] Caching strategies work as expected
- [ ] Push notifications received (if applicable)

### Performance
- [ ] First load under 3s on 3G
- [ ] Time to Interactive under 5s
- [ ] Lighthouse PWA score 90+
- [ ] No layout shift during load

## Local Testing Setup

### Development Server

```bash
# Vite preview mode (builds and serves production)
npm run build && npm run preview

# Service workers only work in production builds
# Use preview mode for SW testing
```

### Simulate Offline

1. Chrome DevTools > Network > Offline
2. Or: Application > Service Workers > Offline

### Simulate Slow Network

```bash
# In DevTools Network tab:
# - Slow 3G: 400ms latency, 400kb/s download
# - Fast 3G: 100ms latency, 1.5mb/s download
```

## Lighthouse Testing

### Command Line

```bash
# Install Lighthouse
npm install -g lighthouse

# Run PWA audit
lighthouse http://localhost:4173 --only-categories=pwa --output=json

# Full audit with report
lighthouse http://localhost:4173 --view
```

### CI Integration

```yaml
# GitHub Actions
name: PWA Audit
on: [push]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci && npm run build
      - run: npm run preview &
      - name: Lighthouse
        uses: treosh/lighthouse-ci-action@v11
        with:
          urls: http://localhost:4173
          uploadArtifacts: true
```

### Custom Lighthouse Config

```javascript
// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      staticDistDir: './dist',
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:pwa': ['error', { minScore: 0.9 }],
        'service-worker': 'error',
        'installable-manifest': 'error',
        'splash-screen': 'warn',
        'themed-omnibox': 'warn',
      },
    },
  },
};
```

## Service Worker Testing

### Unit Testing SW Logic

```typescript
// sw.test.ts
import { describe, it, expect, vi } from 'vitest';

// Mock service worker globals
const mockCache = new Map();
globalThis.caches = {
  open: vi.fn().mockResolvedValue({
    put: vi.fn((req, res) => mockCache.set(req.url, res)),
    match: vi.fn((req) => mockCache.get(req.url)),
  }),
  match: vi.fn(),
};

describe('Caching Logic', () => {
  it('should cache API responses', async () => {
    const request = new Request('https://api.example.com/data');
    const response = new Response(JSON.stringify({ data: 'test' }));

    const cache = await caches.open('api-cache');
    await cache.put(request, response);

    const cached = await cache.match(request);
    expect(cached).toBeDefined();
  });
});
```

### Integration Testing with Playwright

```typescript
// pwa.spec.ts
import { test, expect } from '@playwright/test';

test.describe('PWA Features', () => {
  test('should work offline after first visit', async ({ page, context }) => {
    // First visit - cache resources
    await page.goto('/');
    await page.waitForLoadState('networkidle');

    // Go offline
    await context.setOffline(true);

    // Reload - should work from cache
    await page.reload();
    await expect(page.locator('h1')).toBeVisible();
  });

  test('should show install prompt', async ({ page }) => {
    // Mock beforeinstallprompt
    await page.addInitScript(() => {
      window.addEventListener('load', () => {
        const event = new Event('beforeinstallprompt');
        (event as any).prompt = () => Promise.resolve();
        (event as any).userChoice = Promise.resolve({ outcome: 'accepted' });
        window.dispatchEvent(event);
      });
    });

    await page.goto('/');
    await expect(page.locator('[data-testid="install-button"]')).toBeVisible();
  });

  test('should register service worker', async ({ page }) => {
    await page.goto('/');

    const hasServiceWorker = await page.evaluate(async () => {
      const registration = await navigator.serviceWorker.getRegistration();
      return !!registration;
    });

    expect(hasServiceWorker).toBe(true);
  });
});
```

## Manual Testing Scenarios

### Install Flow

1. Visit app in Chrome/Edge
2. Look for install icon in address bar
3. Click install
4. Verify app opens in standalone window
5. Check home screen/dock icon

### Offline Flow

1. Visit app, browse several pages
2. Enable airplane mode / disconnect network
3. Refresh page - should load from cache
4. Navigate to cached pages
5. Try uncached pages - verify fallback

### Update Flow

1. Deploy new version
2. Visit app (old version loads)
3. Close and reopen (new version should load)
4. Or: interact with update prompt if shown

### Push Notification Flow

1. Enable notifications in app
2. Send test notification from backend
3. Verify notification appears
4. Click notification - verify app opens to correct page

## Browser Testing Matrix

| Feature | Chrome | Firefox | Safari | Edge |
|---------|--------|---------|--------|------|
| Service Worker | ✓ | ✓ | ✓ | ✓ |
| Push Notifications | ✓ | ✓ | ✓* | ✓ |
| Background Sync | ✓ | ✗ | ✗ | ✓ |
| Periodic Sync | ✓ | ✗ | ✗ | ✓ |
| Web Share | ✓ | ✗ | ✓ | ✓ |
| Install Prompt | ✓ | ✓** | ✗ | ✓ |

*Safari requires "Add to Home Screen"
**Firefox shows in address bar

## Debugging Tools

### Chrome DevTools

```
Application Tab:
├── Manifest - View parsed manifest
├── Service Workers - Control SW lifecycle
├── Cache Storage - Inspect cached resources
├── IndexedDB - View stored data
└── Clear storage - Reset for fresh testing
```

### Workbox DevTools

```typescript
// Enable Workbox logging in development
import { setLogLevel } from 'workbox-core';

if (process.env.NODE_ENV === 'development') {
  setLogLevel('debug');
}
```

### Network Inspection

```typescript
// Log all fetch events in SW
self.addEventListener('fetch', (event) => {
  console.log('[SW] Fetch:', event.request.url);
  // ... handle request
});
```

## Common Test Failures

### "Service Worker Not Found"

- Ensure running production build (`npm run preview`)
- Check SW is registered at root scope
- Verify HTTPS or localhost

### "Manifest Not Valid"

- Validate JSON syntax
- Check required fields present
- Verify icon paths resolve
- Test with audit-pwa.ts script

### "App Not Installable"

- Need valid manifest with icons
- Service worker must be registered
- Must be served over HTTPS
- User must interact with page first

### "Offline Not Working"

- Check precache includes needed assets
- Verify runtimeCaching patterns match
- Test with DevTools Application > Service Workers > Offline
- Check for console errors in SW

```

### assets/manifest-template.json

```json
{
  "$schema": "https://json.schemastore.org/web-manifest-combined.json",
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A fast, reliable, and engaging progressive web application",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "orientation": "any",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "icons": [
    {
      "src": "pwa-64x64.png",
      "sizes": "64x64",
      "type": "image/png"
    },
    {
      "src": "pwa-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "pwa-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "maskable-icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "screenshot-wide.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Desktop view of the application"
    },
    {
      "src": "screenshot-narrow.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "Mobile view of the application"
    }
  ],
  "shortcuts": [
    {
      "name": "New Item",
      "short_name": "New",
      "description": "Create a new item",
      "url": "/new",
      "icons": [
        {
          "src": "shortcut-new.png",
          "sizes": "192x192",
          "type": "image/png"
        }
      ]
    },
    {
      "name": "Settings",
      "short_name": "Settings",
      "description": "Open app settings",
      "url": "/settings",
      "icons": [
        {
          "src": "shortcut-settings.png",
          "sizes": "192x192",
          "type": "image/png"
        }
      ]
    }
  ],
  "categories": ["productivity", "utilities"],
  "dir": "ltr",
  "lang": "en-US",
  "prefer_related_applications": false,
  "related_applications": [],
  "handle_links": "preferred",
  "launch_handler": {
    "client_mode": "navigate-existing"
  },
  "edge_side_panel": {
    "preferred_width": 400
  }
}

```

### assets/sw-template.ts

```typescript
/**
 * Custom Service Worker Template for injectManifest mode
 *
 * Usage: Copy to src/sw.ts and configure vite-plugin-pwa with:
 *   strategies: 'injectManifest',
 *   srcDir: 'src',
 *   filename: 'sw.ts'
 */

import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

declare const self: ServiceWorkerGlobalScope;

// ============================================================
// PRECACHING
// ============================================================

// Clean up old caches from previous versions
cleanupOutdatedCaches();

// Precache all assets generated by Vite build
// __WB_MANIFEST is replaced by Workbox with the precache manifest
precacheAndRoute(self.__WB_MANIFEST);

// ============================================================
// NAVIGATION ROUTING
// ============================================================

// Handle navigation requests with network-first, falling back to cached shell
const navigationRoute = new NavigationRoute(
  new NetworkFirst({
    cacheName: 'navigation-cache',
    networkTimeoutSeconds: 3,
    plugins: [
      new CacheableResponsePlugin({
        statuses: [200],
      }),
    ],
  }),
  {
    // Don't handle API routes or static files
    denylist: [/^\/api\//, /\.[^/]+$/],
  }
);
registerRoute(navigationRoute);

// ============================================================
// RUNTIME CACHING
// ============================================================

// Google Fonts stylesheets
registerRoute(
  /^https:\/\/fonts\.googleapis\.com\/.*/i,
  new StaleWhileRevalidate({
    cacheName: 'google-fonts-stylesheets',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 10,
        maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
      }),
    ],
  })
);

// Google Fonts webfont files
registerRoute(
  /^https:\/\/fonts\.gstatic\.com\/.*/i,
  new CacheFirst({
    cacheName: 'google-fonts-webfonts',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 30,
        maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
      }),
    ],
  })
);

// Images
registerRoute(
  /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/i,
  new CacheFirst({
    cacheName: 'images-cache',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
      }),
    ],
  })
);

// API requests (customize the pattern for your API)
registerRoute(
  /^https:\/\/api\..*/i,
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 5,
    plugins: [
      new CacheableResponsePlugin({
        statuses: [200],
      }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 60 * 60 * 24, // 24 hours
      }),
    ],
  })
);

// ============================================================
// PUSH NOTIFICATIONS
// ============================================================

self.addEventListener('push', (event) => {
  if (!event.data) return;

  let data: {
    title: string;
    body?: string;
    icon?: string;
    badge?: string;
    url?: string;
    tag?: string;
    actions?: NotificationAction[];
  };

  try {
    data = event.data.json();
  } catch {
    data = { title: 'Notification', body: event.data.text() };
  }

  const options: NotificationOptions = {
    body: data.body,
    icon: data.icon || '/pwa-192x192.png',
    badge: data.badge || '/badge-72x72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/',
      timestamp: Date.now(),
    },
    actions: data.actions,
    tag: data.tag || 'default',
  };

  event.waitUntil(self.registration.showNotification(data.title, options));
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'dismiss') return;

  const url = event.notification.data?.url || '/';

  event.waitUntil(
    self.clients.matchAll({ type: 'window' }).then((clients) => {
      // Focus existing window if open
      for (const client of clients) {
        if (client.url.includes(url) && 'focus' in client) {
          return client.focus();
        }
      }
      // Open new window
      return self.clients.openWindow(url);
    })
  );
});

// ============================================================
// BACKGROUND SYNC
// ============================================================

self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-pending-requests') {
    event.waitUntil(syncPendingRequests());
  }
});

async function syncPendingRequests(): Promise<void> {
  // Implement your sync logic here
  // Example: Replay queued API requests from IndexedDB
  console.log('[SW] Syncing pending requests...');
}

// ============================================================
// SKIP WAITING (for immediate updates)
// ============================================================

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

// ============================================================
// INSTALL & ACTIVATE
// ============================================================

self.addEventListener('install', () => {
  console.log('[SW] Installing service worker...');
  // Skip waiting to activate immediately (optional)
  // self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  console.log('[SW] Activating service worker...');
  // Claim all clients immediately
  event.waitUntil(self.clients.claim());
});

```

react-pwa | SkillHub