@curiouslearning/sw
v1.0.0
Published
Service worker update-notification lifecycle fix plus registration/precaching boilerplate shared across Curious Learning apps.
Readme
@curiouslearning/sw
Service worker update-notification lifecycle fix, plus the closely related
registration and precaching boilerplate that FeedTheMonsterJS and
assessment-survey-js already duplicate independently. This package defines
that logic once so it can be imported everywhere instead of drifting again
(see the original fix: FTM src/sw-src.js, commit 9e1493da, 2026-05-15).
Why
The old pattern broadcast "update available" on the native updatefound
event, before the new worker had actually activated — a user-triggered
reload could still be served stale content. The fix here computes
isUpdate = !!self.registration.active at evaluation time and only
broadcasts once self.clients.claim() has resolved.
Install
npm install @curiouslearning/swworkbox-core, workbox-precaching, and workbox-routing (^7.4.1) are
peer dependencies — install them alongside this package if your service
worker doesn't already depend on them directly.
Usage
registerUpdateNotifier (worker-side)
Call synchronously, at the top level of the service worker script, after precaching/routing setup, exactly once:
// sw-src.ts
import { precacheAndRoute } from 'workbox-precaching';
import { registerUpdateNotifier } from '@curiouslearning/sw';
precacheAndRoute(self.__WB_MANIFEST);
registerUpdateNotifier(); // uses DEFAULT_CHANNEL_NAME / DEFAULT_READY_MESSAGEThrows a TypeError if called outside a service worker global scope
(i.e. self.registration is undefined).
registerServiceWorkerUpdates (client-side)
Call once per page load, wherever the app currently calls
navigator.serviceWorker.register:
import { registerServiceWorkerUpdates } from '@curiouslearning/sw';
await registerServiceWorkerUpdates({
swUrl: './sw.js',
mode: 'confirm', // default; omit unless overriding
});channelName/readyMessage must match whatever was passed to
registerUpdateNotifier() on the worker side (both default to the shared
constants below, so in most cases you don't need to pass either).
UpdateMode
| Mode | Behavior |
| --- | --- |
| 'confirm' (default) | Shows a blocking confirm() dialog when an update is ready; reloads the page on acceptance. Matches both FTM's and assessment-survey-js's current UX, so adopting this package doesn't change behavior. |
| 'silent' | No dialog. The new worker activates and takes over on the next natural navigation — use this when you don't want to interrupt the user at all. |
| 'custom' | Does nothing but invoke onUpdateAvailable; the app owns 100% of the UX (e.g. a toast/snackbar instead of a native dialog). onUpdateAvailable is required in this mode — omitting it throws a TypeError synchronously, before any registration work happens. |
onUpdateAvailable is optional for 'confirm' and 'silent' too — when
provided, it fires as a side hook (e.g. analytics/logging) alongside the
built-in behavior for those modes.
Shared constants and types
import {
DEFAULT_CHANNEL_NAME, // 'sw-update-channel'
DEFAULT_READY_MESSAGE, // 'UpdateReady'
CACHE_BUST_PARAM, // 'cache-bust'
} from '@curiouslearning/sw';
import type { SwMessageType, UpdateMode } from '@curiouslearning/sw';Import these instead of redeclaring your own string literals — this is what keeps the worker and client sides of the lifecycle in sync across apps.
cacheUrlsWithProgress
import { cacheUrlsWithProgress } from '@curiouslearning/sw';
const cache = await caches.open('app-assets-v1');
await cacheUrlsWithProgress(cache, assetUrls, {
timeoutMs: 8000,
batchSize: 10,
delayBetweenBatchesMs: 800,
onProgress: (pct) => postMessage({ type: 'Loading', percent: pct }),
onItemError: (url, error) => console.warn('Failed to cache', url, error),
});Individual item failures (fetch or cache.put()) are tolerated — they're
reported via onItemError and still counted toward progress, but never
reject the overall promise.
createInjectManifestOptions
// webpack.config.js
const { InjectManifest } = require('workbox-webpack-plugin');
const { createInjectManifestOptions } = require('@curiouslearning/sw');
new InjectManifest(createInjectManifestOptions({
globIgnores: ['**/audio/*.{mp3,wav}'],
}));Pure and synchronous — merges the shared defaults (globDirectory: 'build/',
a 10 MiB per-file precache ceiling, and standard swSrc/swDest
conventions) with whatever overrides you pass.
isCacheBustRequest
import { isCacheBustRequest } from '@curiouslearning/sw';
self.addEventListener('fetch', (event) => {
if (isCacheBustRequest(event.request.url)) return; // let the browser handle it
event.respondWith(/* normal cache strategy */);
});registerNavigationFallback
Call after precacheAndRoute() has run, synchronously during service
worker script evaluation:
import { precacheAndRoute } from 'workbox-precaching';
import { registerNavigationFallback } from '@curiouslearning/sw';
precacheAndRoute(self.__WB_MANIFEST);
registerNavigationFallback(); // default '/index.html', enabled: truePass { enabled: false } to skip registering the fallback route entirely
(e.g. for apps that don't do client-side routing).
Development
npm install
npm run typecheck # tsc --noEmit
npm test # jest — colocated *.spec.ts files under src/
npm run build # vite build -> dist/sw.mjs, dist/sw.cjs, dist/sw.d.tsSpec files are colocated beside the source file they test (*.spec.ts),
not in a separate test/ directory — moving or renaming a feature folder
carries its tests with it. Shared test infrastructure (the BroadcastChannel
and service-worker-global-scope mocks) lives in src/test-utils/mocks.ts.
Build output
The package builds dual ESM + CJS output via Vite library mode:
dist/sw.mjs— ESM build (import)dist/sw.cjs— CJS build (require)dist/sw.d.ts— rolled-up type declarations
Both are covered by a dual-build smoke test (src/index.spec.ts) that
require()s and dynamically import()s the built dist/ output directly,
catching exports map misconfiguration before publish.
