
Pwa Development
- 408 installs
- 133 repo stars
- Updated February 24, 2026
- jwynia/agent-skills
pwa-development is a jwynia agent skill that ships installable, offline-capable progressive web apps with service workers, manifests, and app-like navigation for developers building SaaS or mobile-first web products with
About
pwa-development is a jwynia/agent-skills workflow for turning web apps into installable progressive web applications. The skill guides service worker setup, web app manifest configuration, responsive layouts, and navigation patterns that feel native on phones and desktops. Developers reach for pwa-development when a SaaS or mobile-first product needs offline resilience, home-screen installation, and app-shell UX without publishing to iOS or Android stores. It focuses on production PWA mechanics—caching strategies, manifest fields, and install prompts—rather than generic responsive CSS alone.
- service worker caching
- web app manifest setup
- install and offline UX
- responsive app-shell patterns
- push and background sync basics
Pwa Development by the numbers
- 408 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #659 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/agent-skills --skill pwa-developmentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 408 |
|---|---|
| repo stars | ★ 133 |
| Last updated | February 24, 2026 |
| Repository | jwynia/agent-skills ↗ |
How do you build an installable offline-capable PWA?
Ship installable, offline-capable web apps with service workers, manifests, responsive UI, and app-like navigation for SaaS or mobile-first products without native stores.
Who is it for?
Frontend developers shipping SaaS or mobile-first web apps who need install prompts and offline support without native store distribution.
Skip if: Teams needing native iOS or Android binaries should use mobile platform skills instead of browser-only PWA patterns.
When should I use this skill?
A web app needs service workers, manifest, offline caching, and installable app-shell navigation for production users.
What you get
Service worker files, web app manifest, responsive UI, and app-shell navigation ready for install and offline use.
- Service worker implementation
- Web app manifest JSON
- App-shell navigation and responsive UI
Files
PWA Development
Implement Progressive Web App features including service workers, caching strategies, offline support, and installation prompts for React and Svelte applications.
When to Use This Skill
Use this skill when:
- Adding PWA capabilities to a web app
- Implementing offline support
- Creating service worker caching strategies
- Debugging PWA installation issues
- Handling iOS-specific PWA quirks
Do NOT use this skill when:
- Building backend APIs
- Working on requirements/design (use those skills first)
- Need complex offline-first architecture (design first)
Core Principle
PWAs fail when offline behavior is an afterthought. A PWA is not "add service worker to existing app." It's a fundamental architectural decision about data flow, caching, and connectivity failure.
Diagnostic States
P0: No PWA Setup
Symptoms: No manifest.json, no service worker, online-only
Interventions:
- Run
scripts/manifest-generator.tsto create manifest - Add
<link rel="manifest">to HTML head - Generate minimal SW with
scripts/sw-scaffolder.ts
P1: Basic Manifest Only
Symptoms: Manifest exists but SW missing, breaks offline
Key Questions:
- What content MUST be available offline?
- What should always be fresh (network-first)?
Interventions:
- Use
scripts/cache-strategy-advisor.ts - Implement app shell pattern
- Add offline fallback page
P2: Caching Issues
Symptoms: Stale content, unexpected caching behavior
Interventions:
- Audit with
scripts/pwa-audit.ts - Map resources to strategies using
data/caching-strategies.json - Add cache expiration and cleanup
P3: Update Problems
Symptoms: Users stuck on old versions, multiple refreshes needed
Interventions:
- Implement skipWaiting/clients.claim appropriately
- Add update notification UI (
assets/update-prompt.tsx) - Handle "waiting" state properly
P4: Offline Data Gaps
Symptoms: User actions lost offline, no sync indicator
Interventions:
- Implement IndexedDB for offline storage
- Add Background Sync API
- Create sync status UI
P5: iOS Issues
Symptoms: Works on Android, breaks on iOS
Interventions:
- Review
data/ios-quirks.json - Add apple-mobile-web-app meta tags
- Handle storage eviction gracefully
P6: Production Ready
Indicators: Lighthouse PWA 100, works offline, updates cleanly
Caching Strategies
| Strategy | Use For | Behavior |
|---|---|---|
| Cache First | Static assets, fonts | Serve from cache, update in background |
| Network First | API data, user content | Try network, fall back to cache |
| Stale While Revalidate | Semi-static content | Serve stale, update cache for next time |
| Network Only | Auth, real-time data | Always network, no caching |
Available Scripts
| Script | Purpose |
|---|---|
manifest-generator.ts | Generate manifest.json |
sw-scaffolder.ts | Generate service worker |
cache-strategy-advisor.ts | Recommend caching strategies |
pwa-audit.ts | Validate PWA configuration |
Anti-Patterns
The Everything Cache
Precaching every asset - massive initial download. Fix: Precache only critical app shell. Runtime cache content.
The Immortal Cache
Never expiring caches - stale content forever. Fix: Cache versioning, delete old on activate, set max age.
The Silent Update
Forcing updates without notification. Fix: Notify user, let them choose when to refresh.
The iOS Afterthought
Building for Chrome, testing iOS last. Fix: Test iOS early. Accept iOS limitations.
Framework Quick Reference
React + Vite
npm i -D vite-plugin-pwaSvelteKit
npm i -D @vite-pwa/sveltekitNext.js
npm i next-pwaSee data/framework-patterns.json for configuration.
Debugging Checklist
1. DevTools > Application > Manifest - Valid? 2. DevTools > Application > Service Workers - Registered? 3. DevTools > Application > Cache Storage - What's cached? 4. DevTools > Network > Offline - Works offline? 5. Lighthouse > PWA - Score and failures? 6. iOS Safari - Test on actual device
Related Skills
- requirements-analysis - Determine offline requirements
- system-design - PWA architecture decisions
- react-pwa - React-specific PWA implementation
{
// === REQUIRED FIELDS ===
// Full application name (max 45 chars)
// Used in: install prompt, app launcher, splash screen
"name": "Your Application Name",
// Short name for home screen (max 12 chars)
// Used in: home screen icon label
"short_name": "App",
// URL to open when app launches
// Should be within scope
// Tip: Add query param for analytics (e.g., "/?source=pwa")
"start_url": "/",
// Display mode:
// - "fullscreen": No browser UI, fills entire screen
// - "standalone": Native app look, minimal browser UI (recommended)
// - "minimal-ui": Some browser UI visible
// - "browser": Normal browser tab
"display": "standalone",
// Icons - minimum 192x192 and 512x512 required
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
// Maskable icon for adaptive icons on Android
// Design with safe zone: 80% centered circle
"src": "/icons/icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
// === RECOMMENDED FIELDS ===
// Background color for splash screen
// Should match your app's initial background
"background_color": "#ffffff",
// Theme color for browser UI (title bar, task switcher)
// Also add as <meta name="theme-color"> in HTML
"theme_color": "#4285f4",
// Application description (max 300 chars)
// Used in: app stores, search results, install UI
"description": "A brief description of what your app does.",
// Navigation scope - URLs outside this open in browser
// Default: parent directory of start_url
"scope": "/",
// === OPTIONAL FIELDS ===
// Default orientation
// Options: "any", "natural", "portrait", "portrait-primary",
// "portrait-secondary", "landscape", "landscape-primary",
// "landscape-secondary"
"orientation": "any",
// App categories (for app stores)
// "categories": ["productivity", "utilities"],
// Screenshots for richer install experience
// "screenshots": [
// {
// "src": "/screenshots/mobile-home.png",
// "sizes": "1080x1920",
// "type": "image/png",
// "form_factor": "narrow",
// "label": "Home screen on mobile"
// },
// {
// "src": "/screenshots/desktop-home.png",
// "sizes": "1920x1080",
// "type": "image/png",
// "form_factor": "wide",
// "label": "Home screen on desktop"
// }
// ],
// App shortcuts (right-click/long-press menu)
// "shortcuts": [
// {
// "name": "New Task",
// "short_name": "New",
// "description": "Create a new task",
// "url": "/tasks/new",
// "icons": [
// {
// "src": "/icons/shortcut-new-task.png",
// "sizes": "96x96"
// }
// ]
// }
// ],
// Related native apps
// "related_applications": [
// {
// "platform": "play",
// "url": "https://play.google.com/store/apps/details?id=com.example.app",
// "id": "com.example.app"
// }
// ],
// "prefer_related_applications": false,
// === ADVANCED FEATURES ===
// Share Target - receive shared content
// "share_target": {
// "action": "/share",
// "method": "POST",
// "enctype": "multipart/form-data",
// "params": {
// "title": "title",
// "text": "text",
// "url": "url"
// }
// },
// Protocol handlers - handle custom URL schemes
// "protocol_handlers": [
// {
// "protocol": "web+myapp",
// "url": "/open?url=%s"
// }
// ],
// File handlers - open specific file types
// "file_handlers": [
// {
// "action": "/open-file",
// "accept": {
// "text/plain": [".txt"],
// "application/json": [".json"]
// }
// }
// ],
// Language and text direction
"lang": "en-US",
"dir": "ltr"
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#4285f4">
<title>Offline - App Name</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-align: center;
padding: 20px;
}
.container {
max-width: 400px;
}
.icon {
width: 120px;
height: 120px;
margin-bottom: 24px;
}
.icon svg {
width: 100%;
height: 100%;
}
h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 16px;
}
p {
font-size: 16px;
line-height: 1.5;
opacity: 0.9;
margin-bottom: 32px;
}
.retry-button {
display: inline-flex;
align-items: center;
gap: 8px;
background: white;
color: #667eea;
border: none;
padding: 12px 32px;
font-size: 16px;
font-weight: 600;
border-radius: 50px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.retry-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.retry-button:active {
transform: translateY(0);
}
.retry-button svg {
width: 20px;
height: 20px;
}
.status {
margin-top: 24px;
font-size: 14px;
opacity: 0.7;
}
.status.online {
color: #4ade80;
opacity: 1;
}
/* Animation for the cloud icon */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.icon {
animation: float 3s ease-in-out infinite;
}
/* Loading spinner for retry button */
.retry-button.loading svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<!-- Offline Cloud Icon -->
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 15a4 4 0 0 0 4 4h9a5 5 0 0 0 0-10 5 5 0 0 0-9.9-.5A4 4 0 0 0 3 15z" />
<line x1="1" y1="1" x2="23" y2="23" stroke-width="2" />
</svg>
</div>
<h1>You're Offline</h1>
<p>
It looks like you've lost your internet connection.
Don't worry - some features may still work offline.
</p>
<button class="retry-button" onclick="retry()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
Try Again
</button>
<p class="status" id="status">Waiting for connection...</p>
</div>
<script>
// Update status when online/offline
function updateStatus() {
const statusEl = document.getElementById('status');
if (navigator.onLine) {
statusEl.textContent = 'Connection restored! Reloading...';
statusEl.classList.add('online');
setTimeout(() => window.location.reload(), 1000);
} else {
statusEl.textContent = 'Still offline...';
statusEl.classList.remove('online');
}
}
// Listen for online/offline events
window.addEventListener('online', updateStatus);
window.addEventListener('offline', updateStatus);
// Retry button handler
function retry() {
const button = document.querySelector('.retry-button');
button.classList.add('loading');
button.disabled = true;
// Check if we're online
fetch(window.location.href, { method: 'HEAD', cache: 'no-store' })
.then(() => {
window.location.reload();
})
.catch(() => {
button.classList.remove('loading');
button.disabled = false;
document.getElementById('status').textContent = 'Still no connection. Try again later.';
});
}
// Initial status check
if (navigator.onLine) {
// We're online but got this page - might be a caching issue
document.getElementById('status').textContent = 'Checking connection...';
retry();
}
</script>
</body>
</html>
/**
* Service Worker with Workbox
*
* A production-ready service worker using Workbox strategies.
* Customize the caching strategies based on your app's needs.
*
* Usage with Vite PWA Plugin:
* This file is auto-generated. Configure in vite.config.ts
*
* Usage Standalone:
* 1. Install Workbox: npm i workbox-precaching workbox-routing workbox-strategies workbox-expiration workbox-cacheable-response
* 2. Build with bundler that handles workbox imports
* 3. Register in your app entry point
*/
import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
declare let self: ServiceWorkerGlobalScope;
// === PRECACHING ===
// Clean up old precaches from previous versions
cleanupOutdatedCaches();
// Precache static assets (populated by build tool)
// The __WB_MANIFEST placeholder is replaced during build
precacheAndRoute(self.__WB_MANIFEST || []);
// === APP SHELL / NAVIGATION ===
// For SPA: return index.html for all navigation requests
// Uncomment if using SPA routing:
// const handler = createHandlerBoundToURL('/index.html');
// const navigationRoute = new NavigationRoute(handler, {
// // Exclude specific paths from SPA handling
// denylist: [/^\/api\//, /^\/auth\//],
// });
// registerRoute(navigationRoute);
// === CACHING STRATEGIES ===
// Static Assets (cache-first)
// For versioned assets with hash in filename
registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font',
new CacheFirst({
cacheName: 'static-assets',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
}),
],
})
);
// Images (cache-first with size limit)
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
}),
],
})
);
// API Calls (network-first)
// Fresh data when online, cached data when offline
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 3, // Fall back to cache after 3s
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
}),
],
})
);
// HTML Pages (stale-while-revalidate)
// Fast load from cache, update in background
registerRoute(
({ request }) => request.destination === 'document',
new StaleWhileRevalidate({
cacheName: 'pages',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
}),
],
})
);
// Third-party resources (stale-while-revalidate)
// CDN assets, analytics, etc.
registerRoute(
({ url }) => url.origin !== self.location.origin,
new StaleWhileRevalidate({
cacheName: 'third-party',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
}),
],
})
);
// === OFFLINE FALLBACK ===
import { setCatchHandler } from 'workbox-routing';
import { matchPrecache } from 'workbox-precaching';
// Fallback for failed requests
setCatchHandler(async ({ request }) => {
// For navigation requests, show offline page
if (request.destination === 'document') {
return matchPrecache('/offline.html') || Response.error();
}
// For images, could return placeholder
// if (request.destination === 'image') {
// return matchPrecache('/images/offline-placeholder.png');
// }
return Response.error();
});
// === UPDATE HANDLING ===
// Skip waiting - activate new SW immediately
// Only use if you handle update UI in your app
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Claim clients on activation
// Makes SW take control of all pages immediately
self.addEventListener('activate', () => {
self.clients.claim();
});
// === BACKGROUND SYNC (optional) ===
// Uncomment to enable background sync for failed POST requests
// import { BackgroundSyncPlugin } from 'workbox-background-sync';
//
// const bgSyncPlugin = new BackgroundSyncPlugin('failedRequestsQueue', {
// maxRetentionTime: 24 * 60, // 24 hours in minutes
// });
//
// registerRoute(
// ({ url }) => url.pathname.startsWith('/api/') && request.method === 'POST',
// new NetworkOnly({
// plugins: [bgSyncPlugin],
// }),
// 'POST'
// );
// === PUSH NOTIFICATIONS (optional) ===
// Uncomment to handle push notifications
// self.addEventListener('push', (event) => {
// const data = event.data?.json() ?? {};
// const title = data.title || 'Notification';
// const options = {
// body: data.body,
// icon: '/icons/icon-192x192.png',
// badge: '/icons/badge-72x72.png',
// data: data.url,
// };
// event.waitUntil(self.registration.showNotification(title, options));
// });
//
// self.addEventListener('notificationclick', (event) => {
// event.notification.close();
// if (event.notification.data) {
// event.waitUntil(clients.openWindow(event.notification.data));
// }
// });
<!--
Svelte PWA Update Prompt Component
Shows a notification when a new version of the app is available.
Works with @vite-pwa/sveltekit's virtual:pwa-register/svelte module.
Usage:
1. Install @vite-pwa/sveltekit: npm i -D @vite-pwa/sveltekit
2. Configure registerType: 'prompt' in vite.config.ts
3. Add <ReloadPrompt /> to your +layout.svelte
Customization:
- Modify styles to match your app's design system
- Adjust positioning with the .pwa-toast class
- Add transitions as needed
-->
<script lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/svelte';
import { fly } from 'svelte/transition';
const {
needRefresh,
offlineReady,
updateServiceWorker,
} = useRegisterSW({
onRegistered(registration) {
console.log('SW registered:', registration);
// Optional: Check for updates periodically
// setInterval(() => {
// registration?.update();
// }, 60 * 60 * 1000); // Check every hour
},
onRegisterError(error) {
console.error('SW registration error:', error);
},
});
let isUpdating = false;
async function handleUpdate() {
isUpdating = true;
try {
await updateServiceWorker(true);
} catch (error) {
console.error('Update failed:', error);
isUpdating = false;
}
}
function handleDismiss() {
offlineReady.set(false);
needRefresh.set(false);
}
</script>
{#if $offlineReady || $needRefresh}
<div
class="pwa-toast"
role="alert"
aria-live="polite"
transition:fly={{ x: 100, duration: 300 }}
>
<div class="content">
{#if $offlineReady}
<div class="title">Ready to work offline</div>
<div class="message">
App has been cached for offline use.
</div>
{:else}
<div class="title">Update available</div>
<div class="message">
A new version is ready. Reload to update.
</div>
{/if}
</div>
<div class="buttons">
{#if $needRefresh}
<button
class="update-button"
on:click={handleUpdate}
disabled={isUpdating}
>
{isUpdating ? 'Updating...' : 'Update'}
</button>
{/if}
<button
class="dismiss-button"
on:click={handleDismiss}
>
Dismiss
</button>
</div>
</div>
{/if}
<style>
.pwa-toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 16px 20px;
background-color: #1f2937;
color: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
gap: 16px;
z-index: 9999;
max-width: 400px;
}
.content {
flex: 1;
}
.title {
font-weight: 600;
margin-bottom: 4px;
font-size: 14px;
}
.message {
font-size: 13px;
opacity: 0.8;
line-height: 1.4;
}
.buttons {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.update-button {
background-color: #3b82f6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
font-size: 13px;
transition: background-color 0.2s;
}
.update-button:hover:not(:disabled) {
background-color: #2563eb;
}
.update-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.dismiss-button {
background-color: transparent;
color: #9ca3af;
border: 1px solid #374151;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
font-size: 13px;
transition: background-color 0.2s, border-color 0.2s;
}
.dismiss-button:hover {
background-color: #374151;
border-color: #4b5563;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.pwa-toast {
left: 20px;
right: 20px;
bottom: 10px;
flex-direction: column;
text-align: center;
}
.buttons {
width: 100%;
justify-content: center;
}
}
</style>
<!--
TypeScript declaration for virtual module
Add this to src/app.d.ts or a .d.ts file:
declare module 'virtual:pwa-register/svelte' {
import type { Writable } from 'svelte/store';
export interface RegisterSWOptions {
immediate?: boolean;
onNeedRefresh?: () => void;
onOfflineReady?: () => void;
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void;
onRegisterError?: (error: Error) => void;
}
export function useRegisterSW(options?: RegisterSWOptions): {
needRefresh: Writable<boolean>;
offlineReady: Writable<boolean>;
updateServiceWorker: (reloadPage?: boolean) => Promise<void>;
};
}
-->
/**
* React PWA Update Prompt Component
*
* Shows a notification when a new version of the app is available.
* Works with vite-plugin-pwa's virtual:pwa-register/react module.
*
* Usage:
* 1. Install vite-plugin-pwa: npm i -D vite-plugin-pwa
* 2. Configure registerType: 'prompt' in vite.config.ts
* 3. Add <ReloadPrompt /> to your App component
*
* Customization:
* - Modify styles to match your app's design system
* - Adjust positioning with the .pwa-toast class
* - Add animations as needed
*/
import { useRegisterSW } from 'virtual:pwa-register/react';
import { useState, useEffect } from 'react';
// Styles - customize to match your design system
const styles = {
toast: {
position: 'fixed' as const,
bottom: '20px',
right: '20px',
padding: '16px 20px',
backgroundColor: '#1f2937',
color: 'white',
borderRadius: '12px',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '16px',
zIndex: 9999,
maxWidth: '400px',
animation: 'slideIn 0.3s ease-out',
},
content: {
flex: 1,
},
title: {
fontWeight: 600,
marginBottom: '4px',
fontSize: '14px',
},
message: {
fontSize: '13px',
opacity: 0.8,
lineHeight: 1.4,
},
buttons: {
display: 'flex',
gap: '8px',
flexShrink: 0,
},
updateButton: {
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
padding: '8px 16px',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 500,
fontSize: '13px',
transition: 'background-color 0.2s',
},
dismissButton: {
backgroundColor: 'transparent',
color: '#9ca3af',
border: '1px solid #374151',
padding: '8px 16px',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 500,
fontSize: '13px',
transition: 'background-color 0.2s, border-color 0.2s',
},
};
// CSS animation (add to your global styles or inject)
const keyframes = `
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`;
export function ReloadPrompt() {
const {
offlineReady: [offlineReady, setOfflineReady],
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(registration) {
console.log('SW registered:', registration);
// Optional: Check for updates periodically
// setInterval(() => {
// registration?.update();
// }, 60 * 60 * 1000); // Check every hour
},
onRegisterError(error) {
console.error('SW registration error:', error);
},
});
const [isUpdating, setIsUpdating] = useState(false);
// Inject keyframes on mount
useEffect(() => {
const style = document.createElement('style');
style.textContent = keyframes;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
const handleUpdate = async () => {
setIsUpdating(true);
try {
await updateServiceWorker(true);
} catch (error) {
console.error('Update failed:', error);
setIsUpdating(false);
}
};
const handleDismiss = () => {
setOfflineReady(false);
setNeedRefresh(false);
};
// Show nothing if no updates and not offline-ready
if (!offlineReady && !needRefresh) {
return null;
}
return (
<div style={styles.toast} role="alert" aria-live="polite">
<div style={styles.content}>
{offlineReady ? (
<>
<div style={styles.title}>Ready to work offline</div>
<div style={styles.message}>
App has been cached for offline use.
</div>
</>
) : (
<>
<div style={styles.title}>Update available</div>
<div style={styles.message}>
A new version is ready. Reload to update.
</div>
</>
)}
</div>
<div style={styles.buttons}>
{needRefresh && (
<button
style={styles.updateButton}
onClick={handleUpdate}
disabled={isUpdating}
>
{isUpdating ? 'Updating...' : 'Update'}
</button>
)}
<button
style={styles.dismissButton}
onClick={handleDismiss}
>
Dismiss
</button>
</div>
</div>
);
}
// Alternative: Minimal version with just the hook
export function useUpdatePrompt() {
const {
offlineReady: [offlineReady],
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW();
return {
offlineReady,
needRefresh,
update: () => updateServiceWorker(true),
};
}
// TypeScript module declaration for virtual module
declare module 'virtual:pwa-register/react' {
import type { Dispatch, SetStateAction } from 'react';
export interface RegisterSWOptions {
immediate?: boolean;
onNeedRefresh?: () => void;
onOfflineReady?: () => void;
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void;
onRegisterError?: (error: Error) => void;
}
export function useRegisterSW(options?: RegisterSWOptions): {
needRefresh: [boolean, Dispatch<SetStateAction<boolean>>];
offlineReady: [boolean, Dispatch<SetStateAction<boolean>>];
updateServiceWorker: (reloadPage?: boolean) => Promise<void>;
};
}
export default ReloadPrompt;
{
"_meta": {
"description": "Caching strategy decision matrix for PWA development",
"usage": "Match resource type to recommended strategy based on freshness vs speed trade-offs",
"source": "pwa-development skill"
},
"strategies": {
"cache-first": {
"description": "Check cache first, fall back to network",
"workbox_class": "CacheFirst",
"best_for": [
"Static assets with versioned URLs",
"Fonts (rarely change)",
"Images (content images, icons)",
"App shell HTML (if using app shell pattern)",
"Third-party libraries with version in URL"
],
"avoid_for": [
"API responses",
"Frequently updated content",
"User-specific data",
"Opaque responses (CORS without proper headers)"
],
"trade_offs": {
"speed": "fastest",
"freshness": "may serve stale",
"offline": "works offline if cached"
},
"typical_config": {
"maxAgeSeconds": 31536000,
"maxEntries": 100
}
},
"network-first": {
"description": "Try network first, fall back to cache",
"workbox_class": "NetworkFirst",
"best_for": [
"API calls",
"Frequently updated content",
"HTML pages (if not app shell)",
"User-specific data",
"Authenticated content"
],
"avoid_for": [
"Large static assets (slow repeated downloads)",
"Resources needed for offline-critical paths"
],
"trade_offs": {
"speed": "slower (network latency)",
"freshness": "always fresh when online",
"offline": "works offline with cached fallback"
},
"typical_config": {
"networkTimeoutSeconds": 3,
"maxAgeSeconds": 86400,
"maxEntries": 50
}
},
"stale-while-revalidate": {
"description": "Return cache immediately, update in background",
"workbox_class": "StaleWhileRevalidate",
"best_for": [
"Semi-static content (articles, product listings)",
"User avatars",
"CSS/JS bundles without versioned URLs",
"Content where eventual consistency is acceptable"
],
"avoid_for": [
"Real-time data (stock prices, live scores)",
"Checkout/payment flows",
"Critical user settings",
"Opaque responses"
],
"trade_offs": {
"speed": "fast (serves cache)",
"freshness": "eventually fresh (next load)",
"offline": "works offline if cached"
},
"typical_config": {
"maxAgeSeconds": 86400,
"maxEntries": 50
}
},
"network-only": {
"description": "Always fetch from network, no caching",
"workbox_class": "NetworkOnly",
"best_for": [
"Analytics endpoints",
"Authentication/logout endpoints",
"Real-time APIs (WebSocket fallback)",
"POST/PUT/DELETE requests"
],
"avoid_for": [
"Anything needed for offline functionality"
],
"trade_offs": {
"speed": "network dependent",
"freshness": "always fresh",
"offline": "fails offline"
},
"typical_config": {}
},
"cache-only": {
"description": "Only serve from cache, never network",
"workbox_class": "CacheOnly",
"best_for": [
"Precached assets that never change",
"Versioned bundles (hash in filename)",
"Offline fallback pages"
],
"avoid_for": [
"Anything that might need updating",
"Dynamic content"
],
"trade_offs": {
"speed": "fastest",
"freshness": "never updates",
"offline": "works offline if precached"
},
"typical_config": {}
}
},
"resource_type_recommendations": {
"html_pages": {
"app_like": "cache-first",
"content_heavy": "network-first",
"data_intensive": "cache-first",
"hybrid": "stale-while-revalidate"
},
"css_bundles": {
"versioned_url": "cache-first",
"unversioned_url": "stale-while-revalidate"
},
"js_bundles": {
"versioned_url": "cache-first",
"unversioned_url": "stale-while-revalidate"
},
"images": {
"static": "cache-first",
"user_uploaded": "cache-first",
"frequently_updated": "stale-while-revalidate"
},
"fonts": {
"default": "cache-first"
},
"api_get": {
"default": "network-first",
"cached_for_offline": "network-first"
},
"api_post": {
"default": "network-only"
},
"third_party": {
"default": "stale-while-revalidate"
}
},
"decision_tree": {
"questions": [
{
"question": "Does the resource have a versioned URL (hash in filename)?",
"yes": "cache-first",
"no": "continue"
},
{
"question": "Is the resource an API call?",
"yes": "network-first",
"no": "continue"
},
{
"question": "Does the content change frequently?",
"yes": "network-first or stale-while-revalidate",
"no": "continue"
},
{
"question": "Is eventual consistency acceptable?",
"yes": "stale-while-revalidate",
"no": "network-first"
}
]
}
}
{
"_meta": {
"description": "Framework-specific PWA implementation patterns",
"frameworks": ["vite", "sveltekit", "nextjs", "create-react-app"],
"source": "pwa-development skill"
},
"vite": {
"plugin": "vite-plugin-pwa",
"install": "npm i -D vite-plugin-pwa",
"version": "^0.20.0",
"config_location": "vite.config.ts",
"documentation": "https://vite-pwa-org.netlify.app/",
"dev_mode_note": "Service worker disabled in dev by default. Use devOptions: { enabled: true } to test.",
"basic_config": {
"description": "Minimal configuration for Vite PWA plugin",
"code": "import { VitePWA } from 'vite-plugin-pwa';\n\nexport default defineConfig({\n plugins: [\n VitePWA({\n registerType: 'prompt',\n manifest: {\n name: 'My App',\n short_name: 'App',\n theme_color: '#ffffff',\n icons: [\n { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },\n { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }\n ]\n }\n })\n ]\n});"
},
"with_prompt_config": {
"description": "Configuration with update prompt UI",
"code": "import { VitePWA } from 'vite-plugin-pwa';\n\nexport default defineConfig({\n plugins: [\n VitePWA({\n registerType: 'prompt',\n includeAssets: ['favicon.ico', 'apple-touch-icon.png'],\n manifest: { /* ... */ },\n workbox: {\n globPatterns: ['**/*.{js,css,html,ico,png,svg}']\n }\n })\n ]\n});"
},
"auto_update_config": {
"description": "Configuration for automatic silent updates",
"code": "VitePWA({\n registerType: 'autoUpdate',\n // ... rest of config\n})"
},
"runtime_caching": {
"description": "Add runtime caching for API calls",
"code": "workbox: {\n runtimeCaching: [\n {\n urlPattern: /^https:\\/\\/api\\.example\\.com\\/.*/i,\n handler: 'NetworkFirst',\n options: {\n cacheName: 'api-cache',\n expiration: {\n maxEntries: 50,\n maxAgeSeconds: 60 * 60 * 24\n }\n }\n }\n ]\n}"
},
"react_prompt_component": {
"description": "React component for update prompt",
"code": "import { useRegisterSW } from 'virtual:pwa-register/react';\n\nfunction ReloadPrompt() {\n const {\n needRefresh: [needRefresh, setNeedRefresh],\n updateServiceWorker,\n } = useRegisterSW();\n\n return needRefresh && (\n <div className=\"pwa-toast\">\n <span>New version available!</span>\n <button onClick={() => updateServiceWorker(true)}>Update</button>\n <button onClick={() => setNeedRefresh(false)}>Close</button>\n </div>\n );\n}"
}
},
"sveltekit": {
"plugin": "@vite-pwa/sveltekit",
"install": "npm i -D @vite-pwa/sveltekit",
"version": "^0.6.0",
"config_location": "vite.config.ts",
"service_worker_location": "src/service-worker.ts",
"documentation": "https://vite-pwa-org.netlify.app/frameworks/sveltekit.html",
"native_sw_note": "SvelteKit has built-in SW support via src/service-worker.ts. Use plugin for more features.",
"basic_config": {
"description": "Basic SvelteKit PWA configuration",
"code": "import { sveltekit } from '@sveltejs/kit/vite';\nimport { SvelteKitPWA } from '@vite-pwa/sveltekit';\n\nexport default defineConfig({\n plugins: [\n sveltekit(),\n SvelteKitPWA({\n manifest: {\n name: 'My SvelteKit App',\n short_name: 'App',\n theme_color: '#ffffff',\n icons: [\n { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },\n { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }\n ]\n }\n })\n ]\n});"
},
"native_sw": {
"description": "Using SvelteKit's built-in service worker",
"location": "src/service-worker.ts",
"code": "/// <reference types=\"@sveltejs/kit\" />\n/// <reference no-default-lib=\"true\"/>\n/// <reference lib=\"esnext\" />\n/// <reference lib=\"webworker\" />\n\nimport { build, files, version } from '$service-worker';\n\nconst CACHE = `cache-${version}`;\nconst ASSETS = [...build, ...files];\n\nself.addEventListener('install', (event) => {\n event.waitUntil(\n caches.open(CACHE)\n .then((cache) => cache.addAll(ASSETS))\n .then(() => self.skipWaiting())\n );\n});\n\nself.addEventListener('activate', (event) => {\n event.waitUntil(\n caches.keys().then((keys) => {\n return Promise.all(\n keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))\n );\n })\n );\n});\n\nself.addEventListener('fetch', (event) => {\n if (event.request.method !== 'GET') return;\n \n event.respondWith(\n caches.match(event.request)\n .then((cached) => cached || fetch(event.request))\n );\n});"
},
"svelte_prompt_component": {
"description": "Svelte component for update prompt",
"code": "<script>\n import { useRegisterSW } from 'virtual:pwa-register/svelte';\n \n const { needRefresh, updateServiceWorker } = useRegisterSW();\n</script>\n\n{#if $needRefresh}\n <div class=\"pwa-toast\">\n <span>New version available!</span>\n <button on:click={() => updateServiceWorker(true)}>Update</button>\n <button on:click={() => needRefresh.set(false)}>Close</button>\n </div>\n{/if}"
}
},
"nextjs": {
"plugin": "next-pwa",
"install": "npm i next-pwa",
"version": "^5.6.0",
"config_location": "next.config.js",
"documentation": "https://github.com/shadowwalker/next-pwa",
"notes": [
"Creates sw.js and workbox files automatically",
"Disable in development to avoid caching issues"
],
"basic_config": {
"description": "Basic Next.js PWA configuration",
"code": "const withPWA = require('next-pwa')({\n dest: 'public',\n disable: process.env.NODE_ENV === 'development'\n});\n\nmodule.exports = withPWA({\n // Your Next.js config\n});"
},
"with_caching": {
"description": "Configuration with custom runtime caching",
"code": "const withPWA = require('next-pwa')({\n dest: 'public',\n disable: process.env.NODE_ENV === 'development',\n runtimeCaching: [\n {\n urlPattern: /^https:\\/\\/api\\./,\n handler: 'NetworkFirst',\n options: {\n cacheName: 'api-cache',\n expiration: {\n maxEntries: 50,\n maxAgeSeconds: 60 * 60 * 24\n }\n }\n }\n ]\n});\n\nmodule.exports = withPWA({});"
},
"manifest_location": "public/manifest.json",
"manifest_link": "<link rel=\"manifest\" href=\"/manifest.json\" />"
},
"create_react_app": {
"status": "legacy",
"note": "CRA has limited PWA support. Consider migrating to Vite.",
"built_in_sw": "src/serviceWorkerRegistration.js",
"enable_sw": {
"description": "Enable service worker in CRA",
"code": "// In src/index.js, change:\nserviceWorkerRegistration.unregister();\n// to:\nserviceWorkerRegistration.register();"
},
"manifest_location": "public/manifest.json",
"limitations": [
"Less control over caching strategies",
"No Workbox integration by default",
"Limited customization options"
],
"migration_recommendation": "Migrate to Vite using 'npm create vite@latest' for better PWA support"
},
"common_patterns": {
"register_service_worker": {
"description": "Manual service worker registration",
"code": "if ('serviceWorker' in navigator) {\n window.addEventListener('load', () => {\n navigator.serviceWorker.register('/sw.js')\n .then((reg) => console.log('SW registered:', reg.scope))\n .catch((err) => console.error('SW registration failed:', err));\n });\n}"
},
"update_detection": {
"description": "Detect and handle service worker updates",
"code": "navigator.serviceWorker.register('/sw.js').then((reg) => {\n reg.addEventListener('updatefound', () => {\n const newWorker = reg.installing;\n newWorker.addEventListener('statechange', () => {\n if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {\n // New version available\n showUpdateNotification();\n }\n });\n });\n});"
},
"force_update": {
"description": "Force update and reload",
"code": "function updateApp() {\n navigator.serviceWorker.ready.then((reg) => {\n reg.waiting?.postMessage({ type: 'SKIP_WAITING' });\n });\n window.location.reload();\n}"
}
}
}
{
"_meta": {
"description": "iOS PWA quirks, limitations, and workarounds",
"last_updated": "2025-01",
"ios_versions_covered": ["15", "16", "17", "18"],
"source": "pwa-development skill"
},
"quirks": {
"no_beforeinstallprompt": {
"description": "iOS Safari does not fire the beforeinstallprompt event",
"impact": "Cannot programmatically detect installability or show custom install prompt",
"affected_versions": "all",
"workaround": "Show manual 'Add to Home Screen' instructions for iOS users",
"detection": "Check for iOS via userAgent: /iPhone|iPad|iPod/.test(navigator.userAgent) && !window.MSStream",
"code_example": "if (iOS && !window.matchMedia('(display-mode: standalone)').matches) { showIOSInstallBanner(); }"
},
"storage_eviction": {
"description": "WebKit may evict storage after 7 days of non-use",
"impact": "IndexedDB and Cache Storage data may be deleted if PWA not accessed",
"affected_versions": "all",
"workaround": "Request persistent storage, sync critical data to server, design for data loss",
"detection": "navigator.storage.persisted() to check persistence status",
"code_example": "const persistent = await navigator.storage.persist(); // Returns false on iOS"
},
"cache_storage_limit": {
"description": "Cache Storage limited to approximately 50MB",
"impact": "Cannot cache large amounts of content for offline use",
"affected_versions": "all",
"workaround": "Be selective about what to cache, implement LRU eviction, use IndexedDB for structured data",
"detection": "navigator.storage.estimate() returns quota information"
},
"no_push_before_16.4": {
"description": "Push notifications not supported until iOS 16.4",
"impact": "Cannot send push notifications to older iOS devices",
"affected_versions": "< 16.4",
"fixed_in": "16.4",
"workaround": "Use in-app polling, email notifications, or inform users of limitation",
"detection": "Check iOS version and 'PushManager' in window"
},
"push_requires_home_screen": {
"description": "Push notifications only work when PWA is installed to home screen",
"impact": "Safari browser PWAs don't receive push notifications even on 16.4+",
"affected_versions": "16.4+",
"workaround": "Prompt users to add to home screen before enabling notifications",
"detection": "window.matchMedia('(display-mode: standalone)').matches"
},
"no_background_sync": {
"description": "Background Sync API not supported on iOS",
"impact": "Cannot queue and retry failed requests in background",
"affected_versions": "all",
"workaround": "Store pending actions in IndexedDB, retry on next app open",
"detection": "'SyncManager' in window"
},
"scope_navigation": {
"description": "Navigating outside PWA scope opens Safari instead of staying in app",
"impact": "External links and out-of-scope pages break the app experience",
"affected_versions": "all",
"workaround": "Keep all content within scope, use target='_blank' intentionally for external links",
"detection": "N/A - design consideration"
},
"status_bar_styling": {
"description": "Status bar appearance requires specific meta tags",
"impact": "Status bar may clash with app theme or be unreadable",
"affected_versions": "all",
"workaround": "Add apple-mobile-web-app-status-bar-style meta tag",
"meta_tags": [
"<meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">",
"<meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black\">",
"<meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\">"
],
"options": {
"default": "Black text, white background",
"black": "White text, black background",
"black-translucent": "White text, app content extends behind status bar"
}
},
"splash_screen_caching": {
"description": "Splash screen aggressively cached, may not update with manifest changes",
"impact": "Old splash screen shows even after manifest updates",
"affected_versions": "all",
"workaround": "Change start_url query parameter to bust cache (e.g., /?v=2)",
"code_example": "\"start_url\": \"/?v=2\""
},
"apple_touch_icon_required": {
"description": "iOS ignores manifest icons, requires separate apple-touch-icon link",
"impact": "Home screen icon may be wrong or missing",
"affected_versions": "all",
"workaround": "Add apple-touch-icon link tag",
"meta_tags": [
"<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">",
"<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon-180x180.png\">"
],
"recommended_size": "180x180"
},
"indexeddb_private_browsing": {
"description": "IndexedDB quota severely limited in private browsing",
"impact": "Storage operations may fail in private browsing mode",
"affected_versions": "all",
"workaround": "Detect private mode, show warning, use localStorage fallback for small data",
"detection": "Try to use IndexedDB and catch QuotaExceededError"
},
"audio_autoplay_restrictions": {
"description": "Audio/video autoplay requires user interaction first",
"impact": "Background audio, notification sounds may not play",
"affected_versions": "all",
"workaround": "Wait for user gesture (click/tap) before playing audio"
},
"no_file_system_access": {
"description": "File System Access API not supported",
"impact": "Cannot read/write local files like native apps",
"affected_versions": "all",
"workaround": "Use input[type=file] for file selection, download links for saving"
},
"orientation_lock_limitations": {
"description": "Screen orientation lock may not work reliably",
"impact": "App may rotate unexpectedly",
"affected_versions": "varies",
"workaround": "Design responsive layouts, use CSS media queries for orientation"
}
},
"required_meta_tags": [
{
"tag": "<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">",
"purpose": "Enable standalone mode on iOS"
},
{
"tag": "<meta name=\"apple-mobile-web-app-title\" content=\"App Name\">",
"purpose": "Set app title on home screen"
},
{
"tag": "<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">",
"purpose": "Home screen icon"
},
{
"tag": "<meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">",
"purpose": "Status bar styling"
},
{
"tag": "<meta name=\"theme-color\" content=\"#ffffff\">",
"purpose": "Theme color (also needed in manifest)"
}
],
"testing_checklist": [
"Test on actual iOS device (not just Chrome DevTools simulation)",
"Test add to home screen flow",
"Test offline functionality after 7+ days of non-use",
"Test storage eviction by filling storage then waiting",
"Verify splash screen appearance",
"Check status bar styling",
"Test orientation changes",
"Verify push notifications (iOS 16.4+, home screen only)",
"Test Safari browser vs installed PWA behavior differences"
]
}
{
"_meta": {
"description": "Complete Web App Manifest property reference",
"specification": "https://www.w3.org/TR/appmanifest/",
"source": "pwa-development skill"
},
"required_fields": {
"name": {
"type": "string",
"description": "Full name of the application",
"max_length": 45,
"example": "My Awesome Application",
"used_for": "Install prompt, app launcher, splash screen"
},
"short_name": {
"type": "string",
"description": "Short name for home screen and limited space",
"max_length": 12,
"example": "MyApp",
"used_for": "Home screen icon label"
},
"start_url": {
"type": "string",
"description": "URL to open when app is launched",
"example": "/",
"notes": "Should be within scope. Can include query params for analytics."
},
"display": {
"type": "enum",
"description": "How the app should be displayed",
"values": {
"fullscreen": "Takes up entire display, no browser UI",
"standalone": "Looks like native app, minimal browser UI",
"minimal-ui": "Some browser UI elements visible",
"browser": "Opens in normal browser tab"
},
"default": "browser",
"recommended": "standalone"
},
"icons": {
"type": "array",
"description": "Array of icon objects for various uses",
"required_sizes": ["192x192", "512x512"],
"properties": {
"src": "Path to icon file",
"sizes": "Icon dimensions (e.g., '192x192')",
"type": "MIME type (e.g., 'image/png')",
"purpose": "Icon purpose: 'any', 'maskable', or 'any maskable'"
},
"example": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "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" }
]
}
},
"recommended_fields": {
"background_color": {
"type": "string",
"description": "Background color for splash screen",
"format": "CSS color (hex, rgb, named)",
"example": "#ffffff",
"notes": "Should match app's initial background"
},
"theme_color": {
"type": "string",
"description": "Theme color for browser UI and title bar",
"format": "CSS color (hex, rgb, named)",
"example": "#4285f4",
"notes": "Also add as <meta name='theme-color'> for broader support"
},
"description": {
"type": "string",
"description": "Description of the application",
"max_length": 300,
"example": "A productivity app for managing daily tasks",
"used_for": "App stores, search results"
},
"scope": {
"type": "string",
"description": "Navigation scope - URLs outside open in browser",
"example": "/app/",
"default": "Parent directory of start_url",
"notes": "start_url must be within scope"
},
"orientation": {
"type": "enum",
"description": "Default orientation for the app",
"values": ["any", "natural", "portrait", "portrait-primary", "portrait-secondary", "landscape", "landscape-primary", "landscape-secondary"],
"default": "any"
}
},
"optional_fields": {
"id": {
"type": "string",
"description": "Unique identifier for the app",
"example": "com.example.myapp",
"notes": "Helps with app identity across manifest changes"
},
"screenshots": {
"type": "array",
"description": "Screenshots for richer install UI",
"properties": {
"src": "Path to screenshot",
"sizes": "Dimensions",
"type": "MIME type",
"form_factor": "'narrow' (mobile) or 'wide' (desktop)",
"label": "Description of screenshot"
},
"example": [
{ "src": "/screenshots/mobile.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow" },
{ "src": "/screenshots/desktop.png", "sizes": "1920x1080", "type": "image/png", "form_factor": "wide" }
]
},
"shortcuts": {
"type": "array",
"description": "App shortcuts for quick actions (right-click menu)",
"properties": {
"name": "Shortcut label",
"short_name": "Short label",
"description": "Shortcut description",
"url": "URL to open",
"icons": "Icon for shortcut"
},
"example": [
{ "name": "New Task", "url": "/tasks/new", "icons": [{ "src": "/icons/new-task.png", "sizes": "96x96" }] }
]
},
"categories": {
"type": "array",
"description": "App store categories",
"values": ["books", "business", "education", "entertainment", "finance", "fitness", "food", "games", "government", "health", "kids", "lifestyle", "magazines", "medical", "music", "navigation", "news", "personalization", "photo", "politics", "productivity", "security", "shopping", "social", "sports", "travel", "utilities", "weather"],
"example": ["productivity", "utilities"]
},
"related_applications": {
"type": "array",
"description": "Related native apps",
"properties": {
"platform": "App store platform",
"url": "App store URL",
"id": "App ID"
},
"example": [
{ "platform": "play", "url": "https://play.google.com/store/apps/details?id=com.example", "id": "com.example" }
]
},
"prefer_related_applications": {
"type": "boolean",
"description": "Prefer native app over PWA",
"default": false,
"notes": "Set true to prefer native app install"
},
"dir": {
"type": "enum",
"description": "Text direction",
"values": ["auto", "ltr", "rtl"],
"default": "auto"
},
"lang": {
"type": "string",
"description": "Primary language",
"format": "BCP 47 language tag",
"example": "en-US"
},
"iarc_rating_id": {
"type": "string",
"description": "IARC certification code for content rating",
"notes": "Required for some app stores"
}
},
"advanced_fields": {
"share_target": {
"type": "object",
"description": "Enable app as share target",
"properties": {
"action": "URL to handle share",
"method": "HTTP method (GET or POST)",
"enctype": "Encoding type for POST",
"params": "Parameter mapping"
},
"example": {
"action": "/share",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [{ "name": "media", "accept": ["image/*"] }]
}
}
},
"protocol_handlers": {
"type": "array",
"description": "Handle custom URL protocols",
"properties": {
"protocol": "Protocol scheme (without ://)",
"url": "Handler URL with %s placeholder"
},
"example": [
{ "protocol": "web+myapp", "url": "/open?url=%s" }
]
},
"file_handlers": {
"type": "array",
"description": "Handle file types",
"properties": {
"action": "Handler URL",
"accept": "Object mapping MIME types to extensions"
},
"example": [
{ "action": "/open-file", "accept": { "text/plain": [".txt"] } }
]
},
"launch_handler": {
"type": "object",
"description": "Control launch behavior",
"properties": {
"client_mode": ["auto", "navigate-new", "navigate-existing", "focus-existing"]
}
},
"handle_links": {
"type": "enum",
"description": "How to handle links clicked in other apps",
"values": ["auto", "preferred", "not-preferred"],
"notes": "Experimental feature"
}
},
"validation_rules": [
"name is required and should be <= 45 characters",
"short_name is required and should be <= 12 characters",
"start_url is required and must be within scope",
"icons must include at least 192x192 and 512x512 sizes",
"display must be one of: fullscreen, standalone, minimal-ui, browser",
"Colors should be valid CSS color values",
"URLs should be relative paths or absolute URLs within same origin"
]
}
#!/usr/bin/env -S deno run --allow-read
/**
* Cache Strategy Advisor
*
* Recommends caching strategies based on app type and resource inventory.
* Uses decision matrix from caching-strategies.json.
*
* Usage:
* deno run --allow-read cache-strategy-advisor.ts --app-type content-heavy
* deno run --allow-read cache-strategy-advisor.ts --app-type app-like
* deno run --allow-read cache-strategy-advisor.ts --resources resources.json
*/
// === INTERFACES ===
type AppType = "content-heavy" | "app-like" | "data-intensive" | "hybrid";
type CachingStrategy = "cache-first" | "network-first" | "stale-while-revalidate" | "network-only" | "cache-only";
type ResourceType = "html" | "css" | "js" | "images" | "fonts" | "api" | "video" | "audio" | "documents" | "third-party";
interface ResourceEntry {
path: string;
type: ResourceType;
changeFrequency?: "never" | "rarely" | "sometimes" | "often" | "always";
size?: "small" | "medium" | "large";
critical?: boolean;
}
interface StrategyRecommendation {
resourceType: ResourceType;
strategy: CachingStrategy;
cacheName: string;
maxAge?: number;
maxEntries?: number;
reasoning: string;
}
interface AppTypeProfile {
description: string;
defaultStrategies: Record<ResourceType, CachingStrategy>;
precacheRecommendation: string[];
notes: string[];
}
// === DECISION MATRIX ===
const APP_TYPE_PROFILES: Record<AppType, AppTypeProfile> = {
"content-heavy": {
description: "Blogs, news sites, documentation - content changes but should be readable offline",
defaultStrategies: {
html: "network-first",
css: "stale-while-revalidate",
js: "stale-while-revalidate",
images: "cache-first",
fonts: "cache-first",
api: "network-first",
video: "cache-first",
audio: "cache-first",
documents: "cache-first",
"third-party": "stale-while-revalidate",
},
precacheRecommendation: ["app shell", "critical CSS", "fonts", "logo"],
notes: [
"Use stale-while-revalidate for articles - fast load, eventual freshness",
"Cache images aggressively with expiration",
"Consider offline reading list feature",
],
},
"app-like": {
description: "SPAs, dashboards, tools - app shell is stable, data is dynamic",
defaultStrategies: {
html: "cache-first",
css: "cache-first",
js: "cache-first",
images: "cache-first",
fonts: "cache-first",
api: "network-first",
video: "network-only",
audio: "network-only",
documents: "network-first",
"third-party": "stale-while-revalidate",
},
precacheRecommendation: ["index.html", "app shell", "all JS bundles", "all CSS", "fonts", "icons"],
notes: [
"Precache the entire app shell for instant load",
"API calls should be network-first with offline fallback",
"Consider IndexedDB for offline data persistence",
],
},
"data-intensive": {
description: "Forms, CRMs, data entry - user actions must work offline",
defaultStrategies: {
html: "cache-first",
css: "cache-first",
js: "cache-first",
images: "cache-first",
fonts: "cache-first",
api: "network-first",
video: "network-only",
audio: "network-only",
documents: "stale-while-revalidate",
"third-party": "network-first",
},
precacheRecommendation: ["app shell", "form templates", "validation logic"],
notes: [
"Implement Background Sync for offline form submissions",
"Use IndexedDB to queue offline actions",
"Show sync status indicator to users",
"Design conflict resolution strategy",
],
},
"hybrid": {
description: "Mix of content and functionality - balance freshness and availability",
defaultStrategies: {
html: "stale-while-revalidate",
css: "stale-while-revalidate",
js: "stale-while-revalidate",
images: "cache-first",
fonts: "cache-first",
api: "network-first",
video: "cache-first",
audio: "cache-first",
documents: "stale-while-revalidate",
"third-party": "stale-while-revalidate",
},
precacheRecommendation: ["critical path only", "offline fallback page"],
notes: [
"Use route-specific strategies",
"Identify critical vs. nice-to-have offline content",
"Consider lazy-caching for secondary content",
],
},
};
const STRATEGY_DETAILS: Record<CachingStrategy, { description: string; tradeoffs: string; workboxClass: string }> = {
"cache-first": {
description: "Check cache first, fall back to network",
tradeoffs: "Fast but may serve stale content",
workboxClass: "CacheFirst",
},
"network-first": {
description: "Try network first, fall back to cache",
tradeoffs: "Fresh but slower, requires network for best experience",
workboxClass: "NetworkFirst",
},
"stale-while-revalidate": {
description: "Return cache immediately, update in background",
tradeoffs: "Fast AND eventually fresh, but first view may be stale",
workboxClass: "StaleWhileRevalidate",
},
"network-only": {
description: "Always fetch from network",
tradeoffs: "Always fresh but fails offline",
workboxClass: "NetworkOnly",
},
"cache-only": {
description: "Only serve from cache",
tradeoffs: "Fast and reliable but never updates",
workboxClass: "CacheOnly",
},
};
const RESOURCE_TYPE_DEFAULTS: Record<ResourceType, { cacheName: string; maxAge: number; maxEntries: number }> = {
html: { cacheName: "pages-cache", maxAge: 60 * 60, maxEntries: 50 },
css: { cacheName: "static-cache", maxAge: 60 * 60 * 24 * 30, maxEntries: 30 },
js: { cacheName: "static-cache", maxAge: 60 * 60 * 24 * 30, maxEntries: 30 },
images: { cacheName: "image-cache", maxAge: 60 * 60 * 24 * 30, maxEntries: 100 },
fonts: { cacheName: "font-cache", maxAge: 60 * 60 * 24 * 365, maxEntries: 10 },
api: { cacheName: "api-cache", maxAge: 60 * 5, maxEntries: 50 },
video: { cacheName: "media-cache", maxAge: 60 * 60 * 24 * 7, maxEntries: 20 },
audio: { cacheName: "media-cache", maxAge: 60 * 60 * 24 * 7, maxEntries: 20 },
documents: { cacheName: "document-cache", maxAge: 60 * 60 * 24, maxEntries: 30 },
"third-party": { cacheName: "external-cache", maxAge: 60 * 60 * 24, maxEntries: 20 },
};
// === ANALYSIS ===
function analyzeAppType(appType: AppType): void {
const profile = APP_TYPE_PROFILES[appType];
console.log(`\n=== Cache Strategy Recommendations ===`);
console.log(`\nApp Type: ${appType}`);
console.log(`Description: ${profile.description}\n`);
console.log("Resource Strategies:");
console.log("-".repeat(70));
const resourceTypes: ResourceType[] = ["html", "css", "js", "images", "fonts", "api", "video", "audio", "documents", "third-party"];
for (const resourceType of resourceTypes) {
const strategy = profile.defaultStrategies[resourceType];
const details = STRATEGY_DETAILS[strategy];
const defaults = RESOURCE_TYPE_DEFAULTS[resourceType];
console.log(`\n${resourceType.toUpperCase()}`);
console.log(` Strategy: ${strategy}`);
console.log(` ${details.description}`);
console.log(` Cache: ${defaults.cacheName}, max ${defaults.maxEntries} entries, ${formatAge(defaults.maxAge)}`);
}
console.log("\n" + "-".repeat(70));
console.log("\nPrecache Recommendations:");
profile.precacheRecommendation.forEach(item => console.log(` - ${item}`));
console.log("\nNotes:");
profile.notes.forEach(note => console.log(` - ${note}`));
console.log("\n" + "-".repeat(70));
generateWorkboxConfig(appType);
}
function analyzeResources(resources: ResourceEntry[]): void {
console.log(`\n=== Resource-Specific Recommendations ===\n`);
console.log(`Analyzing ${resources.length} resources...\n`);
const recommendations: StrategyRecommendation[] = [];
for (const resource of resources) {
const recommendation = recommendStrategy(resource);
recommendations.push(recommendation);
}
// Group by strategy
const byStrategy = new Map<CachingStrategy, StrategyRecommendation[]>();
for (const rec of recommendations) {
const existing = byStrategy.get(rec.strategy) || [];
existing.push(rec);
byStrategy.set(rec.strategy, existing);
}
for (const [strategy, recs] of byStrategy) {
const details = STRATEGY_DETAILS[strategy];
console.log(`\n${strategy.toUpperCase()}`);
console.log(` ${details.description}`);
console.log(` Resources:`);
for (const rec of recs) {
console.log(` - ${rec.resourceType}: ${rec.reasoning}`);
}
}
}
function recommendStrategy(resource: ResourceEntry): StrategyRecommendation {
const defaults = RESOURCE_TYPE_DEFAULTS[resource.type];
let strategy: CachingStrategy;
let reasoning: string;
// Decision logic based on change frequency and criticality
if (resource.changeFrequency === "never" || resource.changeFrequency === "rarely") {
strategy = "cache-first";
reasoning = "Rarely changes, prioritize speed";
} else if (resource.changeFrequency === "always") {
strategy = "network-only";
reasoning = "Always needs fresh data";
} else if (resource.critical) {
strategy = "stale-while-revalidate";
reasoning = "Critical resource, balance speed and freshness";
} else if (resource.type === "api") {
strategy = "network-first";
reasoning = "API data should be fresh when possible";
} else if (resource.size === "large") {
strategy = "cache-first";
reasoning = "Large resource, avoid repeated downloads";
} else {
strategy = "stale-while-revalidate";
reasoning = "Default balanced approach";
}
return {
resourceType: resource.type,
strategy,
cacheName: defaults.cacheName,
maxAge: defaults.maxAge,
maxEntries: defaults.maxEntries,
reasoning,
};
}
function formatAge(seconds: number): string {
if (seconds >= 60 * 60 * 24 * 365) {
return `${Math.round(seconds / (60 * 60 * 24 * 365))} year(s)`;
} else if (seconds >= 60 * 60 * 24) {
return `${Math.round(seconds / (60 * 60 * 24))} day(s)`;
} else if (seconds >= 60 * 60) {
return `${Math.round(seconds / (60 * 60))} hour(s)`;
} else if (seconds >= 60) {
return `${Math.round(seconds / 60)} minute(s)`;
}
return `${seconds} seconds`;
}
function generateWorkboxConfig(appType: AppType): void {
const profile = APP_TYPE_PROFILES[appType];
console.log("\nWorkbox Configuration:");
console.log("```javascript");
console.log("// workbox-config.js or vite.config.ts runtimeCaching");
console.log("runtimeCaching: [");
const routes: Array<{ pattern: string; handler: string; cacheName: string; maxEntries: number; maxAge: number }> = [
{ pattern: "/*.html", handler: STRATEGY_DETAILS[profile.defaultStrategies.html].workboxClass, cacheName: "pages-cache", maxEntries: 50, maxAge: 3600 },
{ pattern: "/*.{css,js}", handler: STRATEGY_DETAILS[profile.defaultStrategies.css].workboxClass, cacheName: "static-cache", maxEntries: 60, maxAge: 2592000 },
{ pattern: "/*.{png,jpg,jpeg,svg,gif,webp}", handler: STRATEGY_DETAILS[profile.defaultStrategies.images].workboxClass, cacheName: "image-cache", maxEntries: 100, maxAge: 2592000 },
{ pattern: "/*.{woff,woff2,ttf,eot}", handler: STRATEGY_DETAILS[profile.defaultStrategies.fonts].workboxClass, cacheName: "font-cache", maxEntries: 10, maxAge: 31536000 },
{ pattern: "/api/*", handler: STRATEGY_DETAILS[profile.defaultStrategies.api].workboxClass, cacheName: "api-cache", maxEntries: 50, maxAge: 300 },
];
for (const route of routes) {
console.log(` {`);
console.log(` urlPattern: new RegExp('${route.pattern.replace(/\*/g, ".*")}'),`);
console.log(` handler: '${route.handler}',`);
console.log(` options: {`);
console.log(` cacheName: '${route.cacheName}',`);
console.log(` expiration: {`);
console.log(` maxEntries: ${route.maxEntries},`);
console.log(` maxAgeSeconds: ${route.maxAge}`);
console.log(` }`);
console.log(` }`);
console.log(` },`);
}
console.log("]");
console.log("```");
}
// === ARGUMENT PARSING ===
function parseArgs(args: string[]): { appType?: AppType; resourcesFile?: string; json?: boolean } {
let appType: AppType | undefined;
let resourcesFile: string | undefined;
let json = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const nextArg = args[i + 1];
switch (arg) {
case "--help":
case "-h":
printHelp();
Deno.exit(0);
break;
case "--app-type":
case "-t":
appType = nextArg as AppType;
i++;
break;
case "--resources":
case "-r":
resourcesFile = nextArg;
i++;
break;
case "--json":
json = true;
break;
}
}
return { appType, resourcesFile, json };
}
function printHelp(): void {
console.log(`
Cache Strategy Advisor
Recommend caching strategies based on app type or resource inventory.
USAGE:
cache-strategy-advisor.ts [OPTIONS]
OPTIONS:
--app-type, -t <type> App type for strategy recommendations:
content-heavy, app-like, data-intensive, hybrid
--resources, -r <file> JSON file with resource inventory
--json Output as JSON (for automation)
--help, -h Show this help
APP TYPES:
content-heavy Blogs, news, docs - readable offline
app-like SPAs, dashboards - stable shell, dynamic data
data-intensive Forms, CRMs - offline actions must work
hybrid Mix of content and functionality
RESOURCE FILE FORMAT:
[
{
"path": "/api/users",
"type": "api",
"changeFrequency": "often",
"critical": true
},
{
"path": "/images/*.png",
"type": "images",
"changeFrequency": "rarely",
"size": "large"
}
]
Types: html, css, js, images, fonts, api, video, audio, documents, third-party
Change Frequency: never, rarely, sometimes, often, always
Size: small, medium, large
EXAMPLES:
# Get recommendations for a content-heavy site
cache-strategy-advisor.ts --app-type content-heavy
# Analyze specific resources
cache-strategy-advisor.ts --resources resources.json
# Output as JSON
cache-strategy-advisor.ts --app-type app-like --json
`);
}
// === MAIN ===
async function main(): Promise<void> {
const { appType, resourcesFile, json } = parseArgs(Deno.args);
if (!appType && !resourcesFile) {
console.log("Specify --app-type or --resources. Use --help for options.");
Deno.exit(1);
}
if (resourcesFile) {
try {
const content = await Deno.readTextFile(resourcesFile);
const resources: ResourceEntry[] = JSON.parse(content);
if (json) {
const recommendations = resources.map(r => recommendStrategy(r));
console.log(JSON.stringify(recommendations, null, 2));
} else {
analyzeResources(resources);
}
} catch (error) {
console.error(`Error reading resources file: ${error}`);
Deno.exit(1);
}
} else if (appType) {
if (!APP_TYPE_PROFILES[appType]) {
console.error(`Unknown app type: ${appType}`);
console.error("Valid types: content-heavy, app-like, data-intensive, hybrid");
Deno.exit(1);
}
if (json) {
const profile = APP_TYPE_PROFILES[appType];
console.log(JSON.stringify({ appType, profile }, null, 2));
} else {
analyzeAppType(appType);
}
}
}
main();
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* PWA Manifest Generator
*
* Generates a complete manifest.json for Progressive Web Apps.
* Supports interactive mode and parameter-based generation.
*
* Usage:
* deno run --allow-read --allow-write manifest-generator.ts --interactive
* deno run --allow-read --allow-write manifest-generator.ts --name "My App" --short-name "App"
* deno run --allow-read manifest-generator.ts --validate manifest.json
*/
// === INTERFACES ===
interface ManifestIcon {
src: string;
sizes: string;
type: string;
purpose?: string;
}
interface ManifestScreenshot {
src: string;
sizes: string;
type: string;
form_factor?: "narrow" | "wide";
label?: string;
}
interface WebAppManifest {
name: string;
short_name: string;
description?: string;
start_url: string;
display: "fullscreen" | "standalone" | "minimal-ui" | "browser";
orientation?: "any" | "natural" | "landscape" | "portrait" | "portrait-primary" | "portrait-secondary" | "landscape-primary" | "landscape-secondary";
background_color: string;
theme_color: string;
scope?: string;
icons: ManifestIcon[];
screenshots?: ManifestScreenshot[];
categories?: string[];
shortcuts?: Array<{
name: string;
short_name?: string;
description?: string;
url: string;
icons?: ManifestIcon[];
}>;
related_applications?: Array<{
platform: string;
url: string;
id?: string;
}>;
prefer_related_applications?: boolean;
}
interface GeneratorConfig {
name: string;
shortName: string;
description?: string;
startUrl?: string;
display?: WebAppManifest["display"];
orientation?: WebAppManifest["orientation"];
backgroundColor?: string;
themeColor?: string;
scope?: string;
iconSizes?: string[];
iconPath?: string;
}
interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
// === DEFAULTS ===
const DEFAULT_CONFIG: Partial<GeneratorConfig> = {
startUrl: "/",
display: "standalone",
orientation: "any",
backgroundColor: "#ffffff",
themeColor: "#4285f4",
scope: "/",
iconSizes: ["192x192", "512x512"],
iconPath: "/icons/icon-{size}.png",
};
const REQUIRED_ICON_SIZES = ["192x192", "512x512"];
const RECOMMENDED_ICON_SIZES = [
"48x48",
"72x72",
"96x96",
"128x128",
"144x144",
"192x192",
"256x256",
"384x384",
"512x512",
];
// === VALIDATION ===
function validateManifest(manifest: Partial<WebAppManifest>): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Required fields
if (!manifest.name) {
errors.push("Missing required field: name");
} else if (manifest.name.length > 45) {
warnings.push("name is longer than 45 characters, may be truncated");
}
if (!manifest.short_name) {
errors.push("Missing required field: short_name");
} else if (manifest.short_name.length > 12) {
warnings.push("short_name is longer than 12 characters, may be truncated on home screen");
}
if (!manifest.start_url) {
errors.push("Missing required field: start_url");
}
if (!manifest.display) {
errors.push("Missing required field: display");
} else if (!["fullscreen", "standalone", "minimal-ui", "browser"].includes(manifest.display)) {
errors.push(`Invalid display value: ${manifest.display}`);
}
if (!manifest.background_color) {
warnings.push("Missing background_color - splash screen may look generic");
}
if (!manifest.theme_color) {
warnings.push("Missing theme_color - browser UI won't match app theme");
}
// Icons validation
if (!manifest.icons || manifest.icons.length === 0) {
errors.push("Missing required field: icons (at least one icon required)");
} else {
const sizes = manifest.icons.map(i => i.sizes);
if (!sizes.includes("192x192")) {
errors.push("Missing required icon size: 192x192");
}
if (!sizes.includes("512x512")) {
errors.push("Missing required icon size: 512x512");
}
for (const icon of manifest.icons) {
if (!icon.src) {
errors.push("Icon missing src property");
}
if (!icon.sizes) {
errors.push("Icon missing sizes property");
}
if (!icon.type) {
warnings.push(`Icon ${icon.src} missing type property (should be image/png or image/svg+xml)`);
}
}
}
// Scope validation
if (manifest.scope && manifest.start_url) {
if (!manifest.start_url.startsWith(manifest.scope)) {
warnings.push("start_url should be within scope");
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
// === GENERATION ===
function generateManifest(config: GeneratorConfig): WebAppManifest {
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
const iconSizes = mergedConfig.iconSizes || REQUIRED_ICON_SIZES;
const iconPath = mergedConfig.iconPath || "/icons/icon-{size}.png";
const icons: ManifestIcon[] = iconSizes.map(size => ({
src: iconPath.replace("{size}", size),
sizes: size,
type: "image/png",
purpose: "any maskable",
}));
const manifest: WebAppManifest = {
name: config.name,
short_name: config.shortName,
start_url: mergedConfig.startUrl!,
display: mergedConfig.display!,
background_color: mergedConfig.backgroundColor!,
theme_color: mergedConfig.themeColor!,
icons,
};
if (config.description) {
manifest.description = config.description;
}
if (mergedConfig.orientation && mergedConfig.orientation !== "any") {
manifest.orientation = mergedConfig.orientation;
}
if (mergedConfig.scope) {
manifest.scope = mergedConfig.scope;
}
return manifest;
}
// === INTERACTIVE MODE ===
async function promptInteractive(): Promise<GeneratorConfig> {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
async function prompt(question: string, defaultValue?: string): Promise<string> {
const suffix = defaultValue ? ` [${defaultValue}]` : "";
await Deno.stdout.write(encoder.encode(`${question}${suffix}: `));
const buf = new Uint8Array(1024);
const n = await Deno.stdin.read(buf);
const input = decoder.decode(buf.subarray(0, n!)).trim();
return input || defaultValue || "";
}
console.log("\n=== PWA Manifest Generator ===\n");
const name = await prompt("App name (full)");
if (!name) {
console.error("Error: App name is required");
Deno.exit(1);
}
const shortName = await prompt("Short name (for home screen)", name.slice(0, 12));
const description = await prompt("Description (optional)");
const startUrl = await prompt("Start URL", "/");
const display = await prompt("Display mode (standalone/fullscreen/minimal-ui/browser)", "standalone");
const themeColor = await prompt("Theme color (hex)", "#4285f4");
const backgroundColor = await prompt("Background color (hex)", "#ffffff");
const iconPath = await prompt("Icon path pattern", "/icons/icon-{size}.png");
return {
name,
shortName,
description: description || undefined,
startUrl,
display: display as WebAppManifest["display"],
themeColor,
backgroundColor,
iconPath,
iconSizes: RECOMMENDED_ICON_SIZES,
};
}
// === ARGUMENT PARSING ===
function parseArgs(args: string[]): { config: Partial<GeneratorConfig>; mode: "generate" | "validate" | "interactive"; validatePath?: string; output?: string } {
const config: Partial<GeneratorConfig> = {};
let mode: "generate" | "validate" | "interactive" = "generate";
let validatePath: string | undefined;
let output: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const nextArg = args[i + 1];
switch (arg) {
case "--help":
case "-h":
printHelp();
Deno.exit(0);
break;
case "--interactive":
case "-i":
mode = "interactive";
break;
case "--validate":
mode = "validate";
validatePath = nextArg;
i++;
break;
case "--name":
config.name = nextArg;
i++;
break;
case "--short-name":
config.shortName = nextArg;
i++;
break;
case "--description":
config.description = nextArg;
i++;
break;
case "--start-url":
config.startUrl = nextArg;
i++;
break;
case "--display":
config.display = nextArg as WebAppManifest["display"];
i++;
break;
case "--theme-color":
config.themeColor = nextArg;
i++;
break;
case "--background-color":
config.backgroundColor = nextArg;
i++;
break;
case "--output":
case "-o":
output = nextArg;
i++;
break;
case "--json":
// Output JSON (default behavior)
break;
}
}
return { config, mode, validatePath, output };
}
function printHelp(): void {
console.log(`
PWA Manifest Generator
Generate or validate Progressive Web App manifest files.
USAGE:
manifest-generator.ts [OPTIONS]
MODES:
--interactive, -i Interactive prompts for all fields
--validate <file> Validate an existing manifest.json
OPTIONS:
--name <name> Full app name (required for generation)
--short-name <name> Short name for home screen
--description <desc> App description
--start-url <url> Start URL (default: /)
--display <mode> Display mode: standalone, fullscreen, minimal-ui, browser
--theme-color <hex> Theme color (default: #4285f4)
--background-color <hex> Background color (default: #ffffff)
--output, -o <file> Output file path (default: stdout)
--help, -h Show this help
EXAMPLES:
# Interactive mode
manifest-generator.ts --interactive
# Generate with parameters
manifest-generator.ts --name "My Recipe App" --short-name "Recipes" --theme-color "#ff6b6b"
# Validate existing manifest
manifest-generator.ts --validate public/manifest.json
# Save to file
manifest-generator.ts --name "My App" --short-name "App" -o public/manifest.json
`);
}
// === MAIN ===
async function main(): Promise<void> {
const { config, mode, validatePath, output } = parseArgs(Deno.args);
if (mode === "validate" && validatePath) {
// Validate mode
try {
const content = await Deno.readTextFile(validatePath);
const manifest = JSON.parse(content);
const result = validateManifest(manifest);
console.log("\n=== Manifest Validation ===\n");
console.log(`File: ${validatePath}`);
console.log(`Status: ${result.valid ? "VALID" : "INVALID"}\n`);
if (result.errors.length > 0) {
console.log("ERRORS:");
result.errors.forEach(e => console.log(` - ${e}`));
console.log();
}
if (result.warnings.length > 0) {
console.log("WARNINGS:");
result.warnings.forEach(w => console.log(` - ${w}`));
console.log();
}
if (result.valid && result.warnings.length === 0) {
console.log("No issues found!");
}
Deno.exit(result.valid ? 0 : 1);
} catch (error) {
console.error(`Error reading manifest: ${error}`);
Deno.exit(1);
}
}
let finalConfig: GeneratorConfig;
if (mode === "interactive") {
finalConfig = await promptInteractive();
} else {
// Parameter mode
if (!config.name) {
console.error("Error: --name is required. Use --interactive for guided input or --help for options.");
Deno.exit(1);
}
finalConfig = {
name: config.name,
shortName: config.shortName || config.name.slice(0, 12),
...config,
} as GeneratorConfig;
}
const manifest = generateManifest(finalConfig);
const validation = validateManifest(manifest);
if (!validation.valid) {
console.error("Generated manifest has errors:");
validation.errors.forEach(e => console.error(` - ${e}`));
Deno.exit(1);
}
const jsonOutput = JSON.stringify(manifest, null, 2);
if (output) {
await Deno.writeTextFile(output, jsonOutput);
console.log(`Manifest written to ${output}`);
if (validation.warnings.length > 0) {
console.log("\nWarnings:");
validation.warnings.forEach(w => console.log(` - ${w}`));
}
} else {
console.log(jsonOutput);
}
}
main();
#!/usr/bin/env -S deno run --allow-read
/**
* PWA Audit
*
* Validates PWA configuration against best practices checklist.
* Checks manifest, service worker setup, and common issues.
*
* Usage:
* deno run --allow-read pwa-audit.ts --manifest public/manifest.json
* deno run --allow-read pwa-audit.ts --project-root ./
* deno run --allow-read pwa-audit.ts --json
*/
// === INTERFACES ===
interface AuditResult {
category: string;
check: string;
status: "pass" | "fail" | "warn" | "skip";
message: string;
fix?: string;
}
interface AuditReport {
timestamp: string;
manifestPath?: string;
projectRoot?: string;
summary: {
total: number;
passed: number;
failed: number;
warnings: number;
skipped: number;
};
results: AuditResult[];
score: number;
}
interface ManifestIcon {
src: string;
sizes: string;
type?: string;
purpose?: string;
}
interface WebAppManifest {
name?: string;
short_name?: string;
description?: string;
start_url?: string;
display?: string;
orientation?: string;
background_color?: string;
theme_color?: string;
scope?: string;
icons?: ManifestIcon[];
screenshots?: unknown[];
shortcuts?: unknown[];
categories?: string[];
}
// === AUDIT CHECKS ===
function auditManifest(manifest: WebAppManifest | null, manifestPath?: string): AuditResult[] {
const results: AuditResult[] = [];
if (!manifest) {
results.push({
category: "Manifest",
check: "Manifest exists",
status: "fail",
message: manifestPath ? `Could not read manifest at ${manifestPath}` : "No manifest path provided",
fix: "Create a manifest.json file and link it with <link rel=\"manifest\" href=\"/manifest.json\">",
});
return results;
}
// Required fields
results.push({
category: "Manifest",
check: "name field",
status: manifest.name ? "pass" : "fail",
message: manifest.name ? `name: "${manifest.name}"` : "Missing name field",
fix: "Add 'name' field with full app name (max 45 characters)",
});
results.push({
category: "Manifest",
check: "short_name field",
status: manifest.short_name ? "pass" : "fail",
message: manifest.short_name ? `short_name: "${manifest.short_name}"` : "Missing short_name field",
fix: "Add 'short_name' field (max 12 characters for home screen)",
});
if (manifest.short_name && manifest.short_name.length > 12) {
results.push({
category: "Manifest",
check: "short_name length",
status: "warn",
message: `short_name is ${manifest.short_name.length} characters (recommended max: 12)`,
fix: "Shorten short_name to 12 characters or less",
});
}
results.push({
category: "Manifest",
check: "start_url field",
status: manifest.start_url ? "pass" : "fail",
message: manifest.start_url ? `start_url: "${manifest.start_url}"` : "Missing start_url field",
fix: "Add 'start_url' field (typically '/' or '/index.html')",
});
results.push({
category: "Manifest",
check: "display field",
status: manifest.display ? "pass" : "fail",
message: manifest.display ? `display: "${manifest.display}"` : "Missing display field",
fix: "Add 'display' field: 'standalone', 'fullscreen', 'minimal-ui', or 'browser'",
});
if (manifest.display && !["standalone", "fullscreen", "minimal-ui", "browser"].includes(manifest.display)) {
results.push({
category: "Manifest",
check: "display value",
status: "fail",
message: `Invalid display value: "${manifest.display}"`,
fix: "Use one of: standalone, fullscreen, minimal-ui, browser",
});
}
// Colors
results.push({
category: "Manifest",
check: "background_color field",
status: manifest.background_color ? "pass" : "warn",
message: manifest.background_color ? `background_color: "${manifest.background_color}"` : "Missing background_color",
fix: "Add 'background_color' for splash screen",
});
results.push({
category: "Manifest",
check: "theme_color field",
status: manifest.theme_color ? "pass" : "warn",
message: manifest.theme_color ? `theme_color: "${manifest.theme_color}"` : "Missing theme_color",
fix: "Add 'theme_color' to style browser UI",
});
// Icons
if (!manifest.icons || manifest.icons.length === 0) {
results.push({
category: "Manifest",
check: "icons field",
status: "fail",
message: "No icons defined",
fix: "Add icons array with at least 192x192 and 512x512 PNG icons",
});
} else {
results.push({
category: "Manifest",
check: "icons field",
status: "pass",
message: `${manifest.icons.length} icon(s) defined`,
});
const sizes = manifest.icons.map(i => i.sizes);
results.push({
category: "Manifest",
check: "192x192 icon",
status: sizes.some(s => s?.includes("192")) ? "pass" : "fail",
message: sizes.some(s => s?.includes("192")) ? "192x192 icon present" : "Missing 192x192 icon",
fix: "Add icon with sizes: '192x192'",
});
results.push({
category: "Manifest",
check: "512x512 icon",
status: sizes.some(s => s?.includes("512")) ? "pass" : "fail",
message: sizes.some(s => s?.includes("512")) ? "512x512 icon present" : "Missing 512x512 icon",
fix: "Add icon with sizes: '512x512' (required for splash screen)",
});
// Check for maskable icon
const hasMaskable = manifest.icons.some(i => i.purpose?.includes("maskable"));
results.push({
category: "Manifest",
check: "maskable icon",
status: hasMaskable ? "pass" : "warn",
message: hasMaskable ? "Maskable icon present" : "No maskable icon defined",
fix: "Add icon with purpose: 'maskable' for adaptive icons on Android",
});
}
// Scope
if (manifest.scope && manifest.start_url) {
const scopeValid = manifest.start_url.startsWith(manifest.scope);
results.push({
category: "Manifest",
check: "scope contains start_url",
status: scopeValid ? "pass" : "warn",
message: scopeValid ? "start_url is within scope" : "start_url may be outside scope",
fix: "Ensure start_url path begins with scope path",
});
}
// Optional but recommended
results.push({
category: "Manifest",
check: "description field",
status: manifest.description ? "pass" : "warn",
message: manifest.description ? "Description present" : "Missing description (recommended)",
fix: "Add 'description' for app stores and search results",
});
results.push({
category: "Manifest",
check: "screenshots field",
status: manifest.screenshots && manifest.screenshots.length > 0 ? "pass" : "warn",
message: manifest.screenshots?.length ? `${manifest.screenshots.length} screenshot(s)` : "No screenshots (recommended for richer install UI)",
fix: "Add 'screenshots' array for enhanced install experience",
});
return results;
}
function auditIosCompatibility(manifest: WebAppManifest | null): AuditResult[] {
const results: AuditResult[] = [];
results.push({
category: "iOS Compatibility",
check: "apple-touch-icon reminder",
status: "warn",
message: "Verify <link rel=\"apple-touch-icon\"> exists in HTML",
fix: "Add <link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\"> (180x180)",
});
results.push({
category: "iOS Compatibility",
check: "apple-mobile-web-app-capable reminder",
status: "warn",
message: "Verify meta tag exists for standalone mode on iOS",
fix: "Add <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">",
});
results.push({
category: "iOS Compatibility",
check: "apple-mobile-web-app-status-bar-style reminder",
status: "warn",
message: "Consider status bar styling for iOS",
fix: "Add <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\">",
});
if (manifest?.theme_color) {
results.push({
category: "iOS Compatibility",
check: "theme-color meta tag reminder",
status: "warn",
message: "iOS needs theme-color as meta tag, not just manifest",
fix: `Add <meta name="theme-color" content="${manifest.theme_color}">`,
});
}
results.push({
category: "iOS Compatibility",
check: "iOS limitations awareness",
status: "warn",
message: "iOS has PWA limitations: no beforeinstallprompt, limited background sync, storage eviction after 7 days",
fix: "Test on real iOS devices. Consider manual 'Add to Home Screen' instructions for iOS users.",
});
return results;
}
function auditBestPractices(): AuditResult[] {
const results: AuditResult[] = [];
results.push({
category: "Best Practices",
check: "HTTPS requirement",
status: "warn",
message: "Service workers require HTTPS (except localhost)",
fix: "Ensure production deployment uses HTTPS",
});
results.push({
category: "Best Practices",
check: "Offline fallback page reminder",
status: "warn",
message: "Verify offline.html or offline fallback exists",
fix: "Create offline.html and cache it in service worker install event",
});
results.push({
category: "Best Practices",
check: "Cache versioning reminder",
status: "warn",
message: "Verify cache names include version for updates",
fix: "Use cache names like 'app-cache-v1' and delete old caches on activate",
});
results.push({
category: "Best Practices",
check: "Update notification reminder",
status: "warn",
message: "Consider notifying users when new version is available",
fix: "Implement 'New version available' UI instead of silent updates",
});
results.push({
category: "Best Practices",
check: "Lighthouse audit reminder",
status: "warn",
message: "Run Lighthouse PWA audit in Chrome DevTools",
fix: "Open DevTools > Lighthouse > Check 'Progressive Web App' > Analyze",
});
return results;
}
// === REPORT GENERATION ===
function generateReport(results: AuditResult[], manifestPath?: string, projectRoot?: string): AuditReport {
const summary = {
total: results.length,
passed: results.filter(r => r.status === "pass").length,
failed: results.filter(r => r.status === "fail").length,
warnings: results.filter(r => r.status === "warn").length,
skipped: results.filter(r => r.status === "skip").length,
};
// Score: pass = 1, warn = 0.5, fail = 0
const maxScore = summary.total;
const actualScore = summary.passed + (summary.warnings * 0.5);
const score = Math.round((actualScore / maxScore) * 100);
return {
timestamp: new Date().toISOString(),
manifestPath,
projectRoot,
summary,
results,
score,
};
}
function formatReport(report: AuditReport): string {
const lines: string[] = [];
lines.push("=".repeat(60));
lines.push("PWA AUDIT REPORT");
lines.push("=".repeat(60));
lines.push("");
lines.push(`Timestamp: ${report.timestamp}`);
if (report.manifestPath) lines.push(`Manifest: ${report.manifestPath}`);
if (report.projectRoot) lines.push(`Project Root: ${report.projectRoot}`);
lines.push("");
lines.push("-".repeat(60));
lines.push("SUMMARY");
lines.push("-".repeat(60));
lines.push(`Score: ${report.score}/100`);
lines.push(`Total Checks: ${report.summary.total}`);
lines.push(` Passed: ${report.summary.passed}`);
lines.push(` Failed: ${report.summary.failed}`);
lines.push(` Warnings: ${report.summary.warnings}`);
lines.push("");
// Group by category
const categories = [...new Set(report.results.map(r => r.category))];
for (const category of categories) {
const categoryResults = report.results.filter(r => r.category === category);
lines.push("-".repeat(60));
lines.push(category.toUpperCase());
lines.push("-".repeat(60));
for (const result of categoryResults) {
const statusIcon = {
pass: "[PASS]",
fail: "[FAIL]",
warn: "[WARN]",
skip: "[SKIP]",
}[result.status];
lines.push(`${statusIcon} ${result.check}`);
lines.push(` ${result.message}`);
if (result.status !== "pass" && result.fix) {
lines.push(` Fix: ${result.fix}`);
}
lines.push("");
}
}
// Failures summary
const failures = report.results.filter(r => r.status === "fail");
if (failures.length > 0) {
lines.push("=".repeat(60));
lines.push("REQUIRED FIXES");
lines.push("=".repeat(60));
for (const failure of failures) {
lines.push(`- ${failure.check}: ${failure.fix}`);
}
lines.push("");
}
return lines.join("\n");
}
// === ARGUMENT PARSING ===
function parseArgs(args: string[]): { manifestPath?: string; projectRoot?: string; json?: boolean } {
let manifestPath: string | undefined;
let projectRoot: string | undefined;
let json = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const nextArg = args[i + 1];
switch (arg) {
case "--help":
case "-h":
printHelp();
Deno.exit(0);
break;
case "--manifest":
case "-m":
manifestPath = nextArg;
i++;
break;
case "--project-root":
case "-p":
projectRoot = nextArg;
i++;
break;
case "--json":
json = true;
break;
}
}
return { manifestPath, projectRoot, json };
}
function printHelp(): void {
console.log(`
PWA Audit
Validate PWA configuration against best practices checklist.
USAGE:
pwa-audit.ts [OPTIONS]
OPTIONS:
--manifest, -m <path> Path to manifest.json file
--project-root, -p <path> Project root directory (auto-finds manifest)
--json Output as JSON
--help, -h Show this help
CHECKS PERFORMED:
Manifest:
- Required fields (name, short_name, start_url, display, icons)
- Icon sizes (192x192, 512x512, maskable)
- Colors (background_color, theme_color)
- Scope and start_url alignment
iOS Compatibility:
- Apple-specific meta tags reminders
- iOS limitations awareness
Best Practices:
- HTTPS requirement
- Offline fallback
- Cache versioning
- Update notification
EXAMPLES:
# Audit a specific manifest
pwa-audit.ts --manifest public/manifest.json
# Auto-find manifest in project
pwa-audit.ts --project-root ./
# JSON output for CI/CD
pwa-audit.ts --manifest manifest.json --json
`);
}
// === MAIN ===
async function main(): Promise<void> {
let { manifestPath, projectRoot, json } = parseArgs(Deno.args);
// Auto-find manifest if project root provided
if (projectRoot && !manifestPath) {
const possiblePaths = [
`${projectRoot}/manifest.json`,
`${projectRoot}/public/manifest.json`,
`${projectRoot}/static/manifest.json`,
`${projectRoot}/src/manifest.json`,
];
for (const path of possiblePaths) {
try {
await Deno.stat(path);
manifestPath = path;
break;
} catch {
// Continue to next path
}
}
}
// Read manifest
let manifest: WebAppManifest | null = null;
if (manifestPath) {
try {
const content = await Deno.readTextFile(manifestPath);
manifest = JSON.parse(content);
} catch {
// Will be reported as failure in audit
}
}
// Run audits
const results: AuditResult[] = [
...auditManifest(manifest, manifestPath),
...auditIosCompatibility(manifest),
...auditBestPractices(),
];
const report = generateReport(results, manifestPath, projectRoot);
if (json) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log(formatReport(report));
}
// Exit with error if any failures
if (report.summary.failed > 0) {
Deno.exit(1);
}
}
main();
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* Service Worker Scaffolder
*
* Generates service worker code based on selected caching strategy and framework.
* Supports vanilla JS, Workbox, and Vite PWA plugin configurations.
*
* Usage:
* deno run --allow-read --allow-write sw-scaffolder.ts --strategy cache-first
* deno run --allow-read --allow-write sw-scaffolder.ts --framework workbox --output sw.js
* deno run --allow-read --allow-write sw-scaffolder.ts --routes routes.json
*/
// === INTERFACES ===
type CachingStrategy = "cache-first" | "network-first" | "stale-while-revalidate" | "network-only" | "cache-only";
type Framework = "vanilla" | "workbox" | "vite-pwa" | "sveltekit";
interface RouteConfig {
pattern: string;
strategy: CachingStrategy;
cacheName?: string;
maxAge?: number;
maxEntries?: number;
}
interface ScaffolderConfig {
strategy: CachingStrategy;
framework: Framework;
routes?: RouteConfig[];
cacheName?: string;
precacheUrls?: string[];
offlineFallback?: string;
skipWaiting?: boolean;
clientsClaim?: boolean;
}
// === TEMPLATES ===
const VANILLA_TEMPLATE = `// Service Worker - Generated by pwa-development skill
// Strategy: {{STRATEGY}}
const CACHE_NAME = '{{CACHE_NAME}}';
const CACHE_VERSION = 'v1';
const FULL_CACHE_NAME = \`\${CACHE_NAME}-\${CACHE_VERSION}\`;
// URLs to precache during install
const PRECACHE_URLS = [
{{PRECACHE_URLS}}
];
// Offline fallback page
const OFFLINE_FALLBACK = '{{OFFLINE_FALLBACK}}';
// Install event - precache assets
self.addEventListener('install', (event) => {
console.log('[SW] Install event');
event.waitUntil(
caches.open(FULL_CACHE_NAME)
.then((cache) => {
console.log('[SW] Precaching assets');
return cache.addAll(PRECACHE_URLS);
})
.then(() => {
{{SKIP_WAITING}}
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Activate event');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name.startsWith(CACHE_NAME) && name !== FULL_CACHE_NAME)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
{{CLIENTS_CLAIM}}
})
);
});
// Fetch event - apply caching strategy
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
event.respondWith(
{{FETCH_HANDLER}}
);
});
// Handle offline fallback
async function handleOffline(request) {
if (request.mode === 'navigate' && OFFLINE_FALLBACK) {
const cache = await caches.open(FULL_CACHE_NAME);
return cache.match(OFFLINE_FALLBACK);
}
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
`;
const FETCH_HANDLERS: Record<CachingStrategy, string> = {
"cache-first": `
caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request)
.then((response) => {
// Don't cache non-success responses
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone response for caching
const responseToCache = response.clone();
caches.open(FULL_CACHE_NAME)
.then((cache) => cache.put(event.request, responseToCache));
return response;
})
.catch(() => handleOffline(event.request));
})
`,
"network-first": `
fetch(event.request)
.then((response) => {
if (!response || response.status !== 200) {
throw new Error('Network response not ok');
}
const responseToCache = response.clone();
caches.open(FULL_CACHE_NAME)
.then((cache) => cache.put(event.request, responseToCache));
return response;
})
.catch(() => {
return caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return handleOffline(event.request);
});
})
`,
"stale-while-revalidate": `
caches.match(event.request)
.then((cachedResponse) => {
const fetchPromise = fetch(event.request)
.then((response) => {
if (response && response.status === 200) {
const responseToCache = response.clone();
caches.open(FULL_CACHE_NAME)
.then((cache) => cache.put(event.request, responseToCache));
}
return response;
})
.catch(() => cachedResponse || handleOffline(event.request));
return cachedResponse || fetchPromise;
})
`,
"network-only": `
fetch(event.request)
.catch(() => handleOffline(event.request))
`,
"cache-only": `
caches.match(event.request)
.then((cachedResponse) => {
return cachedResponse || handleOffline(event.request);
})
`,
};
const WORKBOX_TEMPLATE = `// Service Worker with Workbox - Generated by pwa-development skill
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// Precache static assets (populated by build tool)
precacheAndRoute(self.__WB_MANIFEST || []);
{{ROUTE_REGISTRATIONS}}
// Offline fallback
import { setCatchHandler } from 'workbox-routing';
import { matchPrecache } from 'workbox-precaching';
setCatchHandler(async ({ request }) => {
if (request.destination === 'document') {
return matchPrecache('{{OFFLINE_FALLBACK}}');
}
return Response.error();
});
{{SKIP_WAITING}}
{{CLIENTS_CLAIM}}
`;
const VITE_PWA_CONFIG_TEMPLATE = `// vite.config.ts PWA configuration
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: '{{REGISTER_TYPE}}',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: {
name: 'Your App Name',
short_name: 'App',
description: 'Your app description',
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: [
{{RUNTIME_CACHING}}
]
}
})
]
});
`;
const SVELTEKIT_TEMPLATE = `// src/service-worker.ts - SvelteKit Service Worker
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE_NAME = \`cache-\${version}\`;
const ASSETS = [
...build, // the app itself
...files // static files
];
// Install - cache all static assets
sw.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(ASSETS))
.then(() => {
{{SKIP_WAITING}}
})
);
});
// Activate - clean up old caches
sw.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => {
return Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
})
.then(() => {
{{CLIENTS_CLAIM}}
})
);
});
// Fetch - {{STRATEGY}} strategy
sw.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
{{FETCH_HANDLER}}
);
});
`;
// === GENERATION ===
function generateVanilla(config: ScaffolderConfig): string {
let output = VANILLA_TEMPLATE;
output = output.replace("{{STRATEGY}}", config.strategy);
output = output.replace("{{CACHE_NAME}}", config.cacheName || "app-cache");
output = output.replace("{{OFFLINE_FALLBACK}}", config.offlineFallback || "/offline.html");
const precacheUrls = config.precacheUrls || ["/", "/offline.html"];
output = output.replace(
"{{PRECACHE_URLS}}",
precacheUrls.map(url => ` '${url}',`).join("\n")
);
output = output.replace(
"{{SKIP_WAITING}}",
config.skipWaiting ? "self.skipWaiting();" : "// Call self.skipWaiting() to activate immediately"
);
output = output.replace(
"{{CLIENTS_CLAIM}}",
config.clientsClaim ? "self.clients.claim();" : "// Call self.clients.claim() to take control immediately"
);
output = output.replace("{{FETCH_HANDLER}}", FETCH_HANDLERS[config.strategy]);
return output;
}
function generateWorkbox(config: ScaffolderConfig): string {
let output = WORKBOX_TEMPLATE;
output = output.replace("{{OFFLINE_FALLBACK}}", config.offlineFallback || "/offline.html");
// Generate route registrations
const routes = config.routes || [
{ pattern: "/api/", strategy: "network-first" as CachingStrategy, cacheName: "api-cache", maxAge: 60 },
{ pattern: /\.(png|jpg|jpeg|svg|gif|webp)$/.toString(), strategy: "cache-first" as CachingStrategy, cacheName: "image-cache", maxEntries: 50 },
{ pattern: /\.(js|css)$/.toString(), strategy: "stale-while-revalidate" as CachingStrategy, cacheName: "static-cache" },
];
const routeRegistrations = routes.map(route => {
const strategyClass = {
"cache-first": "CacheFirst",
"network-first": "NetworkFirst",
"stale-while-revalidate": "StaleWhileRevalidate",
"network-only": "NetworkOnly",
"cache-only": "CacheFirst", // Workbox doesn't have CacheOnly, use CacheFirst with networkTimeoutSeconds: 0
}[route.strategy];
const plugins: string[] = [];
if (route.maxEntries) {
plugins.push(`new ExpirationPlugin({ maxEntries: ${route.maxEntries} })`);
}
if (route.maxAge) {
plugins.push(`new ExpirationPlugin({ maxAgeSeconds: ${route.maxAge} })`);
}
plugins.push("new CacheableResponsePlugin({ statuses: [0, 200] })");
const pattern = route.pattern.startsWith("/")
? `({ url }) => url.pathname.startsWith('${route.pattern}')`
: route.pattern;
return `
registerRoute(
${pattern},
new ${strategyClass}({
cacheName: '${route.cacheName || "runtime-cache"}',
plugins: [
${plugins.join(",\n ")}
]
})
);`;
}).join("\n");
output = output.replace("{{ROUTE_REGISTRATIONS}}", routeRegistrations);
output = output.replace(
"{{SKIP_WAITING}}",
config.skipWaiting ? "self.skipWaiting();" : ""
);
output = output.replace(
"{{CLIENTS_CLAIM}}",
config.clientsClaim ? "self.clients.claim();" : ""
);
return output;
}
function generateVitePwaConfig(config: ScaffolderConfig): string {
let output = VITE_PWA_CONFIG_TEMPLATE;
output = output.replace(
"{{REGISTER_TYPE}}",
config.skipWaiting ? "autoUpdate" : "prompt"
);
// Generate runtime caching config
const routes = config.routes || [
{ pattern: "/api/", strategy: "network-first" as CachingStrategy },
];
const runtimeCaching = routes.map(route => {
const handler = {
"cache-first": "CacheFirst",
"network-first": "NetworkFirst",
"stale-while-revalidate": "StaleWhileRevalidate",
"network-only": "NetworkOnly",
"cache-only": "CacheOnly",
}[route.strategy];
return ` {
urlPattern: ${route.pattern.startsWith("/") ? `new RegExp('^${route.pattern}')` : route.pattern},
handler: '${handler}',
options: {
cacheName: '${route.cacheName || "runtime-cache"}',
expiration: {
maxEntries: ${route.maxEntries || 10},
maxAgeSeconds: ${route.maxAge || 60 * 60 * 24}
}
}
}`;
}).join(",\n");
output = output.replace("{{RUNTIME_CACHING}}", runtimeCaching);
return output;
}
function generateSvelteKit(config: ScaffolderConfig): string {
let output = SVELTEKIT_TEMPLATE;
output = output.replace("{{STRATEGY}}", config.strategy);
output = output.replace(
"{{SKIP_WAITING}}",
config.skipWaiting ? "sw.skipWaiting();" : ""
);
output = output.replace(
"{{CLIENTS_CLAIM}}",
config.clientsClaim ? "sw.clients.claim();" : ""
);
// Adapt fetch handler for SvelteKit
const fetchHandler = FETCH_HANDLERS[config.strategy]
.replace(/self\./g, "sw.")
.replace(/FULL_CACHE_NAME/g, "CACHE_NAME");
output = output.replace("{{FETCH_HANDLER}}", fetchHandler);
return output;
}
function generate(config: ScaffolderConfig): string {
switch (config.framework) {
case "vanilla":
return generateVanilla(config);
case "workbox":
return generateWorkbox(config);
case "vite-pwa":
return generateVitePwaConfig(config);
case "sveltekit":
return generateSvelteKit(config);
default:
return generateVanilla(config);
}
}
// === ARGUMENT PARSING ===
function parseArgs(args: string[]): { config: ScaffolderConfig; output?: string } {
const config: ScaffolderConfig = {
strategy: "cache-first",
framework: "vanilla",
skipWaiting: false,
clientsClaim: false,
};
let output: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const nextArg = args[i + 1];
switch (arg) {
case "--help":
case "-h":
printHelp();
Deno.exit(0);
break;
case "--strategy":
case "-s":
config.strategy = nextArg as CachingStrategy;
i++;
break;
case "--framework":
case "-f":
config.framework = nextArg as Framework;
i++;
break;
case "--cache-name":
config.cacheName = nextArg;
i++;
break;
case "--offline-fallback":
config.offlineFallback = nextArg;
i++;
break;
case "--skip-waiting":
config.skipWaiting = true;
break;
case "--clients-claim":
config.clientsClaim = true;
break;
case "--output":
case "-o":
output = nextArg;
i++;
break;
case "--routes":
try {
const routesContent = Deno.readTextFileSync(nextArg);
config.routes = JSON.parse(routesContent);
} catch {
console.error(`Error reading routes file: ${nextArg}`);
Deno.exit(1);
}
i++;
break;
}
}
return { config, output };
}
function printHelp(): void {
console.log(`
Service Worker Scaffolder
Generate service worker code with various caching strategies and frameworks.
USAGE:
sw-scaffolder.ts [OPTIONS]
OPTIONS:
--strategy, -s <strategy> Caching strategy:
cache-first, network-first, stale-while-revalidate,
network-only, cache-only (default: cache-first)
--framework, -f <framework> Target framework:
vanilla, workbox, vite-pwa, sveltekit (default: vanilla)
--cache-name <name> Cache name prefix (default: app-cache)
--offline-fallback <path> Offline fallback page (default: /offline.html)
--skip-waiting Call skipWaiting() immediately
--clients-claim Call clients.claim() on activate
--routes <file> JSON file with route configurations
--output, -o <file> Output file path (default: stdout)
--help, -h Show this help
STRATEGIES:
cache-first Check cache first, then network (static assets)
network-first Try network first, fall back to cache (API calls)
stale-while-revalidate Return cache immediately, update in background
network-only Always fetch from network
cache-only Only serve from cache
FRAMEWORKS:
vanilla Plain JavaScript service worker
workbox Workbox-based service worker with imports
vite-pwa Vite PWA plugin configuration snippet
sveltekit SvelteKit-compatible service worker
EXAMPLES:
# Basic cache-first service worker
sw-scaffolder.ts --strategy cache-first -o sw.js
# Workbox with network-first for APIs
sw-scaffolder.ts --framework workbox --strategy network-first -o sw.js
# Vite PWA configuration
sw-scaffolder.ts --framework vite-pwa --skip-waiting
# SvelteKit with custom routes
sw-scaffolder.ts --framework sveltekit --routes routes.json -o src/service-worker.ts
`);
}
// === MAIN ===
async function main(): Promise<void> {
const { config, output } = parseArgs(Deno.args);
const code = generate(config);
if (output) {
await Deno.writeTextFile(output, code);
console.log(`Service worker written to ${output}`);
console.log(`\nConfiguration:`);
console.log(` Strategy: ${config.strategy}`);
console.log(` Framework: ${config.framework}`);
console.log(` Skip Waiting: ${config.skipWaiting}`);
console.log(` Clients Claim: ${config.clientsClaim}`);
} else {
console.log(code);
}
}
main();
Related skills
How it compares
Use pwa-development for browser-installable offline web apps; pick native mobile skills when platform APIs or store distribution require iOS or Android binaries.
FAQ
What PWA components does pwa-development cover?
pwa-development covers service workers, web app manifests, responsive UI, and app-like navigation for installable progressive web apps. The skill targets SaaS and mobile-first products that need offline capability without native iOS or Android store distribution.
When should developers choose PWA over native apps?
Developers should choose pwa-development when a web product needs installability and offline support through browser standards. pwa-development fits teams avoiding native store releases while still delivering home-screen installation and cached offline UX.