Skip to content
Eugene Lazutkin edited this page Jul 10, 2026 · 2 revisions

Service Worker integration

The opt-in page half of double-meh's Service-Worker contract. It pairs with the double-meh-sw sibling (or any SW that speaks the same message contract): the page library keeps the semantics — inspectors, errors, options, promises — while a controlling SW amplifies with what only it can do: a cache tier shared across tabs and navigations, cross-tab wire coalescing, and bundling for pages that never heard of double-meh. Everything here is a performance adornment: with no SW present the app behaves identically.

import io from 'double-meh';
import {installSW} from 'double-meh/sw.js';

installSW(io);

installSW(io, options?) registers the sw transport, announces the library to a controlling SW, and maintains io.sw. Options: library (the name announced, default 'double-meh'), serviceWorker (injectable container, default navigator.serviceWorker), and helloTimeout (ms to wait for a hello reply, default 500).

The hello handshake

When a controlling SW is detected, the module announces io:hello {library}. A contract-speaking SW answers with its contract version, its own version, and its capabilities — and marks the page as a library client: the SW passes that page's traffic through instead of bundling it itself (client-wins — the page has the context: named bundles, explicit flushes). The announce repeats on controllerchange, because a new controller starts with an empty client registry.

The negotiated state lives on io.sw:

io.sw.supported; // Service Workers exist in this environment
io.sw.connected; // a SW answered the handshake
io.sw.contract; // the contract version it answered with
io.sw.version; // the SW's own version string
io.sw.capabilities; // e.g. ['cache', 'coalesce', 'bundle', 'invalidate', 'version', 'transport']

await io.sw.hello(); // re-announce manually: the state on reply, null on timeout or no SW

Every connect and disconnect also emits the sw event:

io.on('sw', state => console.log(state.connected ? 'SW attached' : 'SW detached'));

A hello that nobody answers (no SW, a foreign SW) resolves null after helloTimeout and leaves io.sw.connected false — the app works exactly as before.

The sw message transport

{transport: 'sw'} routes a request as a message to the SW, which fetches upstream and replies with the transferred body — instead of going through the page's own fetch(). This is the transport for the prefetch/adopt class, because it owns two things interception structurally cannot:

  • Uncontrolled pages — it reaches the SW registration before the SW controls the page (first visit included), where intercepting fetch() cannot engage yet.
  • Navigation-surviving requests — the SW completes the fetch even if the page navigates away, and lands the result in the shared cache tier: fire on page A, adopt on page B.
// prefetch: the result lands in the page cache AND the SW's shared tier
await io.get('/api/dashboard', null, {transport: 'sw'});

Constraints, by construction: request bodies do not cross the channel (writes throw FailedIO — interactive traffic belongs on the default fetch transport), abort signals and progress events do not cross either, and DevTools network attribution moves to the SW. Responses re-mint from the transferred bytes through the normal pipeline — decode, cache, track, and inspectors all apply.

The shared cache tier

The SW keeps its cache in the Cache API under the name io-shared (the contract's default). Two page-side writers can feed it:

  • io.bundle.writeThrough = true — unpacked bundle parts are written through under their real URLs, so the SW serves them to unadorned fetch() calls and to other tabs.
  • The sw transport — the SW itself stores every GET it fetches for the page.

A custom name (writeThrough: 'my-cache') must match the SW's configured cache tier; prefer the default unless the SW side is reconfigured too. The page-side storage backend cacheApiStorage is a different animal — it stores decoded envelopes under synthetic keys and does not collide with the shared tier.

Cross-tab invalidation

installChannel(io) closes the invalidation loop: invalidate /users/42 in one tab and every tab drops it — with a service worker as the hub, or with a bare BroadcastChannel('io') when there is none.

import io from 'double-meh';
import {installChannel} from 'double-meh/sw.js';

installChannel(io);

// this now evicts in every tab (and a controlling SW's shared tier):
await io.cache.remove('https://api.example.com/users/42');

How a removal travels:

  • String patterns propagate — an exact URL or a trailing-* prefix becomes a URL-prefix invalidation on the wire. A controlling SW receives io:invalidate, evicts its shared tier, and — when connected — broadcasts io:invalidated to every tab; with no connected SW the page broadcasts directly, so the cross-tab story works in the no-SW quadrant too.
  • Key-space RegExp and predicate removals stay local — they match stored keys, not URLs, and don't translate to the wire. So does io.cache.clear().
  • Incoming invalidations evict quietly — the local eviction covers all accept variants of the matched URLs and never re-broadcasts, so there are no loops. Note the receiving side treats an exact URL as a prefix: possible over-eviction is just a cache miss.

io.channel reports {name, active} and close() detaches everything (restores io.cache.remove, stops listening, closes the channel). Options: name (the channel, default 'io') and serviceWorker (injectable, as in installSW). On CLIs the channel is unrefed — it never holds the process open. installChannel composes with installSW but does not require it.

See also

Clone this wiki locally