一键导入
pwa-development
Progressive Web Apps - service workers, caching strategies, offline, Workbox
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Progressive Web Apps - service workers, caching strategies, offline, Workbox
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Create MDX documentation files with proper frontmatter, imports, and component integration. Apply when creating or updating Fumadocs documentation pages in content/docs/.
Integrate the @remcostoeten/auth-drawer React library (a sign-in/sign-up drawer or modal) into a consumer app. Use this whenever the user wants to add a login/signup/auth surface, an "auth drawer" or "auth modal", wrap their app in AuthProvider, render <AuthDrawer>, call useAuth(), pick or write an auth adapter (Better Auth, Supabase, Clerk, NextAuth, Firebase, custom JWT/REST, Passport, or createMockAdapter), or configure auth triggers/theming for that package. Use it even if the user only mentions the auth backend (e.g. "add a Supabase login drawer") and doesn't name the package, as long as @remcostoeten/auth-drawer is or will be the UI. Do NOT use it for unrelated drawer/sheet UI, for configuring an auth backend with no auth-drawer UI, or for other auth UI kits.
Ultra-compressed communication mode. Cuts output tokens 65% (measured) by speaking like caveman while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, wenyan-lite, wenyan-full, wenyan-ultra. Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
When the user wants to write, rewrite, or improve marketing copy for any page — including homepage, landing pages, pricing pages, feature pages, about pages, or product pages. Also use when the user says "write copy for," "improve this copy," "rewrite this page," "marketing copy," "headline help," "CTA copy," "value proposition," "tagline," "subheadline," "hero section copy," "above the fold," "this copy is weak," "make this more compelling," or "help me describe my product." Use this whenever someone is working on website text that needs to persuade or convert. For email copy, see emails. For popup copy, see popups. For editing existing copy, see copy-editing. For the offer underneath the copy (bonuses, guarantees, value framing), see offers.
Scaffold and implement authentication in TypeScript/JavaScript apps using Better Auth. Detect frameworks, configure database adapters, set up route handlers, add OAuth providers, and create auth UI pages. Use when users want to add login, sign-up, or authentication to a new or existing project with Better Auth.
Configure email verification, implement password reset flows, set password policies, and customise hashing algorithms for Better Auth email/password authentication. Use when users need to set up login, sign-in, sign-up, credential authentication, or password security with Better Auth.
| name | pwa-development |
| description | Progressive Web Apps - service workers, caching strategies, offline, Workbox |
| when-to-use | When building PWA features - service workers, caching, offline support |
| user-invocable | false |
| paths | ["**/sw.*","**/service-worker.*","**/workbox-config.*","**/manifest.json"] |
| effort | medium |
Purpose: Build Progressive Web Apps that work offline, install like native apps, and deliver fast, reliable experiences across all devices.
┌─────────────────────────────────────────────────────────────────┐
│ THE THREE PILLARS OF PWA │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 1. HTTPS │
│ Required for service workers and security. │
│ localhost allowed for development. │
│ │
│ 2. SERVICE WORKER │
│ JavaScript that runs in background. │
│ Enables offline, caching, push notifications. │
│ │
│ 3. WEB APP MANIFEST │
│ JSON file describing app metadata. │
│ Enables installation and app-like experience. │
├─────────────────────────────────────────────────────────────────┤
│ INSTALLABILITY CRITERIA (Chrome) │
│ ───────────────────────────────────────────────────────────── │
│ • HTTPS (or localhost) │
│ • Service worker with fetch handler │
│ • Web app manifest with: name, icons (192px + 512px), │
│ start_url, display: standalone/fullscreen/minimal-ui │
└─────────────────────────────────────────────────────────────────┘
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A description of what the app does",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
{
"name": "My Progressive Web App",
"short_name": "MyPWA",
"description": "A full-featured PWA",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#3367D6",
"dir": "ltr",
"lang": "en",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "/icons/icon-152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{
"src": "/icons/icon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
],
"shortcuts": [
{
"name": "New Item",
"short_name": "New",
"description": "Create a new item",
"url": "/new?source=shortcut",
"icons": [{ "src": "/icons/shortcut-new.png", "sizes": "192x192" }]
}
],
"share_target": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [{ "name": "files", "accept": ["image/*"] }]
}
},
"protocol_handlers": [
{
"protocol": "web+myapp",
"url": "/handle?url=%s"
}
],
"file_handlers": [
{
"action": "/open-file",
"accept": {
"text/plain": [".txt"]
}
}
]
}
name and short_name definedstart_url set (use query param for analytics)display set to standalone or fullscreentheme_color matches app designbackground_color for splash screen// sw.js
const CACHE_NAME = "app-cache-v1";
const STATIC_ASSETS = ["/", "/index.html", "/styles/main.css", "/scripts/app.js", "/offline.html"];
// Install: Cache static assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting()),
);
});
// Activate: Clean old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
// Fetch: Serve from cache, fall back to network
self.addEventListener("fetch", (event) => {
event.respondWith(
caches
.match(event.request)
.then((cached) => cached || fetch(event.request))
.catch(() => caches.match("/offline.html")),
);
});
// main.js
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
console.log("SW registered:", registration.scope);
} catch (error) {
console.error("SW registration failed:", error);
}
});
}
| Strategy | Use Case | Description |
|---|---|---|
| Cache First | Static assets (CSS, JS, images) | Check cache, fall back to network |
| Network First | API responses, dynamic content | Try network, fall back to cache |
| Stale While Revalidate | Semi-static content (avatars, articles) | Serve cache immediately, update in background |
| Network Only | Non-cacheable requests (analytics) | Always use network |
| Cache Only | Offline-only assets | Only serve from cache |
// Best for: Static assets that rarely change
self.addEventListener("fetch", (event) => {
if (
event.request.destination === "image" ||
event.request.destination === "style" ||
event.request.destination === "script"
) {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
});
}),
);
}
});
// Best for: API data, frequently updated content
self.addEventListener("fetch", (event) => {
if (event.request.url.includes("/api/")) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
return response;
})
.catch(() => caches.match(event.request)),
);
}
});
// Best for: Content that's okay to be slightly outdated
self.addEventListener("fetch", (event) => {
if (event.request.url.includes("/articles/")) {
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
}),
);
}
});
npm install workbox-webpack-plugin # Webpack
npm install @vite-pwa/vite-plugin # Vite
// vite.config.js
import { VitePWA } from "vite-plugin-pwa";
export default {
plugins: [
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.ico", "robots.txt", "apple-touch-icon.png"],
manifest: {
name: "My App",
short_name: "App",
theme_color: "#ffffff",
icons: [
{ src: "pwa-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "pwa-512x512.png", sizes: "512x512", type: "image/png" },
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
},
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: "CacheFirst",
options: {
cacheName: "image-cache",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
},
},
},
],
},
}),
],
};
// sw.js
import { precacheAndRoute } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
// Precache static assets (generated by build tool)
precacheAndRoute(self.__WB_MANIFEST);
// Cache images
registerRoute(
({ request }) => request.destination === "image",
new CacheFirst({
cacheName: "images",
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
}),
);
// Cache API responses
registerRoute(
({ url }) => url.pathname.startsWith("/api/"),
new NetworkFirst({
cacheName: "api-responses",
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 24 * 60 * 60, // 24 hours
}),
],
}),
);
// Cache page navigations
registerRoute(
({ request }) => request.mode === "navigate",
new NetworkFirst({
cacheName: "pages",
plugins: [new CacheableResponsePlugin({ statuses: [0, 200] })],
}),
);
<!-- offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offline - App Name</title>
<style>
body {
font-family: system-ui, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.offline-content {
text-align: center;
padding: 2rem;
}
.offline-icon {
font-size: 4rem;
}
h1 {
color: #333;
}
p {
color: #666;
}
button {
background: #3367d6;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
</style>
</head>
<body>
<div class="offline-content">
<div class="offline-icon">📡</div>
<h1>You're offline</h1>
<p>Check your connection and try again.</p>
<button onclick="location.reload()">Retry</button>
</div>
</body>
</html>
// Online/offline status handling
function updateOnlineStatus() {
const status = navigator.onLine ? "online" : "offline";
document.body.dataset.connectionStatus = status;
if (!navigator.onLine) {
showNotification("You are offline. Some features may be unavailable.");
}
}
window.addEventListener("online", updateOnlineStatus);
window.addEventListener("offline", updateOnlineStatus);
updateOnlineStatus();
// sw.js with Workbox
import { BackgroundSyncPlugin } from "workbox-background-sync";
import { registerRoute } from "workbox-routing";
import { NetworkOnly } from "workbox-strategies";
const bgSyncPlugin = new BackgroundSyncPlugin("formQueue", {
maxRetentionTime: 24 * 60, // Retry for 24 hours
});
registerRoute(
({ url }) => url.pathname === "/api/submit",
new NetworkOnly({
plugins: [bgSyncPlugin],
}),
"POST",
);
// main.js - Queue form submission
async function submitForm(data) {
try {
const response = await fetch("/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return response.json();
} catch (error) {
// Will be retried by background sync when online
showNotification("Saved offline. Will sync when connected.");
}
}
let deferredPrompt;
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallButton();
});
async function installApp() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User ${outcome === "accepted" ? "accepted" : "dismissed"} install`);
deferredPrompt = null;
hideInstallButton();
}
window.addEventListener("appinstalled", () => {
console.log("App installed");
deferredPrompt = null;
});
// Check if running as installed PWA
function isInstalledPWA() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.navigator.standalone === true
); // iOS
}
// Listen for display mode changes
window.matchMedia("(display-mode: standalone)").addEventListener("change", (e) => {
console.log("Display mode:", e.matches ? "standalone" : "browser");
});
// Request permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === "granted") {
await subscribeToPush();
}
return permission;
}
// Subscribe to push
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Send subscription to server
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
}
// sw.js - Handle push events
self.addEventListener("push", (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: "/icons/icon-192.png",
badge: "/icons/badge-72.png",
data: { url: data.url },
}),
);
});
// Handle notification click
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
// sw.js - Handle share target
self.addEventListener("fetch", (event) => {
if (event.request.url.endsWith("/share") && event.request.method === "POST") {
event.respondWith(
(async () => {
const formData = await event.request.formData();
const title = formData.get("title");
const text = formData.get("text");
const url = formData.get("url");
// Store or process shared content
// Redirect to app with shared data
return Response.redirect(`/?shared=true&title=${encodeURIComponent(title)}`);
})(),
);
}
});
<!-- Inline critical CSS -->
<style>
/* Critical above-the-fold styles */
</style>
<!-- Preload important resources -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/scripts/app.js" as="script" />
<!-- Defer non-critical CSS -->
<link rel="stylesheet" href="/styles/main.css" media="print" onload="this.media='all'" />
<noscript><link rel="stylesheet" href="/styles/main.css" /></noscript>
<!-- Responsive images -->
<img
src="/images/hero-800.webp"
srcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w, /images/hero-1200.webp 1200w"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
alt="Hero image"
loading="lazy"
decoding="async"
/>
<!-- Modern formats with fallback -->
<picture>
<source srcset="/images/hero.avif" type="image/avif" />
<source srcset="/images/hero.webp" type="image/webp" />
<img src="/images/hero.jpg" alt="Hero image" loading="lazy" />
</picture>
// Dynamic imports for route-based splitting
const routes = {
"/": () => import("./pages/Home.js"),
"/about": () => import("./pages/About.js"),
"/settings": () => import("./pages/Settings.js"),
};
async function loadPage(path) {
const loader = routes[path];
if (loader) {
const module = await loader();
return module.default;
}
}
# Run Lighthouse from CLI
npx lighthouse https://your-app.com --view
# Key metrics to check:
# - PWA badge (installable, offline-ready)
# - Performance score
# - Best practices
# - Accessibility
Installability
Offline Support
Performance
Service Worker
Manifest
// Force update check
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.update();
});
}
// Listen for updates
navigator.serviceWorker.addEventListener("controllerchange", () => {
// New service worker activated
window.location.reload();
});
project/
├── public/
│ ├── manifest.json # Web app manifest
│ ├── sw.js # Service worker (if not bundled)
│ ├── offline.html # Offline fallback page
│ ├── robots.txt
│ └── icons/
│ ├── icon-72.png
│ ├── icon-96.png
│ ├── icon-128.png
│ ├── icon-144.png
│ ├── icon-152.png
│ ├── icon-192.png
│ ├── icon-384.png
│ ├── icon-512.png
│ ├── icon-maskable.png # For adaptive icons
│ ├── apple-touch-icon.png
│ └── favicon.ico
├── src/
│ ├── sw.js # Service worker source (if bundled)
│ ├── pwa/
│ │ ├── install.js # Install prompt handling
│ │ ├── offline.js # Offline detection
│ │ └── push.js # Push notification handling
│ └── ...
└── tests/
└── pwa/
├── manifest.test.js
├── sw.test.js
└── offline.test.js
| Mistake | Fix |
|---|---|
| Missing maskable icon | Add icon with "purpose": "maskable" |
| No offline fallback | Create offline.html and cache it |
| Cache never expires | Use ExpirationPlugin with Workbox |
| SW caches too aggressively | Use appropriate strategies per resource type |
| No update mechanism | Implement skipWaiting() + reload prompt |
| Broken install prompt | Ensure manifest meets all criteria |
| No HTTPS in production | Configure SSL certificate |
| Large cache size | Set maxEntries and maxAgeSeconds |
| Stale API responses | Use NetworkFirst for dynamic data |
| Missing start_url tracking | Add query param: /?source=pwa |
npm install next-pwa
// next.config.js
const withPWA = require("next-pwa")({
dest: "public",
disable: process.env.NODE_ENV === "development",
});
module.exports = withPWA({
// Your Next.js config
});
# CRA 4+ has PWA support built-in
npx create-react-app my-pwa --template cra-template-pwa
npm install vite-plugin-pwa -D
See Workbox with Vite section above for configuration.
Static Assets (CSS, JS, images) → Cache First
API Responses → Network First
User-generated content → Stale While Revalidate
Analytics, non-cacheable → Network Only
Offline-only assets → Cache Only
{
"name": "App Name",
"short_name": "App",
"start_url": "/",
"display": "standalone",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
1. Register → 2. Install → 3. Activate → 4. Fetch
↓ ↓ ↓ ↓
Load app Cache assets Clean old Serve requests
caches from cache/network