ServiceWorker
Register, inspect, update and unregister service workers, and exchange messages with the worker controlling the page - a strongly-typed wrapper over navigator.serviceWorker.
@inject Bit.Butil.ServiceWorker serviceWorkerMDN reference
/sw.js that does no caching - it exists purely so the registration-based APIs on this page
(and on the BackgroundSync and Push pages) have something real to register and inspect.
Returns true when the runtime exposes navigator.serviceWorker. Service workers are available in every modern browser, but not in insecure contexts.
var supported = await serviceWorker.IsSupported();Registers a same-origin worker script and resolves with a snapshot of the created registration. Optional parameters control the scope, the update-via-cache strategy (imports, all or none) and whether the script is loaded as an ES module.
Bit.Butil.ServiceWorker serviceWorker
<button @onclick="Register">Register /sw.js</button>
{
private async Task Register()
{
var info = await serviceWorker.Register("/sw.js");
// info.IsRegistered -> true
// info.Scope -> e.g. "https://example.com/"
// info.ActiveState -> "activated" once the worker is running
// info.InstallingState / info.WaitingState / info.UpdateViaCache
}
}// The script the registration points at. It has to be same-origin, and its location decides the
// widest scope it may claim: a worker at /sw.js can control the whole origin, one at /app/sw.js
// only /app/.
// Deliberately no self.skipWaiting() here. A first registration activates immediately anyway
// (nothing is controlling the page yet), while a worker installed over a running one waits - which
// is the only state ServiceWorker.SkipWaiting has anything to do in.
self.addEventListener('install', () => {
console.log('[sw] installed; waiting if another version is still in control');
});
self.addEventListener('activate', event => {
// Takes over the pages that were already open when this worker activated. Without it the first
// visit after an install is the one where a worker's offline support quietly does not work.
event.waitUntil(self.clients.claim());
});GetRegistration returns the registration matching a scope, or the most specific one for the current URL when the scope is omitted; IsRegistered is false when none exists. Update forces the browser to re-fetch the worker script and check for a newer version.
var info = await serviceWorker.GetRegistration();
if (info.IsRegistered)
{
await serviceWorker.Update();
}PostMessage sends a payload to the worker currently controlling this page and returns false when there is no controller yet. SubscribeMessage receives payloads the worker posts back as JsonElement values, and SubscribeControllerChange fires whenever the controlling worker changes - for example right after the demo worker activates and claims this page. Both return a ButilSubscription; dispose it to detach.
IAsyncDisposable
Bit.Butil.ServiceWorker serviceWorker
{
private string? _lastEcho;
private ButilSubscription? _messageSub;
private ButilSubscription? _controllerSub;
private async Task Subscribe()
{
_messageSub = await serviceWorker.SubscribeMessage(data =>
{
// data is a JsonElement carrying whatever the worker posted
_lastEcho = data.GetProperty("echo").GetString();
InvokeAsync(StateHasChanged);
});
_controllerSub = await serviceWorker.SubscribeControllerChange(() =>
{
// the page is now controlled by a (new) worker
InvokeAsync(StateHasChanged);
});
}
// False when nothing controls this page yet - a first registration does not control the page
// that registered it until the worker claims it.
private async Task Post()
{
var delivered = await serviceWorker.PostMessage("hi worker");
}
public async ValueTask DisposeAsync()
{
if (_messageSub is not null) await _messageSub.DisposeAsync();
if (_controllerSub is not null) await _controllerSub.DisposeAsync();
}
}// The other half of the conversation. A page posts to the worker controlling it; the worker answers
// every client it controls, because there is no single "the page" from in here.
self.addEventListener('message', event => {
// Skip the internal protocol Bit.Butil's SkipWaiting / Claim / MatchAllClients speak.
if (event.data && event.data.__butil) return;
event.waitUntil(self.clients.matchAll({ type: 'window' })
.then(clients => clients.forEach(client => client.postMessage({ echo: event.data }))));
});
self.addEventListener('activate', event => {
// Claiming is what fires SubscribeControllerChange on a page that loaded uncontrolled.
event.waitUntil(self.clients.claim());
});Both of these can only be done by the worker to itself, so these post a message and the worker acts on it. SkipWaiting promotes a worker that has installed and is waiting for the old one's pages to go away - the 'reload to update' button, without the reload. Claim takes control of pages that loaded before the worker activated, which is why the first visit after an install is the one where offline support quietly does not work.
Bit.Butil.ServiceWorker serviceWorker
{
// False when there was no worker waiting - a first registration activates on its own.
private async Task Promote()
{
var hadWaitingWorker = await serviceWorker.SkipWaiting();
}
// Claim answers over a MessageChannel, so it is false when no worker answered in time as well
// as when the call failed inside the worker.
private async Task Take()
{
var claimed = await serviceWorker.Claim();
}
}// Both facilities live on the worker's global scope and cannot be reached from a page at all, so
// the page asks and the worker acts. This protocol is what the two calls expect - a worker without
// it leaves SkipWaiting and Claim with nothing to talk to.
self.addEventListener('message', event => {
if (event.data?.__butil === 'skipWaiting') self.skipWaiting();
if (event.data?.__butil === 'claim') {
// The reply goes back over the port the page sent along with the message. Answer even on
// failure: with no reply the page waits out its whole timeout and then reads the default.
event.waitUntil(self.clients.claim()
.then(() => event.ports[0]?.postMessage(true), () => event.ports[0]?.postMessage(false)));
}
});Which tabs, iframes and workers the service worker controls. The Clients API exists only on the worker's global scope, so this is a question asked over a MessageChannel - and an empty result is ambiguous: the worker may have answered with no clients (this page is not in the list while it is uncontrolled and includeUncontrolled is false), or there was no active worker, or none answered.
Bit.Butil.ServiceWorker serviceWorker
{
private async Task ListClients()
{
ServiceWorkerClientInfo[] clients = await serviceWorker.MatchAllClients(
includeUncontrolled: true, type: "window");
// An empty array is ambiguous: no clients, no active worker, or none answered in time.
foreach (var client in clients)
{
// client.Id, client.Url, client.Type, client.FrameType,
// client.Focused, client.VisibilityState
}
}
}// The Clients API exists only on the worker's global scope, so the question is asked over a
// MessageChannel and answered here. A Client is not serializable, so only the fields the page reads
// go back across.
self.addEventListener('message', event => {
if (event.data?.__butil !== 'clients') return;
event.waitUntil(self.clients
.matchAll({ includeUncontrolled: event.data.includeUncontrolled, type: event.data.type })
.then(clients => event.ports[0]?.postMessage(clients.map(c => ({
id: c.id, url: c.url, type: c.type, frameType: c.frameType,
focused: c.focused, visibilityState: c.visibilityState
}))), () => event.ports[0]?.postMessage([])));
});Removes the registration matching the given scope, or the current one when omitted, and returns true when a registration was actually removed. Butil never unregisters workers automatically on disposal - the consuming app decides when a worker goes away.
var removed = await serviceWorker.Unregister();API reference
ValueTask<bool> IsSupported()ValueTask<ServiceWorkerRegistrationInfo> Register(string scriptUrl, string? scope = null, string? updateViaCache = null, bool moduleType = false)ValueTask<ServiceWorkerRegistrationInfo> GetRegistration(string? scope = null)ValueTask Update(string? scope = null)ValueTask<bool> Unregister(string? scope = null)ValueTask<bool> PostMessage<T>(T message)Task<ButilSubscription> SubscribeMessage(Action<JsonElement> handler)Task<ButilSubscription> SubscribeControllerChange(Action handler)ValueTask DisposeAsync()void InvokeServiceWorkerMessage(Guid id, JsonElement data)void InvokeServiceWorkerControllerChange(Guid id)bool IsRegistered { get; set; }string Scope { get; set; }string? ActiveState { get; set; }string? InstallingState { get; set; }string? WaitingState { get; set; }string? UpdateViaCache { get; set; }ValueTask<ServiceWorkerRegistrationInfo[]> GetRegistrations()ValueTask<ServiceWorkerRegistrationInfo> Ready(int timeoutMs = 10000)ValueTask<bool> EnableNavigationPreload(string? scope = null)ValueTask<bool> DisableNavigationPreload(string? scope = null)ValueTask<bool> SetNavigationPreloadHeader(string value, string? scope = null)ValueTask<NavigationPreloadState> GetNavigationPreloadState(string? scope = null)ValueTask<bool> SkipWaiting(string? scope = null)ValueTask<bool> Claim(string? scope = null, int timeoutMs = 5000)ValueTask<ServiceWorkerClientInfo[]> MatchAllClients(bool includeUncontrolled = false, string type = "window", string? scope = null, int timeoutMs = 5000)class NavigationPreloadState { bool IsSupported; bool Enabled; string HeaderValue; }class ServiceWorkerClientInfo { string Id; string Url; string Type; string FrameType; bool Focused; string VisibilityState; }