@maxnate/pwa-core
v0.1.0
Published
Framework-agnostic PWA / offline runtime primitives: cache strategies, mutation replay queue, web app manifest builder, service worker registration. Zero runtime dependencies.
Downloads
103
Maintainers
Readme
@maxnate/pwa-core
Framework-agnostic PWA / offline runtime primitives.
Zero runtime dependencies. ESM only. Targets ES2022. Ships with full TypeScript types.
Modules
| Module | Purpose |
|---|---|
| offline-queue | Mutation replay queue with pluggable storage, exponential backoff with full jitter, idempotency-key support, permanent-error eviction |
| idb-storage | IndexedDB-backed OfflineQueueStorage adapter — survives reloads, tab restarts, and process death |
| cache-strategies | Workbox-style strategies: cacheFirst, networkFirst, staleWhileRevalidate, networkOnly |
| manifest | Web App Manifest (W3C) builder + validator |
| service-worker | navigator.serviceWorker.register wrapper with update detection + safe SSR/feature-detect fallback |
Quick start
Offline mutation queue
import { createOfflineQueue, OfflineQueuePermanentError } from '@maxnate/pwa-core'
const queue = createOfflineQueue({
async replay(mutation) {
const res = await fetch(`/api/${mutation.kind}`, {
method: 'POST',
headers: { 'Idempotency-Key': mutation.id, 'Content-Type': 'application/json' },
body: JSON.stringify(mutation.payload)
})
if (res.status >= 400 && res.status < 500) {
throw new OfflineQueuePermanentError(`HTTP ${res.status}`) // evict, don't retry
}
if (!res.ok) throw new Error(`HTTP ${res.status}`) // retry with backoff
}
})
// User taps "Save" while offline
await queue.enqueue({ kind: 'create.invoice', payload: { amount: 12345 } })
// Network is back
window.addEventListener('online', () => queue.flush())Persistent storage (IndexedDB)
The default in-memory storage is lost on reload. For real PWAs, use the IndexedDB adapter:
import { createOfflineQueue, createIndexedDBStorage } from '@maxnate/pwa-core'
const queue = createOfflineQueue({
storage: createIndexedDBStorage({ dbName: 'myapp-offline', storeName: 'mutations' }),
async replay(mutation) { /* ... */ }
})
// Mutations enqueued in one tab survive reload, tab close, and browser restart.
// Multiple tabs sharing the same dbName see the same queue (last-write-wins per id).Throws a clear error if globalThis.indexedDB is unavailable (SSR, very old browsers) — no silent in-memory fallback. Pass a polyfill via the indexedDB option for tests:
import { createIndexedDBStorage } from '@maxnate/pwa-core'
import FDBFactory from 'fake-indexeddb/lib/FDBFactory.js'
const storage = createIndexedDBStorage({ indexedDB: new FDBFactory() })Cache strategies (inside a service worker)
import { staleWhileRevalidate, networkFirst } from '@maxnate/pwa-core'
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
if (url.pathname.startsWith('/api/')) {
event.respondWith(caches.open('api').then(c => networkFirst(event.request, c)))
} else if (url.pathname.startsWith('/static/')) {
event.respondWith(caches.open('static').then(c => staleWhileRevalidate(event.request, c)))
}
})Manifest builder
import { buildManifest, serializeManifest } from '@maxnate/pwa-core'
const manifest = buildManifest({
name: 'My App',
short_name: 'App',
start_url: '/',
scope: '/',
display: 'standalone',
theme_color: '#1e40af',
background_color: '#ffffff',
icons: [
{ src: '/icons/192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
})
writeFileSync('public/manifest.webmanifest', serializeManifest(manifest, true))Throws ManifestValidationError (with full issues[] list) on invalid input.
Service worker registration with update prompt
import { registerServiceWorker } from '@maxnate/pwa-core'
const result = await registerServiceWorker({
scriptUrl: '/sw.js',
onUpdateAvailable(reload) {
showToast('Update available', { action: 'Reload', onAction: reload })
},
onError: console.error
})
if (!result.supported) {
// SSR, file://, very old browsers — render a degradation banner if needed.
}Design principles
- Storage is pluggable. The offline queue ships with an in-memory adapter for tests and an IndexedDB adapter (
createIndexedDBStorage) for browsers; hosts can wire localStorage / Capacitor SQLite / custom backends via theOfflineQueueStoragecontract. - Tests run in Node.js. Cache primitives use a structural
CacheLikecontract (match,put) — they work with the browser Cache API in production and with aMap-backed mock in tests. - No global state. All factories return self-contained instances. Multiple queues, multiple SW registrations are first-class.
- Feature-detect, never throw.
registerServiceWorkerreturns{ supported: false }rather than throwing on SSR / private browsing / file://.
What's not in this package
- Service worker file generation — ships with your build pipeline.
- Push notifications, Background Sync, Periodic Sync — separate concerns.
- Per-platform legacy tags (
apple-touch-icon, etc.) — your HTML.
Testing
npm run build && npm test54 unit tests across the five modules. No DOM required — IndexedDB tests run against a self-contained in-memory fake.
