3.2 KiB
service-worker/service-worker.ts
(no submodules)
Purpose
ServiceWorkerManager wraps the browser ServiceWorkerContainer API behind an
injectable seam so production code reaches navigator.serviceWorker exactly once
(via the constructor default) while tests pass an in-process fake container that
drives every lifecycle transition deterministically. The manager handles
registration, update polling, skipWaiting coordination, state-change emission,
and OTel counter updates without any vi.stubGlobal shenanigans.
Features
- Registration with scope, custom
scriptUrl,onUpdate/onSuccess/onErrorcallbacks, and configurable update-poll interval. - Lifecycle state machine —
installing → installed → activating → activated → redundantplusunsupportedfor runtimes lackingServiceWorkerContainer. skipWaiting()posts{ type: "SKIP_WAITING" }to the waiting worker.unregister()clears the update timer and tears down the registration.checkForUpdate()invokesregistration.update()and returns success.- Typed errors via
PWA_CODES.SW_UNSUPPORTED/PWA_CODES.SW_REGISTRATION_FAILED. - OTel counters for
pwa_sw_installed_total,pwa_sw_activated_total,pwa_sw_updated_total.
Usage: Basic
import { ServiceWorkerManager } from "@suaveplan/pwa";
const swm = new ServiceWorkerManager();
const result = await swm.register({ scriptUrl: "/sw.js" });
if (!result.success && result.error !== undefined) {
console.error(result.error.code, result.error.message);
}
Usage: Advanced
Inject a fake container + interval scheduler for deterministic tests:
import { ServiceWorkerManager } from "@suaveplan/pwa";
const swm = new ServiceWorkerManager({
container: fakeContainer,
intervals: {
setInterval: (cb, ms) => fakeTimers.schedule(cb, ms),
clearInterval: (h) => fakeTimers.cancel(h),
},
observability: testObs,
});
await swm.register({ scriptUrl: "/sw.js", updateInterval: 100 });
fakeTimers.tick(100);
expect(fakeContainer.lastRegistration?.update).toHaveBeenCalled();
API Reference
new ServiceWorkerManager(options?: ServiceWorkerManagerOptions).state: SwState— current lifecycle state (readonly).registration: ServiceWorkerRegistration | null.isSupported: boolean.register(options): Promise<SwRegistrationResult>.unregister(): Promise<boolean>.checkForUpdate(): Promise<boolean>.skipWaiting(): void.onStateChange(listener): () => void.
Implementation Notes
The manager never reaches globalThis.navigator from the body of any method —
this._container is captured in the constructor and threaded through every
subsequent call so an injected fake completely replaces the real container.
The interval scheduler is a separate seam so tests can advance time without
hooking the global timer, which under happy-dom + Vitest's fork pool can race
the next test file. Errors surfaced from container.register() are wrapped in
a PwaError with the original thrown error preserved as cause, so callers
can instanceof PwaError + error.code === "PWA.SW_REGISTRATION_FAILED" and
still recover the underlying TypeError / SecurityError via error.cause.