offline
Everything a SPA needs to work fully offline — split into the two places the work actually happens.
At build time, the offline-postbuild CLI
reads offline.json, collects your build's files, generates
a service worker (sw-min.js) with a precache manifest
(critical-assets.json) and injects a small bootstrap script
into index.html. At runtime, the
OfflineTracker class registers that worker, warms the cache
and answers one question precisely: is this app ready to work
offline? — as a boolean, a subscription and a promise. No rxjs, no
signals — plain TypeScript and promises.
Getting started
Install#
npm i @dmytromykhailiuk/offline @dmytromykhailiuk/network-connection
OfflineTracker consumes
@dmytromykhailiuk/network-connection for every
online/offline decision and never initializes it on its
own — the app owns that singleton.
OfflineTracker.init() throws immediately when
NetworkConnection.init() has not been called first.
Quick start#
Three pieces: a config file next to your project, one script in the build pipeline, a few lines at app startup.
1. Describe the offline layer in offline.json:
{
"name": "Client",
"buildPath": "/dist",
"themeColor": "#1c1c1c",
"icons": [
{ "src": "icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }
],
"criticalAssets": ["**.js", "**.css", "**.svg", "/images/logo.png"],
"lazyLoadAssets": ["/images/*.jpg"],
"dataGroups": [
{ "name": "translations", "urls": ["/translations/"], "maxSize": 1 }
]
}
2. Run the CLI after every build:
{
"scripts": {
"postbuild": "offline-postbuild"
}
}
3. Wire the runtime at startup — here with a blocking screen while the connection is down and the app is not yet ready to work offline:
import { NetworkConnection } from "@dmytromykhailiuk/network-connection";
import { OfflineTracker } from "@dmytromykhailiuk/offline";
await NetworkConnection.init("/health.txt");
OfflineTracker.init({ disabled: import.meta.env.DEV });
OfflineTracker.registerServiceWorker();
const networkUnsubscribeFn = NetworkConnection.subscribe((isOnline) => {
if (!isOnline && !OfflineTracker.isOfflineReady && !NetworkBlockingScreen.isVisible()) {
NetworkBlockingScreen.show();
}
if (isOnline && NetworkBlockingScreen.isVisible()) {
NetworkBlockingScreen.hide();
}
});
OfflineTracker.whenOfflineReady().then(() => networkUnsubscribeFn());
init() only kicks the process off. A repeat visit —
online or offline — initializes from the cached
critical-assets.json (a client-side cache-first
bucket via @dmytromykhailiuk/cache-request,
invalidated by the bootstrap's new-build cache wipe); only on a
first-ever offline visit is there nothing to fall back on, so
the cache warms up once the connection appears. Follow the
progress through subscribe(),
whenOfflineReady() and isOfflineReady.
The worker and the manifest exist only after
offline-postbuild runs over a real build. In dev,
initialize with disabled: true — the tracker stays
readable but inert: no requests, no listeners, no registration,
and isOfflineReady stays false.
How it works#
-
The CLI walks the build directory, matches files
against the
criticalAssets/lazyLoadAssetspatterns and writes the resulting pathnames — prefixed withdeploymentPath— intocritical-assets.jsonand into the generated worker. It also stampsbuild-timestamp.txtand injects the bootstrap script intoindex.html. -
The worker serves SPA routes network-first from a
dedicated index cache, assets cache-first, and
dataGroupsURLs cache-first into named caches. It also understands a diagnostic header: a request carryingX-Cache-Only: trueis answered from the cache or failed — never sent to the network. - The tracker uses that header to ask, asset by asset, "is this cached yet?". Whatever is missing gets fetched (which makes the worker cache it), re-checked on an interval, paused while offline, resumed on reconnect — until everything critical is confirmed. Lazy assets do the same in the background.
Build time
offline.json#
Lives wherever you point --config at (default: the
directory you run the CLI from). Every path is resolved relative to
the config file's directory — a leading / is a
convention, not a filesystem root.
Every field#
The built app's directory, e.g. "/dist". Everything
the CLI generates lands here.
The app's name, shown at install time — name of the
generated manifest.
short_name of the manifest — the homescreen label.
theme_color of the manifest.
background_color of the manifest.
How the installed app is displayed —
"standalone" is its own window without browser UI
(the PWA default), "fullscreen" hides even the
status bar, "minimal-ui" keeps a minimal set of
navigation controls, "browser" opens as a regular
tab. Anything else fails validation.
Manifest icons, passed through verbatim. src is
resolved by the browser relative to the manifest file —
deployment-agnostic relative paths like
"icons/icon-192x192.png" just work.
The path prefix the app is deployed under, e.g.
"/client" when the app is served from
https://host/client/. It is baked into every
generated pathname, into the worker's route matching and into
the window.__OFFLINE_CONFIG__ global the runtime
reads. Leave it out for root deployments.
The built HTML entry inside buildPath.
Pathname prefixes of your SPA routes. Requests under any of
them (plus the deployment root and the index itself) are
served with the cached index.html, network-first.
An empty prefix would turn every request into a SPA
route — hence the non-empty default.
The request header the worker forwards when fetching on behalf of the page — set it when your assets sit behind a custom auth header.
Patterns of files the app cannot work offline without —
Angular-PWA-style globs. ** matches across path
segments ("**.js" — every JS file at any depth),
* within one segment
("/images/*.png", "*-test-*/**.js"),
? exactly one character
("/icon-??.png"), and a ! prefix
excludes — a file must match at least one plain pattern and
no ! pattern
(["**.js", "!**.spec.js"]). Anything else is an
exact path. The matched files become
critical-assets.json — the list
OfflineTracker verifies.
Same pattern language, lower urgency: cached by the worker and loaded by the tracker in the background, without blocking critical readiness. A file matched by both lists counts as critical.
Named runtime caches for data the build does not contain —
themes, fonts, translations, models. Matched by
urls prefixes and/or patterns
globs. E.g.
[{ "name": "translations", "urls": ["/translations/"], "maxSize": 1 }]
serves everything under /translations/
cache-first from its own capped cache. Every field —
dataGroups.
dataGroups#
Named runtime caches for data the build does not contain — themes,
fonts, translations, models. A request belongs to a group — and is
served cache-first from data-group-<name> — when
its pathname starts with any of the group's
urls prefixes or matches any
of its patterns.
Cache name suffix; must be unique across groups.
Pathname prefixes, e.g. ["/translations/"]. The CLI
prefixes them with deploymentPath at generation
time, so write them deployment-agnostic.
Glob patterns matched against the request pathname — the
same language as
criticalAssets:
** across segments, * within one,
? a single character, anything else an exact
path. Compiled at build time (by the same matcher) and, like
urls, made deployment-aware automatically —
write them deployment-agnostic. A group may combine
urls and patterns; either list may
be omitted as long as the other is non-empty.
Cap on entries in the group's cache; the oldest entry is evicted
first. Useful for versioned resources where only the current one
matters — maxSize: 1 keeps exactly the latest.
"dataGroups": [
{ "name": "translations", "urls": ["/translations/"], "maxSize": 1 },
{ "name": "images", "patterns": ["**.webp", "**.avif"] },
{ "name": "themes", "urls": ["/themes/"], "patterns": ["**/skins/**.css"] }
]
The generated manifest#
Every run generates manifest.webmanifest in the build
directory — built from the flat fields above (name,
shortName, themeColor,
backgroundColor, display,
icons) — and adds
<link rel="manifest"> to the injected head block,
making the app installable with zero extra steps. camelCase config
keys map to the spec's snake_case on output.
Both derive from deploymentPath —
"/client" becomes
"scope": "/client/" and
"start_url": "/client/" (root deployments get
"/"). One source of truth for the worker, the
tracker, the bootstrap and the manifest.
// offline.json (manifest-related fields)
{
"name": "Client",
"deploymentPath": "/client",
"buildPath": "/dist",
"themeColor": "#1c1c1c",
"icons": [
{
"src": "core-assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "core-assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}
// → dist/manifest.webmanifest
{
"name": "Client",
"short_name": "Client",
"theme_color": "#1c1c1c",
"background_color": "#ffffff",
"display": "standalone",
"scope": "/client/",
"start_url": "/client/",
"icons": [ ... ]
}
The CLI#
offline-postbuild # reads ./offline.json
offline-postbuild --config ./config/offline.json
Inside buildPath, in order:
build-timestamp.txt— the build'sDate.now(), the new-version detector;- collects assets by the config patterns (its own outputs are excluded, so re-runs never precache a stale worker);
critical-assets.json— the deployment-prefixed critical list;manifest.webmanifest— the web app manifest, withscope/start_urlderived fromdeploymentPath;sw-min.js— the worker, generated from the template with your config baked in and minified in memory (no intermediatesw.jsever touches the disk);- injects the bootstrap script and the manifest
<link>as the first thing inside<head>of the built index.
The injected block is fenced with marker comments; a re-run replaces it instead of stacking a second copy. Running the CLI twice produces byte-identical HTML.
The service worker#
The generated sw-min.js routes every fetch by four rules, first match wins:
A request carrying X-Cache-Only: true is answered
from the matching cache, or failed without touching the network.
This is the tracker's probe: a failed probe means "not cached
yet", never "server said no".
The deployment root, the index pathname and everything under
any spaRoutesPaths prefix are served with
index.html — fresh from the network when possible,
from the index cache when not.
Every pathname in the generated critical + lazy lists. Cached on first successful fetch, then served from the cache forever — a new build's timestamp check resets it.
Matched by url prefixes and/or glob patterns
into data-group-<name> caches, with
maxSize eviction where configured.
Asset matching compares url.pathname against the
generated lists verbatim. Query strings are ignored by the browser's
pathname, but a mapAssetUrl that rewrites the
pathname would make the tracker request URLs the worker
does not recognize as assets — keep mapped pathnames identical to
the built files.
The bootstrap script#
Injected by the CLI as the first script in <head>,
in plain pre-ES2015 JS, so it runs before any bundle on any browser.
Four jobs:
-
Publish the build config.
window.__OFFLINE_CONFIG__ = { deploymentPath }— the convention that letsregisterServiceWorker()work with no arguments, at any moment, in any order. -
Fix the worker scope. A visit to
/app(no trailing slash) is redirected to/app/— a worker registered at/app/sw-min.jsdoes not control the slashless URL. -
Recover from chunk errors. A
ChunkLoadErrormeans a lazy chunk was requested that is neither cached nor reachable — the page reloads once, as soon as the connection returns. -
Detect new builds.
build-timestamp.txtis fetched cache-bypassing and compared with the remembered one; a mismatch clears every cache so the worker re-caches the new version.
Runtime
OfflineTracker#
A class used purely through its static members — the constructor is private and throws. There is exactly one offline state per app, and the type system (plus a runtime guard) makes an accidental second one impossible.
class OfflineTracker {
static init(options?: OfflineTrackerOptions): void;
static reInit(options?: OfflineTrackerReInitOptions): void;
static registerServiceWorker(): void;
static get status(): OfflineStatus;
static get isOfflineReady(): boolean;
static subscribe(listener: (isOfflineReady: boolean) => void): OfflineTrackerUnsubscribe;
static whenOfflineReady(): Promise<void>;
static stabilizeCaching(): Promise<void>;
static destroy(): void;
}
Every stateful member throws before init() — a default
value would be a plausible-looking lie about the cache. The two
exceptions: registerServiceWorker() is deliberately
independent, and destroy() is a safe no-op.
init(options?)#
Synchronous, returns nothing — it kicks the whole process off and
the async work runs in the background. The first call verifies the
NetworkConnection precondition (throws synchronously
when missing), wires the controllerchange re-track and
the reconnect auto-stabilization, fetches
critical-assets.json through a client-side
cache-first bucket
(@dmytromykhailiuk/cache-request, bucket
offline-critical-assets): the cached copy is served
when present — no network round-trip per session — and a miss goes
to the network, HTTP-cache-bypassing, caching the ok response. The
bucket is invalidated by the bootstrap's
new-build cache wipe, so a fresh build means a fresh list. With no
cached copy the fetch is retried until an ok response
arrives (the CLI always generates the file, so a 404/5xx is
transient deploy state; offline stretches are waited out via
NetworkConnection.continueWhenOnline()) — and starts
tracking: probe everything with
X-Cache-Only, fetch what's missing (the worker caches
it), re-probe on retryDelay, park while offline,
resume on reconnect. Critical assets gate readiness directly;
additionalLazyLoadAssets load in the background and
flip isOfflineReady when
done.
Callable once — the configuration it resolves is
final, and a second call throws. Restart tracking with
reInit(); reconfigure via
destroy() + init().
Turns the whole tracker into an inert no-op — for dev
environments, where the offline layer does not exist. Pass
your dev flag, e.g.
disabled: import.meta.env.DEV. While disabled
nothing runs — no requests, no listeners,
registerServiceWorker() skips registration,
stabilizeCaching() resolves without doing
anything, reInit() calls stay no-ops — and
isOfflineReady stays false. The
NetworkConnection precondition is skipped too.
Use destroy() to leave it.
Called before every request the tracker makes — the
list, the cache probes, the warming loads — with a
Headers instance holding what is about to be
sent; returns the Headers to actually send
(mutating and returning the same instance is fine). Attach an
auth token here — the headers analog of
mapAssetUrl; see Recipes.
Transforms every asset URL (built list + additional) before it is requested — e.g. substituting the current theme or translation version into a templated path.
Consulted before an automatic
stabilizeCaching() run
after a reconnect. Return false to skip it — say,
while the user is mid-upload.
Milliseconds to wait after serviceWorker.ready
before loading, giving a fresh worker time to take over the
page.
Milliseconds between load-and-recheck rounds.
Milliseconds to wait after the connection returns before a paused loop resumes.
Extra assets that must be cached — on top of
critical-assets.json — before the app counts as
ready. Later reInit() calls merge into this
list: every cycle tracks the accumulated, deduplicated union.
Extra assets loaded in the background. They gate
isOfflineReady but load without blocking the
critical set. Merged across reInit() calls, like
the critical ones.
reInit(options?)#
// The flow-manager table arrived and brought its own assets:
OfflineTracker.reInit({
additionalCriticalAssets: table.additionalAssetsToLoad,
additionalLazyLoadAssets: table.additionalLazyLoadAssets,
});
Restart tracking with more assets — the only two options it takes
are additionalCriticalAssets and
additionalLazyLoadAssets; the configuration (hooks,
delays, disabled) belongs to init() and
cannot change here. Synchronous, returns nothing, callable any
number of times. Throws before init(); a no-op while
initialized with disabled: true.
-
Merging: the passed lists join the deduplicated
union of everything given to
init()and earlierreInit()calls — a later caller can never drop what an earlier one declared critical. The union always sits on top ofcritical-assets.json, which is not refetched. - Superseding: the previous cycle stops silently; readiness resets and is re-verified from scratch.
- controllerchange: a new worker taking over re-runs tracking with the accumulated union automatically — a new worker means a new, empty cache.
registerServiceWorker()#
Registers origin + deploymentPath + "/sw-min.js" with
updateViaCache: "none", the deployment path taken from
the CLI-injected window.__OFFLINE_CONFIG__ — the single
source of truth. Synchronous, returns nothing — like
init(), it kicks the registration off and the browser
does the rest in the background. No parameters, independent of
init() — call it at any moment, in either order. A
silent no-op where service workers do not exist (SSR, dev without
the postbuild, unsupported browsers). When
NetworkConnection is initialized, the registration
survives connection drops via
restartIfNotFinishedWhenOnline; a registration that
genuinely fails is reported to the console — there is no meaningful
recovery the caller could do.
status & isOfflineReady#
OfflineTracker.status;
// { criticalAssetsLoaded: boolean, lazyAssetsLoaded: boolean, allAssetsLoaded: boolean }
OfflineTracker.isOfflineReady; // shortcut for status.allAssetsLoaded
status returns a fresh snapshot object on every read;
isOfflineReady is the single boolean the app usually
cares about. Both throw before init().
subscribe(listener)#
const unsubscribe = OfflineTracker.subscribe((isOfflineReady) => {
banner.hidden = isOfflineReady;
});
unsubscribe(); // detach; calling it again is a no-op
Fires on every change of isOfflineReady — in
both directions (re-initializing against a wiped cache drops readiness
first). The listener is not called on subscription: the current value
is already available synchronously. A throwing listener is contained
and reported; subscribing or unsubscribing from inside a listener is
safe; destroy() drops every subscription without a final
call.
whenOfflineReady()#
Resolves immediately when ready, otherwise on the next transition to
ready. destroy() rejects pending waiters — leaving them
hanging would deadlock the code awaiting them.
stabilizeCaching()#
The repair path. Without a controlling, active worker the cache state is untrustworthy — every cache is deleted and the page reloads to start clean. With a healthy worker, the still-missing assets get one load-and-recheck round, flipping readiness if that closed the gap.
It runs automatically after the connection comes
back while the app is not ready (gated by
shouldStabilize), and it can be called manually at any
time. Before the first tracking cycle populates the missing-asset sets it is a deliberate no-op
— an empty "missing" set proves nothing.
destroy()#
Undo init(): stop every loop, detach the
controllerchange and network listeners, drop
subscriptions without a final call, reject pending
whenOfflineReady() waiters, reset all state. A no-op
when not initialized. Built for tests and HMR teardown — production
apps normally never call it.
Recipes#
Auth-protected assets#
OfflineTracker.init({
modifyRequestHeaders: (headers) => {
headers.set("Authorization", getToken());
return headers;
},
});
The worker forwards the header named by authHeaderPath in
offline.json when it fetches on the page's behalf.
Versioned asset paths#
OfflineTracker.init({
mapAssetUrl: (url) =>
url
.replace("{theme}", currentTheme())
.replace("{lang}", currentLanguage()),
});
Route-scoped stabilization#
OfflineTracker.init({
shouldStabilize: () =>
!location.pathname.includes("/upload") &&
!location.pathname.includes("/completed"),
});
Assets that arrive with data#
// Call reInit() whenever the server tells you what else matters —
// the new assets merge into the tracked union, tracking restarts,
// the list is not refetched.
onTableLoaded((table) => {
OfflineTracker.reInit({
additionalCriticalAssets: table.additionalAssetsToLoad ?? [],
additionalLazyLoadAssets: table.additionalLazyLoadAssets ?? [],
});
});
TypeScript#
Everything is typed; the package ships ESM + CJS with
.d.ts for both. Exported types:
OfflineTrackerOptions ·
OfflineTrackerReInitOptions ·
OfflineStatus · OfflineTrackerListener ·
OfflineTrackerUnsubscribe ·
OfflineGlobalConfig · OfflineConfig ·
OfflineDataGroup · OfflineManifestDisplay ·
OfflineManifestIcon.
OfflineConfig / OfflineDataGroup describe
offline.json — handy for generating the config from
typed build scripts. The window.__OFFLINE_CONFIG__
global is declared on Window, so reading it in app code
type-checks out of the box.