ContentIndex
Tell the browser which pages your app can already show offline, so it can offer them to the user itself - in a list the user reaches without opening your app at all.
@inject Bit.Butil.ContentIndex contentIndexMDN reference
Warning:
Chromium only, and it needs a service worker
The API hangs off an active ServiceWorkerRegistration, so
IsSupported is
false until one is registered. In Chromium on Android the entries appear under
Downloads → Articles for you; on desktop Chromium they are registered but there is
no UI that lists them. Firefox and Safari do not implement it.
Note:
An entry is a claim, not a copy
Indexing stores a title, a description, icons and a URL - nothing else. Cache the page first
(see CacheStorage): an indexed URL that fails offline is worse
than no entry, and Chromium removes entries whose cached response goes away.
The same minimal /sw.js the rest of this category uses. Registering it is what makes registration.index exist.
Bit.Butil.ServiceWorker serviceWorker
Bit.Butil.ContentIndex contentIndex
<button @onclick="RegisterWorker">Register /sw.js</button>
{
private async Task RegisterWorker()
{
await serviceWorker.Register("/sw.js");
await serviceWorker.Ready();
// registration.index only exists once there is an active registration to hang it off.
var supported = await contentIndex.IsSupported();
}
}self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
// Indexing an entry is a claim that its URL is available offline, and nothing but a fetch handler
// can make that true: the browser navigating to an indexed entry with no network reaches this, and
// only this. An index with no fetch handler behind it advertises pages that will not load.
self.addEventListener('fetch', event => {
// Navigations only - that is the request an indexed entry produces, and leaving everything else
// unhandled keeps the rest of the site on the network path it has without a worker.
if (event.request.mode !== 'navigate' || event.request.method !== 'GET') return;
event.respondWith(caches.open('butil-content-index')
// ignoreSearch so a query string the browser appends on its way to an indexed page does not
// turn a hit into a miss.
.then(cache => cache.match(event.request, { ignoreSearch: true }))
// A cache that cannot be opened must not take the navigation down with it.
.catch(() => undefined)
.then(cached => cached || fetch(event.request)));
});
// Fired when the user removes an entry from the browser's own UI. The cached response has to go
// with it, or the fetch handler above serves a page nothing advertises any more.
self.addEventListener('contentdelete', event => {
event.waitUntil(caches.open('butil-content-index')
.then(cache => cache.keys().then(keys => Promise.all(keys
.filter(request => request.url.includes(event.id))
.map(request => cache.delete(request))))));
});
Live sample
support output
Results will appear here when you interact with the samples.
The URL has to be inside the worker's scope, and Chromium requires at least one icon it can fetch. Adding the same id twice replaces the earlier entry rather than adding a second one. The button below caches the page first, so the entry is honest.
Bit.Butil.ContentIndex contentIndex
Bit.Butil.CacheStorage cacheStorage
{
private async Task AddEntry()
{
// The entry is only honest if the URL really is available offline, so the page goes into the
// cache the worker's fetch handler reads before it is advertised.
await cacheStorage.Add("butil-content-index", "/articles/42");
var added = await contentIndex.Add(new ContentIndexEntry
{
Id = "article-42",
Title = "The article's title",
Description = "One line about it, shown under the title.",
Url = "/articles/42",
Category = "article",
Icons = [new ContentIndexIcon { Src = "/icon-512.png", Sizes = "512x512", Type = "image/png" }]
});
}
}// The cache the page just wrote to is the one this handler reads, which is what makes the entry
// true. Nothing else in the browser connects the two - the index is a list of promises, and this is
// what keeps them.
self.addEventListener('fetch', event => {
if (event.request.mode !== 'navigate' || event.request.method !== 'GET') return;
event.respondWith(caches.open('butil-content-index')
.then(cache => cache.match(event.request, { ignoreSearch: true }))
.catch(() => undefined)
.then(cached => cached || fetch(event.request)));
});
Live sample
add output
Results will appear here when you interact with the samples.
GetAll is the only honest answer to what is currently indexed: the browser removes entries on its own too - when the user deletes one from its UI, or when the cached response behind the URL goes away.
ContentIndexEntry[] entries = await contentIndex.GetAll();
foreach (var entry in entries)
{
// entry.Id, entry.Title, entry.Url, entry.Icons
}
await contentIndex.Delete("article-42");
Live sample
index output
Results will appear here when you interact with the samples.
API reference
Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()True when the active service worker registration exposes index.
Add
ValueTask<bool> Add(ContentIndexEntry entry)Registers one piece of offline-available content, replacing any entry with the same id.
Delete
ValueTask<bool> Delete(string id)Removes an entry. Removing an id that is not there is not an error.
GetAll
ValueTask<ContentIndexEntry[]> GetAll()Everything currently indexed for this service worker registration.
ContentIndexEntry
class ContentIndexEntry { string Id; string Title; string Description; string Url; string Category; ContentIndexIcon[] Icons; }One indexed piece of content. Url must be inside the worker's scope.
ContentIndexIcon
class ContentIndexIcon { string Src; string Sizes; string Type; string Label; }One icon for the browser's UI. Fetched while the entry is registered, so an unreachable one fails the Add.