Range reading and storage on the web, for players and other code that reads a file by offset: from a URL with HTTP range requests, a Blob, memory, or a source of your own. The reads go through a small memory buffer, and can be kept between plays in OPFS, IndexedDB, the Cache API or memory, under a size cap. A Service Worker does the same for <video> and <audio>, which make their own requests.
The alpha-video players (alpha-video-web and alpha-video-gpu-web) depend on it and re-export what an app needs to write its own source or store, so their users don't install it themselves.
npm install range-source-webAnything that reads a file by offset can read through it: a video or audio player's demuxer, a parser for images, fonts, archives or map tiles. Make a source, choose a store, open a reader, and read by offset:
import { createStore, httpSource, openReader } from 'range-source-web';
// One store per app or player, shared by every reader; null for 'none'
const store = createStore('auto', { namespace: 'my-player', maxBytes: 256 * 1024 * 1024 });
const reader = await openReader(httpSource(url, { headers: { Authorization: `Bearer ${token}` } }), {
store,
key: trackId, // what the copy is kept under: the URL, or an id that outlives presigned URLs
});
reader.size; // the file's length, from the first response
const head = await reader.read(0, 64 * 1024); // one request, kept open and read on
const tail = await reader.read(reader.size - 16 * 1024, 16 * 1024); // a seek: a second request
reader.close();-
Reading in order from the start is one HTTP request. Each
readresolves once its bytes are in, and the reader fetches 1 MB ahead of the last read, so a demuxer reading on rarely waits. -
A seek is a
readsomewhere else. The reader opens a request at that offset, and aborts its own read-ahead so the seek isn't stuck behind it. If you know where the player will go next,prefetch(offset, length)starts fetching it without waiting. -
Storage: with a store, every block that arrives is kept under
key. A file played through plays again with no requests, offline too, and a file held in part fetches only what's missing. Leave outstorefor none: the reader still keeps 16 MB of blocks in memory, so a short clip loops without fetching again. -
Code that can't await, such as a WebAssembly demuxer called synchronously, uses
peek(offset, length). It returns the bytes in memory at once, or null. On null, return "not yet" to your caller,await reader.read(offset, length), and call again:function bytesFor(offset: number, length: number): Uint8Array | null { const bytes = reader.peek(offset, length); if (!bytes) void reader.read(offset, length).then(retryDemux); return bytes; }
-
Cancelling: pass an
AbortSignaltoopenReaderor to aread, for example when the user changes track.close()aborts everything the reader has in flight. -
Other sources:
blobSource(file)for aFilethe user picked,memorySource(bytes), or an object of your own withsizeandread(below), for a fetcher, cache or decryption of your own. They all work with the same reader and stores.
A source reads bytes by offset:
interface ByteSource {
/** Length and version. Without it, size is used. */
open?(signal?: AbortSignal): Promise<{ size: number; etag?: string; lastModified?: string }>;
readonly size?: number;
/** `length` bytes from `offset`, fewer only where the source ends. */
read(offset: number, length: number, signal?: AbortSignal): Promise<Uint8Array>;
close?(): void;
}The smallest source is { size, read }. Readers call open() once before reading, and read() one call at a time. This is how an app plays from its own fetcher, cache or decryption, or from a MediaCache:
const source = {
size: 5_069_344,
read: (offset, length, signal) => myCache.read(fileId, offset, length, signal),
};
player.setSource(source);Built in:
httpSource(url, options)makes range requests.open()sendsRange: bytes=0-and keeps that response open, so playing from the start is a single request.- Reads that follow on, or skip ahead by up to
reconnectGap(512 KB by default), reuse it. A read elsewhere, such as a seek, starts a new request at its offset. - A server that answers
200instead of206is read progressively: seeks ahead wait for the download, and a seek back starts it again from 0. A response without a length, or a compressed one (Content-Encoding), is read into memory whole. - Each new request checks the version (
ETag, elseLast-Modified, and the length), and fails withSourceChangedErrorif the file changed. - Options:
headers(sent with every request, for exampleAuthorization),init(otherfetchoptions, such ascredentials),fetch(one to use instead of the global), andreconnectGap. - Cross-origin servers need CORS that allows
GET. TheRangeheader is a safelisted request header. To check versions, exposeETag(orLast-Modified) andContent-RangewithAccess-Control-Expose-Headers. Without them, the length is taken fromContent-Length, and versions aren't checked. - Requests go through
fetch, so a Service Worker of the app's own controlling the page can answer them from its own cache. range-source's worker is for media elements, which don't read through a reader (Service Worker).
blobSource(blob)reads aBloborFilewithslice.memorySource(bytes)reads anArrayBufferorUint8Array.
Failed requests throw HttpSourceError, with its status and url.
Where a player takes a URL and a name rather than a source, such as the react-native-web views of react-native-alpha-video and react-native-alpha-video-gpu (source={{ uri, dataSource: 'mediacache' }}), the name is looked up in RangeDataSources, as on Android and iOS:
import { RangeDataSources } from 'range-source-web';
RangeDataSources.register('mycache', (uri, { headers } = {}) => myCacheSource(uri, headers));The factory makes a source per URL and gets the headers the player was asked to send. The registry is kept on Symbol.for('range-source.dataSources'), so a library can register without importing range-source-web, and copies of it in one app share it. MediaCache registers this way with cache.registerRangeDataSource().
openReader(source, options) returns a reader with read(offset, length, signal), peek(offset, length), prefetch(offset, length) and close(). peek is for code that can't await, such as a WebAssembly demuxer: it returns the bytes already in memory at once, up to the end of their block, or null if they aren't there yet, and then you read them and peek again. It reads ahead like read. The reader reads the source in 256 KB blocks:
- It keeps up to 16 MB of blocks in memory (
memoryBytes), evicting the least recently used. So a clip of up to 16 MB loops without fetching again, even with no store. - After each read, it fetches 1 MB further in the background (
readAheadBytes, 0 to turn it off). A read that needs a block already being fetched waits for it; other background fetches are aborted, so a seek isn't stuck behind read-ahead. - No single source read is longer than 4 MB (
maxRequestBytes). - With a
storeand akey, it writes each block to the store as it arrives, and reads blocks from the store before going to the source.stored()resolves once the blocks read so far are written, for handing the file to another reader of the store, such as the Service Worker.- A clip held whole plays without opening the source at all, so it plays offline. It isn't revalidated; store a changed file under a new key, or
deletethe old one. - A clip held in part opens the source first, and drops the stored copy if the version differs.
- A clip held whole plays without opening the source at all, so it plays offline. It isn't revalidated; store a changed file under a new key, or
const store = createStore('auto', { namespace: 'my-app', maxBytes: 512 * 1024 * 1024 });| Kind | Where | Notes |
|---|---|---|
'auto', 'opfs' |
The origin private file system: a folder per key, a file per block | Falls back to IndexedDB, then memory |
'indexeddb' |
An IndexedDB database per namespace, with a record per block | Falls back to memory |
'cache' |
The Cache API, as synthetic responses per block | Cache.put doesn't take 206 responses, so blocks are stored as their own 200s. Falls back to IndexedDB, then memory |
'memory' |
Memory, shared by every reader on the page | Lost on reload |
'none' |
Nothing; createStore returns null |
- The namespace keeps one player's storage apart from another's. Each namespace has its own cap and files.
maxBytescaps the store across every key in it. It defaults to 512 MB (DEFAULT_MAX_STORAGE_BYTES). Past the cap, the least recently used keys are evicted, except those open in a reader.- Stores are best-effort. A failed write is skipped, and a block the browser evicted is fetched again. Asking for persistent storage (
navigator.storage.persist()) is up to the app. createStorereturns one store per kind and namespace, so players on a page share an index and a cap. The cap is the one passed last.- Pages, tabs and the Service Worker share a store of the same kind and namespace. Each announces the blocks it writes and the keys it deletes on a
BroadcastChannel, and the others merge them into their index. A key open in a reader in any of them holds a shared Web Lock, and eviction skips keys whose lock it can't take. Memory stores aren't shared. WithoutBroadcastChannelor Web Locks (Safari before 15.4), each context keeps its own index, and a block that went missing is fetched again.
A store of your own implements ByteStore (lookup, open, delete, clear), or a StoreBackend (init, saveEntry, deleteEntry, readBlock, writeBlock, clear) passed to new BlockStore([backend], maxBytes), which adds the index and eviction.
A media element (<video>, <audio>) makes its own requests, which the page can't see, so what it plays skips the reader and the store. range-source's Service Worker answers those requests instead: from the store first, fetching what's missing by range and storing it as it goes, so a file the element played through plays again with no requests, offline too.
-
Serve the worker script at your site's root, by copying
node_modules/range-source-web/dist/range-source-sw.jsin your build (it's also the exportrange-source-web/range-source-sw.js). The folder it's served from is the part of the site it controls. -
Register it before rendering anything that plays, and wait for it, so the first request goes through it too:
import { registerRangeSourceWorker } from 'range-source-web'; await registerRangeSourceWorker('/range-source-sw.js');
It resolves once the worker controls the page: about 50–100 ms on a first visit, at once on later ones. A page that a hard reload (Shift-reload) left uncontrolled is claimed again. It gives up after
timeoutMs(2 s), and resolves null where Service Workers aren't available (insecure origins, some private modes).scopeandtypeare those ofnavigator.serviceWorker.register. -
Give the element a URL the worker answers:
import { serviceWorkerUrl } from 'range-source-web'; const handle = await serviceWorkerUrl(url, { namespace: 'my-player', key: trackId, headers: { Authorization: `Bearer ${token}` } }); video.src = handle?.url ?? url; // Once the element is done with it video.removeAttribute('src'); video.load(); handle?.release();
It returns null when no range-source worker controls the page (
rangeSourceWorker()asks the worker, once per worker). The URL is on the page's origin, in the worker's scope (__range-source/<file name>?…), and says which file, store and key; the server sees the file's own URL.
What the worker does:
- Storage:
storage(a kind, default'auto'),maxBytesandkeyare those ofcreateStoreandopenReader. The worker opens that store itself, shared with the page's (above). With a store object of the page's own instead of a kind, the page reads the file through it and the worker passes the bytes on. - Headers: an element can't send them, so the worker asks the page that gave them, over
postMessage, and keeps them in memory. - Sources the page holds, a
Blob, bytes or aByteSource: the worker forwards each range the element asks for to the page, which reads it through a reader (and a store, withstorageandkey) and sends the bytes back.contentTypegives the media type for a source that doesn't have one. - Cross-origin files need CORS for the worker to read them, as for
httpSource. Without it, the worker fetches the file inno-corsmode and passes it through whole and unstored, and remembers that origin while it runs. The responses come from the page's own origin, so a cross-origin isolated page (COEPrequire-corp) accepts them. - Responses are
206forRange: bytes=a-b,a-and-n, and200without aRange, withContent-Type(from the server, else the file extension),Content-Length,Content-Range,ETagandLast-Modified. The body is read from a reader 256 KB at a time as the element takes it, keeping at most 4 MB in memory per response, and stops when the element drops the request, as it does on a seek. - A server without range support is read from the start, as by
httpSource, and the element still gets206s. - Long responses: a browser may stop a worker in the middle of one (Firefox after 5 minutes). The element then asks again from where it had got to, and the worker carries on, since everything it needs is in the URL and the store. Tested by stopping the worker mid-stream in Chromium and Firefox.
Browser notes, from the tests: Chromium and Firefox keep the store in OPFS, WebKit in IndexedDB. Firefox only sends an element's requests to a worker when they're on the page's origin, which is why the URLs are. Playwright's WebKit reads a WebM from the start without Range, worker or not, so a seek far ahead waits for the bytes before it. Firefox logs "A ServiceWorker intercepted the request and encountered an unexpected error" when an element drops a response the worker is streaming; playback isn't affected.
An app with a Service Worker of its own adds range-source's handler to it. A classic worker can importScripts('/range-source-sw.js'); its fetch listener answers only the URLs serviceWorkerUrl made. A bundled one imports the worker entry:
import { handleRangeSourceFetch, installRangeSourceWorker } from 'range-source-web/sw';
installRangeSourceWorker(self, { fetch: false }); // hello, activation and claiming the pages
self.addEventListener('fetch', (event) => {
if (handleRangeSourceFetch(event)) return;
// the app's own handling
});claim: false leaves activation to the app; the page then waits for its worker to take control of it, up to timeoutMs.
npm test runs the unit tests: the sources and reader in memory, the stores (IndexedDB through fake-indexeddb), and httpSource against a local server with and without range support, including chunked responses, hidden Content-Range, required headers, aborts, and a file that changes between requests.
npm run test:browsers runs the Service Worker in Chromium, Firefox and WebKit through Playwright (npx playwright install once; ffmpeg makes the test clips): <video> streaming and storing through it, replay with no requests, offline, seeks, a head the page stored, headers, bytes the page holds, a store of the page's own, a server without ranges, cross-origin with and without CORS, eviction across pages, a hard reload, cross-origin isolation, and the browser stopping the worker mid-stream. -- chromium picks browsers.
npm run serve -- <dir> --port 8766 serves a folder for browser tests, answering range requests. --no-range makes it answer 200 with the whole file, and --chunked leaves out Content-Length. Tests can also start it in process with startRangeServer() from test/range-server.mjs.
MIT.