offline
offline 1.0.0

Full offline mode for SPAs. One post-build command, one tracker, plain promises.

Contents

Contents

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.

installable PWA one postbuild command generated service worker offline-ready tracking static-only API promise-based verified network state SSR-safe typed end to end

Getting started

Install#

sh
npm i @dmytromykhailiuk/offline @dmytromykhailiuk/network-connection
Hard precondition

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:

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:

json
{
  "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:

ts
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());
Readiness is asynchronous

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.

Dev is not this library's mode

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#

  1. The CLI walks the build directory, matches files against the criticalAssets / lazyLoadAssets patterns and writes the resulting pathnames — prefixed with deploymentPath — into critical-assets.json and into the generated worker. It also stamps build-timestamp.txt and injects the bootstrap script into index.html.
  2. The worker serves SPA routes network-first from a dedicated index cache, assets cache-first, and dataGroups URLs cache-first into named caches. It also understands a diagnostic header: a request carrying X-Cache-Only: true is answered from the cache or failed — never sent to the network.
  3. 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#

buildPathstring · required

The built app's directory, e.g. "/dist". Everything the CLI generates lands here.

namestring · required

The app's name, shown at install time — name of the generated manifest.

shortNamestring · default: name

short_name of the manifest — the homescreen label.

themeColorstring · default "#ffffff"

theme_color of the manifest.

backgroundColorstring · default "#ffffff"

background_color of the manifest.

display"standalone" | "fullscreen" | "minimal-ui" | "browser" · default "standalone"

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.

icons{ src, sizes, type?, purpose? }[] · default []

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.

deploymentPathstring · default ""

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.

indexstring · default "/index.html"

The built HTML entry inside buildPath.

spaRoutesPathsstring[] · default ["page"]

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.

authHeaderPathstring · default "Authorization"

The request header the worker forwards when fetching on behalf of the page — set it when your assets sit behind a custom auth header.

criticalAssetsstring[] · default []

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.

lazyLoadAssetsstring[] · default []

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.

dataGroups{ name, urls?, patterns?, maxSize? }[] · default []

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.

namestring · required

Cache name suffix; must be unique across groups.

urlsstring[] · at least one of urls / patterns

Pathname prefixes, e.g. ["/translations/"]. The CLI prefixes them with deploymentPath at generation time, so write them deployment-agnostic.

patternsstring[] · at least one of urls / patterns

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.

maxSizenumber · optional

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.

json
"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.

scope and start_url are not options

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.

json
// 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#

sh
offline-postbuild                # reads ./offline.json
offline-postbuild --config ./config/offline.json

Inside buildPath, in order:

  1. build-timestamp.txt — the build's Date.now(), the new-version detector;
  2. collects assets by the config patterns (its own outputs are excluded, so re-runs never precache a stale worker);
  3. critical-assets.json — the deployment-prefixed critical list;
  4. manifest.webmanifest — the web app manifest, with scope/start_url derived from deploymentPath;
  5. sw-min.js — the worker, generated from the template with your config baked in and minified in memory (no intermediate sw.js ever touches the disk);
  6. injects the bootstrap script and the manifest <link> as the first thing inside <head> of the built index.
Idempotent

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:

X-Cache-Only requestscache or failure

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".

SPA routesnetwork-first

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.

Assetscache-first

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.

dataGroupscache-first, capped

Matched by url prefixes and/or glob patterns into data-group-<name> caches, with maxSize eviction where configured.

Exact-pathname matching

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:

  1. Publish the build config. window.__OFFLINE_CONFIG__ = { deploymentPath } — the convention that lets registerServiceWorker() work with no arguments, at any moment, in any order.
  2. Fix the worker scope. A visit to /app (no trailing slash) is redirected to /app/ — a worker registered at /app/sw-min.js does not control the slashless URL.
  3. Recover from chunk errors. A ChunkLoadError means a lazy chunk was requested that is neither cached nor reachable — the page reloads once, as soon as the connection returns.
  4. Detect new builds. build-timestamp.txt is 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.

ts
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;
}
Fail-fast contract

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().

disabledboolean · default false

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.

modifyRequestHeaders(headers: Headers) => Headers · default identity

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.

mapAssetUrl(url: string) => string · default identity

Transforms every asset URL (built list + additional) before it is requested — e.g. substituting the current theme or translation version into a templated path.

shouldStabilize() => boolean · default () => true

Consulted before an automatic stabilizeCaching() run after a reconnect. Return false to skip it — say, while the user is mid-upload.

swSettleDelaynumber · default 1000

Milliseconds to wait after serviceWorker.ready before loading, giving a fresh worker time to take over the page.

retryDelaynumber · default 2500

Milliseconds between load-and-recheck rounds.

reconnectSettleDelaynumber · default 250

Milliseconds to wait after the connection returns before a paused loop resumes.

additionalCriticalAssetsstring[] · default []

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.

additionalLazyLoadAssetsstring[] · default []

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?)#

ts
// 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 earlier reInit() calls — a later caller can never drop what an earlier one declared critical. The union always sits on top of critical-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#

ts
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)#

ts
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#

ts
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#

ts
OfflineTracker.init({
  mapAssetUrl: (url) =>
    url
      .replace("{theme}", currentTheme())
      .replace("{lang}", currentLanguage()),
});

Route-scoped stabilization#

ts
OfflineTracker.init({
  shouldStabilize: () =>
    !location.pathname.includes("/upload") &&
    !location.pathname.includes("/completed"),
});

Assets that arrive with data#

ts
// 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.