Offline-capable asset loading
Warm a persistent cache ahead of time, and keep the application working - and failing honestly - when the network is gone.
Offline-capable asset loading
A cache makes a second session faster. It only makes an application work offline if two more things are true: the assets were put there before the connection went away, and the loader refuses to reach for the network once it has.
Both are explicit. Nothing here happens by itself.
The shape of it
import { Application, AssetCache, ConnectivityPolicyResolver, IndexedDbStore, type LoaderOptions } from '@codexo/exojs';
const loader: LoaderOptions = {
basePath: '/assets/',
cache: new AssetCache({
stores: new IndexedDbStore('my-game'),
policy: new ConnectivityPolicyResolver(),
}),
};
const app = new Application({ loader });
app.connectivity is the application’s own, created for you. The resolver holds no connectivity of its own: it is asked, once per acquisition, which policy that acquisition runs under, and reads the answer from a snapshot the loader hands it.
That is what makes one AssetCache safe to share between two applications - each acquisition carries its own application’s answer, so they can disagree about whether they may use the network.
Warm the cache while you can
loader.cacheSource(asset) acquires an asset’s source and lets the cache keep it - without building the asset:
await app.loader.cacheSource(Asset.type('json', 'levels/01.json'));
await app.loader.cacheSource(Asset.type('json', 'text/dialogue.json'));Nothing is constructed, nothing is claimed, and nothing stays in memory. What remains is a cache record, which a later load() of the same asset finds because both derive the same source identity from the same descriptor.
Run it where a delay is acceptable and a connection is likely: a title screen, a settings toggle, the pause before a chapter.
Media is warmed the same way
Music and video are streamed by the browser while the network is available: the element owns the transfer and never holds more than it is playing, so an ordinary load() acquires nothing and caches nothing. cacheSource is you asking for the acquisition by name, so it applies to media too, from the very same descriptor:
await app.loader.cacheSource(Asset.type('music', 'theme.mp3'));Warming a source does not take streaming away. While the network is there, load() still streams that URL; the record is for when it is not.
Go offline
app.connectivity.mode = 'offline';
From the next acquisition on, the resolver picks a cache-only policy. That has two consequences, and both are the point:
- an asset whose source was warmed loads normally - the cache answers, and no request is made;
- an asset that was never warmed fails immediately with an
AssetCacheMissError, instead of waiting out a fetch that cannot succeed.
Streamed media follows the same rule, without the caller doing anything:
// Online: the browser streams it from the URL.
// Offline: the same call reads the warmed blob, or misses.
const theme = await this.loader.load(Asset.type('music', 'theme.mp3'));One descriptor covers all of it: cacheSource warms it, load streams or reads it back, and nothing in the descriptor names a transport.
Streaming reaches the network directly, past the cache and therefore past every policy. So when the network is forbidden, a normally-streamed request stops streaming and takes the acquisition path instead - which is the only way mode = 'offline' can mean what it says. No network-backed source is ever installed on the element.
try {
const world = await this.loader.load(assets.world);
} catch (error) {
if (error instanceof AssetCacheMissError) {
// Not "loading failed" - "this was never cached". A different message,
// and often a different recovery.
showNotDownloadedYet();
}
}Set mode back to 'auto' to follow the environment again, or to 'online' to keep using the network whatever the host reports.
The two questions, kept apart
Connectivity answers two different questions, and never confuses them:
| Means | Values | |
|---|---|---|
state |
what the environment appears to provide | 'online' | 'offline' | 'unknown' |
mode |
what the application allows | 'auto' | 'online' | 'offline' |
state is a hint. navigator.onLine === true says the device has a network interface, not that your origin answers - a captive portal, a dead resolver and a firewalled host all report as online. 'unknown' is a real answer for a host that reports nothing, and it counts as permitted: refusing on no evidence would break every environment that stays silent.
mode is your decision, and it wins. 'auto' follows state, which is what makes a dropped connection switch the application over without any code running.
Bringing your own
ApplicationOptions.connectivity accepts an instance you built - for a test, a custom host, or a service shared between applications. It is dependency injection, not something ordinary offline caching needs: an injected Connectivity is yours to dispose, and Application.destroy() leaves it alone.
Show it in the UI
The same object drives an offline banner. It is an ordinary runtime service - nothing about it is cache-specific:
app.connectivity.onStateChange.add(state => {
offlineBanner.visible = state === 'offline';
});
app.connectivity.onModeChange.add(mode => {
downloadButton.enabled = mode !== 'offline';
});Where media data lives
Media the application owns - warmed, unpacked from a container, or read back from the cache - is persisted as a Blob, not as bytes on the JavaScript heap, which is the difference between caching a large video and running out of memory trying. The element is then pointed at an object URL over that blob, and the URL lives exactly as long as the resource does:
Blob -> URL.createObjectURL -> <audio> / <video>
Releasing the asset revokes it. Revoking earlier would break a seek that leaves the buffered range, because the element re-reads its source.
A policy change never re-routes a request in flight
The policy is chosen when an acquisition starts. A request that was allowed to reach the network keeps that contract to completion, even if the connection drops or the application switches to offline mid-transfer. Only the next acquisition sees the new answer.
That is deliberate: a half-finished transfer being cancelled by an event it never observed is harder to reason about than one that simply finishes.
What this is not
This is a cache policy, not a service worker. The application still has to be loaded from somewhere: Connectivity and cacheSource cover the assets an already-running application needs, and say nothing about the document, the bundle, or a request your own code makes with fetch.

