@budarin/psw-plugin-opfs-serve-range 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Вадим Бударин
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # @budarin/psw-plugin-opfs-serve-range
2
+
3
+ [Русская версия](https://github.com/budarin/psw-plugin-opfs-serve-range/blob/master/README.ru.md)
4
+
5
+ Service Worker plugins and utilities for `@budarin/pluggable-serviceworker` that serve HTTP Range requests from files stored in Origin Private File System (OPFS).
6
+
7
+ [![CI](https://github.com/budarin/psw-plugin-opfs-serve-range/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/budarin/psw-plugin-opfs-serve-range/actions/workflows/ci.yml)
8
+ [![npm](https://img.shields.io/npm/v/@budarin/psw-plugin-opfs-serve-range?color=cb0000)](https://www.npmjs.com/package/@budarin/psw-plugin-opfs-serve-range)
9
+ [![npm](https://img.shields.io/npm/dt/@budarin/psw-plugin-opfs-serve-range)](https://www.npmjs.com/package/@budarin/psw-plugin-opfs-serve-range)
10
+ [![bundle](https://img.shields.io/bundlephobia/minzip/@budarin/psw-plugin-opfs-serve-range)](https://bundlephobia.com/result?p=@budarin/psw-plugin-opfs-serve-range)
11
+ [![GitHub](https://img.shields.io/github/license/budarin/psw-plugin-opfs-serve-range)](https://github.com/budarin/psw-plugin-opfs-serve-range)
12
+
13
+ Large media files and other heavy assets are almost always requested in chunks via HTTP Range rather than as a single download. When such files live in a regular HTTP cache (Cache API), the service worker often has to read and process the entire file to serve a small range, which is wasteful in terms of memory and CPU and quickly hits storage limits on low‑end devices.
14
+
15
+ This package takes a different approach: it uses the Origin Private File System (OPFS) as the primary storage for large resources and range responses. Files are stored in OPFS in a custom format (one file per URL plus a metadata footer), and ranges are read directly from the file system instead of Cache API. On top of that, the package provides plugins for precaching, background downloads, and serving range requests.
16
+
17
+ Unlike `@budarin/psw-plugin-serve-range-requests`, which works on top of the regular browser cache (Cache API) and serves ranges for already cached responses, this package uses OPFS as the cache backend: it gives you explicit control over quota and eviction policy (limits, LRU, notifications to tabs), supports “download first, then play offline for a long time” scenarios (via Background Fetch and precache), and exposes utilities for implementing your own OPFS writers and plugins.
18
+
19
+ ### What this package provides
20
+
21
+ - **opfsServeRange** – reads files from OPFS and serves byte ranges.
22
+ - **opfsPrecache** – during SW install, fetches a list of URLs and writes them to OPFS. Downloading large files at install time may take a long time – the UI should either warn users to wait or avoid putting huge files into precache. It is also important to note that if OPFS runs out of space while writing during the `install` phase and the operation fails, the whole service worker install fails (the SW is not installed). Use `opfsPrecache` only for resources that are guaranteed to fit even on small, partially filled devices; use `opfsRangeFromNetworkAndCache` or background downloads for heavy files.
23
+ - **opfsRangeFromNetworkAndCache** – handles requests that `opfsServeRange` did not serve (resource not in cache yet): goes to the network, streams the response to the client, and optionally starts a full background download into OPFS; only fully downloaded files are cached. If the tab or browser is closed or the network drops, the download is aborted; the next request for the same URL starts a new full download (which may be slow or expensive for large files). If you need downloads that survive tab or browser closes, or your files are very large, use the Background Fetch API utilities from `@budarin/pluggable-serviceworker`.
24
+ - **opfsBackgroundFetch** – on successful Background Fetch completion, writes responses into OPFS; subsequent Range requests for these URLs are served by `opfsServeRange`.
25
+ - **writeToOpfs**, **metadataFromResponse**, **urlToOpfsKey**, **isOpfsAvailable** – low‑level utilities for writing your own OPFS plugins; **isOpfsAvailable()** provides a synchronous check for OPFS support.
26
+
27
+ In environments without OPFS support, plugin factories return `undefined`.
28
+
29
+ All cache files live under a single OPFS directory. The directory name is configured once via **configureOpfs({ folderName })** before registering plugins (defaults to `'range-requests-cache'`). To clear the cache, call **clearOpfsCache()** – the whole directory is removed. Inside, there is one file per URL; all metadata is stored in the file footer.
30
+
31
+ Detailed cache behavior (limits, LRU, eviction, notifications) is described in [docs/opfs-cache-behavior.md](docs/opfs-cache-behavior.md) (Russian version: [docs/opfs-cache-behavior.ru.md](docs/opfs-cache-behavior.ru.md)).
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pnpm add @budarin/psw-plugin-opfs-serve-range
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ The following example shows how to configure media (video, map tiles, etc.) so that on the first request the content is loaded and stored in the local OPFS cache, and on subsequent requests – once fully downloaded – it is served from OPFS without hitting the network.
42
+
43
+ ```typescript
44
+ import { initServiceWorker } from '@budarin/pluggable-serviceworker';
45
+ import {
46
+ configureOpfs,
47
+ opfsServeRange,
48
+ opfsRangeFromNetworkAndCache,
49
+ } from '@budarin/psw-plugin-opfs-serve-range';
50
+
51
+ configureOpfs({
52
+ folderName: 'ranges-media-cache',
53
+ maxCacheFraction: 0.5, // fraction of origin quota reserved for this cache (default 0.5)
54
+ });
55
+
56
+ initServiceWorker(
57
+ [
58
+ opfsServeRange({
59
+ order: -15,
60
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
61
+ }),
62
+ opfsRangeFromNetworkAndCache({
63
+ order: -10,
64
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
65
+ }),
66
+ ],
67
+ { version: '1.0.0' }
68
+ );
69
+ ```
70
+
71
+ Here `opfsServeRange` serves ranges from OPFS when the file is already cached; `opfsRangeFromNetworkAndCache` goes to the network when the file is not cached yet, streams the response to the client, and optionally fills OPFS in the background so that subsequent requests are served from OPFS. You can add **opfsPrecache** or **opfsBackgroundFetch** as needed; the set and order of plugins are fully configurable.
72
+
73
+ ### Example: “Download for offline” (Background Fetch) + Range playback
74
+
75
+ **Scenario:** The user clicks “Download for offline” → a large file (video, map) is downloaded in the background; the tab may be closed. After the download finishes, a player or map viewer issues Range requests for this URL – responses come from OPFS, without re‑downloading the file. The goal is the full cycle “button → background download → offline playback from cache”.
76
+
77
+ **Client (page)** – trigger a background download based on user action:
78
+
79
+ ```typescript
80
+ import {
81
+ startBackgroundFetch,
82
+ isBackgroundFetchSupported,
83
+ } from '@budarin/pluggable-serviceworker/client/background-fetch';
84
+
85
+ async function downloadForOffline(
86
+ url: string,
87
+ title: string,
88
+ downloadTotal?: number
89
+ ) {
90
+ const supported = await isBackgroundFetchSupported();
91
+ if (!supported) {
92
+ console.warn('Background Fetch API is not supported');
93
+ return;
94
+ }
95
+ const reg = await navigator.serviceWorker.ready;
96
+ const id = `offline-${Date.now()}`;
97
+ await startBackgroundFetch(reg, id, [url], { title, downloadTotal });
98
+ }
99
+ ```
100
+
101
+ **Service worker** – register plugins (after Background Fetch completes, the file is written into the range cache, and subsequent Range requests are served by `opfsServeRange`):
102
+
103
+ ```typescript
104
+ import { initServiceWorker } from '@budarin/pluggable-serviceworker';
105
+ import {
106
+ configureOpfs,
107
+ opfsServeRange,
108
+ opfsRangeFromNetworkAndCache,
109
+ opfsBackgroundFetch,
110
+ } from '@budarin/psw-plugin-opfs-serve-range';
111
+
112
+ configureOpfs({ folderName: 'range-requests-cache', maxCacheFraction: 0.5 });
113
+
114
+ initServiceWorker(
115
+ [
116
+ opfsServeRange({
117
+ order: -15,
118
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
119
+ }),
120
+ opfsRangeFromNetworkAndCache({
121
+ order: -10,
122
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
123
+ }),
124
+ opfsBackgroundFetch({
125
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
126
+ enableLogging: true,
127
+ }),
128
+ ],
129
+ { version: '1.0.0' }
130
+ );
131
+ ```
132
+
133
+ ## OPFS storage format
134
+
135
+ If you implement your own writer plugin or serve files from OPFS directly (bypassing these plugins), the format details are important. The cache key is `hex(SHA-256(URL))` (64 characters). There is one OPFS file per URL: the file layout is `[body][JSON metadata][4‑byte JSON length (uint32 LE)]`. To clear the cache, delete a file by key or remove the entire directory via `clearOpfsCache`.
136
+
137
+ **Important:** if you serve a file from OPFS **as a whole** (e.g. `200` without Range) to a player or other code, you must strip the footer and only return the body: first read the footer, compute `bodySize`, then do `new Response(file.slice(0, bodySize), ...)`. The `opfsServeRange` plugin only serves body ranges (`206`) and never exposes the footer.
138
+
139
+ Metadata example (JSON footer): `url`, `size`, `type`, `etag`, `lastModified`. All plugins in this package use the same format and the shared `urlToOpfsKey`.
140
+
141
+ ## Writing your own OPFS plugin
142
+
143
+ If you need to write into OPFS following the same format as the built‑in plugins, you can use **getOpfsDir**, **urlToOpfsKey**, **writeToOpfs**, **metadataFromResponse**. Example:
144
+
145
+ ```typescript
146
+ import {
147
+ getOpfsDir,
148
+ urlToOpfsKey,
149
+ writeToOpfs,
150
+ metadataFromResponse,
151
+ } from '@budarin/psw-plugin-opfs-serve-range';
152
+
153
+ const root = await navigator.storage.getDirectory();
154
+ const dir = await getOpfsDir(root, true);
155
+ const key = await urlToOpfsKey(url);
156
+ const metadata = metadataFromResponse(response, url);
157
+ await writeToOpfs(dir, key, response.body, metadata);
158
+ ```
159
+
160
+ The response may not have a `Content-Length` header – when writing the full body, the size is determined automatically from the bytes written. When using limits, pass the fifth `options` argument to `writeToOpfs`: `{ url, knownSize }` (for example, `knownSize: metadata.size > 0 ? metadata.size : undefined`).
161
+
162
+ ## Tab notifications about quota and limits
163
+
164
+ The service worker sends messages to clients when quota is exceeded, writes are refused, eviction happens, etc. You can subscribe using typed handlers from the client entry point `@budarin/psw-plugin-opfs-serve-range/client`:
165
+
166
+ ```typescript
167
+ import {
168
+ onOPFSQuotaExceeded,
169
+ onOPFSWriteSkipped,
170
+ onOPFSSkipQuotaExceeded,
171
+ } from '@budarin/psw-plugin-opfs-serve-range/client';
172
+
173
+ onOPFSQuotaExceeded((event) => {
174
+ console.warn('OPFS: quota exceeded', event.data?.url);
175
+ });
176
+
177
+ onOPFSSkipQuotaExceeded((event) => {
178
+ console.warn('OPFS: resource not cached (quota)', event.data?.url);
179
+ });
180
+ ```
181
+
182
+ See [docs/opfs-cache-behavior.md](docs/opfs-cache-behavior.md) for details (Russian version: [docs/opfs-cache-behavior.ru.md](docs/opfs-cache-behavior.ru.md)).
183
+
184
+ ## Clearing the cache and managing individual resources
185
+
186
+ To wipe the whole cache (e.g. from a UI button or on logout), call `clearOpfsCache()` from the service worker or client – the entire cache directory will be deleted.
187
+
188
+ If you need finer‑grained control (show a list of cached resources and let users delete specific ones), use the client utilities from the entry point `@budarin/psw-plugin-opfs-serve-range/client`. The list is built from metadata in the footer (each file stores its original `url`):
189
+
190
+ - get a list of resources stored in OPFS with sizes and types – `listOpfsCachedResources()`;
191
+ - check whether a particular URL is cached – `hasInOpfsCache(url)`;
192
+ - delete a single resource by URL – `deleteFromOpfsCache(url)`.
193
+
194
+ ## Plugin options
195
+
196
+ The cache folder name and quota fraction are configured via **configureOpfs({ folderName, maxCacheFraction })**.
197
+
198
+ - **opfsServeRange:** `order`, `enableLogging`, `include`, `exclude`, `rangeResponseCacheControl` – to restrict which URLs are served and how 206 responses are cached by the browser.
199
+ - **opfsPrecache:** `urls` (array or function returning an array), `order`, `enableLogging` – which URLs to fetch at SW install.
200
+ - **opfsRangeFromNetworkAndCache:** `order` (e.g. `-10`, after `opfsServeRange`), `include`, `exclude`, `enableLogging` – which requests to cache; on Range requests it streams the response immediately and optionally fills OPFS in the background. With `enableLogging`, a warning is logged when a file already exists in OPFS but the Range response is served from network (e.g. because of If-Range mismatch or plugin ordering).
201
+ - **opfsBackgroundFetch:** `order`, `include`, `exclude`, `enableLogging` – which URLs to write into OPFS when Background Fetch completes. `fail`/`abort`/`click` events are logged with `enableLogging`; you can register your own plugin with the same hooks (e.g. to show UI on fail). To trigger downloads from the client, use utilities from `@budarin/pluggable-serviceworker/client/background-fetch`.
202
+
203
+ ## Requirements
204
+
205
+ - A browser with OPFS support (Chrome 108+, Edge 108+, Firefox 111+, Safari 16.4+) and a secure context (HTTPS).
206
+
207
+ ## License
208
+
209
+ MIT
package/README.ru.md ADDED
@@ -0,0 +1,201 @@
1
+ # @budarin/psw-plugin-opfs-serve-range
2
+
3
+ Большие медиафайлы и другие «тяжёлые» ресурсы почти всегда запрашиваются по частям через HTTP Range, а не одним куском. Когда такие файлы лежат в обычном HTTP‑кеше (Cache API), сервис‑воркеру приходится каждый раз читать и обрабатывать весь файл, даже если клиенту нужен только небольшой диапазон. Это лишняя нагрузка на память и процессор, которая особенно больно бьёт по слабым устройствам и при небольшой квоте хранилища.
4
+
5
+ Этот пакет решает задачу по‑другому: он использует Origin Private File System (OPFS) как основное хранилище для больших ресурсов и ответов по Range. Файлы записываются в OPFS в собственном формате (один файл на URL плюс метаданные во футере), а диапазоны читаются напрямую из файловой системы, без Cache API. Поверх этого построены плагины для предзагрузки, фоновых загрузок и обслуживания range‑запросов.
6
+
7
+ В отличие от пакета `@budarin/psw-plugin-serve-range-requests`, который работает поверх обычного кеша (Cache API) и отдаёт диапазоны для уже закешированных ответов, этот пакет использует именно OPFS в качестве кеша: даёт явный контроль над квотой и политиками эвикции (лимиты, LRU, уведомления вкладок), поддерживает сценарии «сначала скачать в фоне, потом долго воспроизводить или просматривать офлайн» (через Background Fetch и precache) и предоставляет утилиты для собственных плагинов записи и чтения из OPFS.
8
+
9
+ Пакет предоставляет плагины и утилиты для `@budarin/pluggable-serviceworker` для обработки range‑запросов к файлам в OPFS:
10
+
11
+ - **opfsServeRange** — читает файлы из OPFS и отдаёт запрошенные диапазоны байтов.
12
+ - **opfsPrecache** — при установке сервис‑воркера загружает список URL и записывает их в OPFS. Загрузка объёмных файлов на стадии установки может занять много времени, поэтому в UI имеет смысл явно сообщать пользователю, что идёт инициализация, либо не включать большие файлы в precache. Отдельно важно учитывать, что если на стадии `install` при записи в OPFS не хватит места и операция завершится ошибкой, весь сервис‑воркер не будет установлен (install не завершится успешно). Через opfsPrecache стоит грузить только те ресурсы, которые гарантированно помещаются даже на маленьких и уже частично заполненных устройствах; тяжёлые файлы лучше выносить в отдельные сценарии фоновой или отложенной загрузки с помощью плагина `opfsRangeFromNetworkAndCache` или Background Fetch.
13
+ - **opfsRangeFromNetworkAndCache** — подхватывает запросы, которые `opfsServeRange` не обслужил (ресурс ещё не в кеше): идёт в сеть, сразу отдаёт ответ клиенту и при необходимости запускает параллельно полную загрузку файла в OPFS; в кеш попадают только полностью загруженные файлы. При закрытии вкладки, браузера или обрыве сети загрузка прерывается — при следующем запросе к тому же URL загрузка начнётся заново. Для очень больших файлов и платных каналов стоит особенно внимательно отнестись к таким сценариям; если нужна загрузка, переживающая закрытие вкладки или браузера, используйте `Background Fetch API` и плагины из `@budarin/pluggable-serviceworker`.
14
+ - **opfsBackgroundFetch** — при успешном завершении загрузки при помощи `Background Fetch API` записывает ответы в OPFS; дальнейшие range‑запросы по этим URL обслуживает `opfsServeRange`.
15
+ - **writeToOpfs**, **metadataFromResponse**, **urlToOpfsKey**, **isOpfsAvailable** — утилиты, которые могут понадобиться для написания собственных плагинов записи в OPFS; **isOpfsAvailable()** — утилита для синхронной проверки наличия OPFS.
16
+
17
+ В средах без поддержки OPFS фабрики плагинов возвращают `undefined`.
18
+
19
+ Все файлы кеша лежат в одной папке OPFS. Её имя задаётся один раз в **configureOpfs({ folderName })** до регистрации плагинов (по умолчанию `'range-requests-cache'`). Чтобы очистить кеш, вызовите **clearOpfsCache()** — удалится вся папка. Внутри — один файл на URL, все метаданные хранятся в самом файле.
20
+
21
+ Подробное описание поведения кеша (лимиты, LRU, эвикция, оповещения) — в [docs/opfs-cache-behavior.ru.md](docs/opfs-cache-behavior.ru.md).
22
+
23
+ ## Установка
24
+
25
+ ```bash
26
+ pnpm add @budarin/psw-plugin-opfs-serve-range
27
+ ```
28
+
29
+ ## Использование
30
+
31
+ В следующем примере показано, как сделать так, чтобы медиа (видео, тайлы карт и т.п.) по первому запросу подгружались и сохранялись в локальный кэш, а при повторных запросах — после полной загрузки — отдавались из кэша без сети.
32
+
33
+ ```typescript
34
+ import { initServiceWorker } from '@budarin/pluggable-serviceworker';
35
+ import {
36
+ configureOpfs,
37
+ opfsServeRange,
38
+ opfsRangeFromNetworkAndCache,
39
+ } from '@budarin/psw-plugin-opfs-serve-range';
40
+
41
+ configureOpfs({
42
+ folderName: 'ranges-media-cache',
43
+ maxCacheFraction: 0.5, // доля квоты origin для кеша (по умолчанию 0.5)
44
+ });
45
+
46
+ initServiceWorker(
47
+ [
48
+ opfsServeRange({
49
+ order: -15,
50
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
51
+ }),
52
+ opfsRangeFromNetworkAndCache({
53
+ order: -10,
54
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
55
+ }),
56
+ ],
57
+ { version: '1.0.0' }
58
+ );
59
+ ```
60
+
61
+ Здесь два плагина: **opfsServeRange** отдаёт диапазоны из OPFS, если файл уже в кеше; **opfsRangeFromNetworkAndCache** — если файла ещё нет — идёт в сеть, сразу отдаёт ответ клиенту и при необходимости догружает файл в OPFS в фоне. Так при следующих запросах тот же URL уже обслужит opfsServeRange из кэша. При необходимости можно добавить **opfsPrecache** или **opfsBackgroundFetch**; состав и порядок плагинов можно менять под свою задачу.
62
+
63
+ ### Пример: загрузка по кнопке (Background Fetch) и отдача по range
64
+
65
+ **Что реализует пример:** Пользователь нажимает «Скачать для офлайна» → большой файл (видео, карта) качается в фоне, можно закрыть вкладку. После завершения загрузки плеер или карта запрашивают этот URL с заголовком Range — ответы идут из кэша, без повторной загрузки. Цель: полный цикл «кнопка → фоновая загрузка → воспроизведение/просмотр из кэша».
66
+
67
+ **Клиент (страница)** — запуск загрузки по действию пользователя:
68
+
69
+ ```typescript
70
+ import {
71
+ startBackgroundFetch,
72
+ isBackgroundFetchSupported,
73
+ } from '@budarin/pluggable-serviceworker/client/background-fetch';
74
+
75
+ async function downloadForOffline(
76
+ url: string,
77
+ title: string,
78
+ downloadTotal?: number
79
+ ) {
80
+ const supported = await isBackgroundFetchSupported();
81
+ if (!supported) {
82
+ console.warn('Background Fetch API не поддерживается');
83
+ return;
84
+ }
85
+ const reg = await navigator.serviceWorker.ready;
86
+ const id = `offline-${Date.now()}`;
87
+ await startBackgroundFetch(reg, id, [url], { title, downloadTotal });
88
+ }
89
+ ```
90
+
91
+ **Сервис-воркер** — регистрация плагинов (по завершении Background Fetch файл пишется в range cache, дальше range-запросы обслуживает opfsServeRange):
92
+
93
+ ```typescript
94
+ import { initServiceWorker } from '@budarin/pluggable-serviceworker';
95
+ import {
96
+ configureOpfs,
97
+ opfsServeRange,
98
+ opfsRangeFromNetworkAndCache,
99
+ opfsBackgroundFetch,
100
+ } from '@budarin/psw-plugin-opfs-serve-range';
101
+
102
+ configureOpfs({ folderName: 'range-requests-cache', maxCacheFraction: 0.5 });
103
+
104
+ initServiceWorker(
105
+ [
106
+ opfsServeRange({
107
+ order: -15,
108
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
109
+ }),
110
+ opfsRangeFromNetworkAndCache({
111
+ order: -10,
112
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
113
+ }),
114
+ opfsBackgroundFetch({
115
+ include: ['*.mp4', '*.webm', '*.pmtiles'],
116
+ enableLogging: true,
117
+ }),
118
+ ],
119
+ { version: '1.0.0' }
120
+ );
121
+ ```
122
+
123
+ ## Схема хранения в OPFS
124
+
125
+ Тем, кто пишет свой плагин записи или отдаёт файл из OPFS в обход плагинов, пригодятся детали формата. Ключ файла — `hex(SHA-256(URL))` (64 символа). Один файл на URL: сначала тело ресурса, в конце футер (JSON с метаданными + 4 байта длины). Очистка — удалить файл по ключу или всю папку через clearOpfsCache.
126
+
127
+ **Важно:** если вы отдаёте файл из OPFS **целиком** (например, 200 без Range) плееру или другому коду — отдавайте только тело, без футера: сначала прочитайте футер и вычислите `bodySize`, затем `new Response(file.slice(0, bodySize), ...)`. Плагин opfsServeRange отдаёт только диапазоны тела (206), футер в ответ не попадает.
128
+
129
+ Пример метаданных в футере (JSON): `url`, `size`, `type`, `etag`, `lastModified`. Все плагины пакета используют один формат и общий `urlToOpfsKey`.
130
+
131
+ ## Свой плагин записи в OPFS
132
+
133
+ Если нужно записывать в OPFS по своей логике (тот же формат, что и у плагинов пакета), могут понадобиться **getOpfsDir**, **urlToOpfsKey**, **writeToOpfs**, **metadataFromResponse**. Пример:
134
+
135
+ ```typescript
136
+ import {
137
+ getOpfsDir,
138
+ urlToOpfsKey,
139
+ writeToOpfs,
140
+ metadataFromResponse,
141
+ } from '@budarin/psw-plugin-opfs-serve-range';
142
+
143
+ const root = await navigator.storage.getDirectory();
144
+ const dir = await getOpfsDir(root, true);
145
+ const key = await urlToOpfsKey(url);
146
+ const metadata = metadataFromResponse(response, url);
147
+ await writeToOpfs(dir, key, response.body, metadata);
148
+ ```
149
+
150
+ Ответ может быть без заголовка `Content-Length` — при записи полного тела размер определяется автоматически. При использовании лимитов передайте в `writeToOpfs` пятый аргумент `options`: `{ url, knownSize }` (например, `knownSize: metadata.size > 0 ? metadata.size : undefined`).
151
+
152
+ ## Оповещения вкладок о квоте и лимитах
153
+
154
+ Сервис-воркер отправляет сообщения клиентам при исчерпании квоты, отказе в записи, эвикции и т.д. Подписаться можно через типизированные обработчики из пакета (entry point `@budarin/psw-plugin-opfs-serve-range/client`):
155
+
156
+ ```typescript
157
+ import {
158
+ onOPFSQuotaExceeded,
159
+ onOPFSWriteSkipped,
160
+ onOPFSSkipQuotaExceeded,
161
+ } from '@budarin/psw-plugin-opfs-serve-range/client';
162
+
163
+ onOPFSQuotaExceeded((event) => {
164
+ console.warn('OPFS: quota exceeded', event.data?.url);
165
+ });
166
+
167
+ onOPFSSkipQuotaExceeded((event) => {
168
+ console.warn('OPFS: resource not cached (quota)', event.data?.url);
169
+ });
170
+ ```
171
+
172
+ Подробнее — в [docs/opfs-cache-behavior.ru.md](docs/opfs-cache-behavior.ru.md).
173
+
174
+ ## Очистка кеша и управление отдельными ресурсами
175
+
176
+ Когда нужно сбросить весь кеш (например, по кнопке в UI или при логауте), можно вызвать clearOpfsCache() из сервис-воркера или клиента — будет удалена вся папка кеша.
177
+
178
+ Если нужно работать с отдельными ресурсами (показать пользователю список сохранённых файлов и дать удалить что-то выборочно), можно использовать клиентские утилиты из entry point `@budarin/psw-plugin-opfs-serve-range/client`. Список в кеше строится по метаданным в футере (там теперь хранится исходный url каждого ресурса).
179
+
180
+ Основные сценарии:
181
+
182
+ - получить список ресурсов в OPFS-кеше с размерами и типами — listOpfsCachedResources();
183
+ - проверить, есть ли конкретный URL в кеше — hasInOpfsCache(url);
184
+ - удалить один ресурс по URL — deleteFromOpfsCache(url).
185
+
186
+ ## Опции плагинов
187
+
188
+ Имя папки и доля квоты задаются в **configureOpfs({ folderName, maxCacheFraction })**.
189
+
190
+ - **opfsServeRange:** `order`, `enableLogging`, `include`, `exclude`, `rangeResponseCacheControl` — чтобы ограничить URL и кеш ответов 206.
191
+ - **opfsPrecache:** `urls` (список или функция, возвращающая список), `order`, `enableLogging` — какие URL загружать при установке SW.
192
+ - **opfsRangeFromNetworkAndCache:** `order` (например -10, после opfsServeRange), `include`, `exclude`, `enableLogging` — какие запросы кешировать; при запросе с Range отдаёт ответ сразу и при необходимости догружает файл в OPFS в фоне. При `enableLogging` в консоль пишется предупреждение, если файл уже есть в OPFS, но ответ по Range отдан с сети (например, из‑за If-Range или порядка плагинов).
193
+ - **opfsBackgroundFetch:** `order`, `include`, `exclude`, `enableLogging` — какие URL писать в OPFS по завершении Background Fetch. События fail/abort/click при `enableLogging` логируются; можно зарегистрировать свой плагин с теми же хуками (например, показать уведомление при fail). Запуск загрузки с клиента — утилиты из `@budarin/pluggable-serviceworker/client/background-fetch`.
194
+
195
+ ## Требования
196
+
197
+ - Браузер с поддержкой OPFS (Chrome 108+, Edge 108+, Firefox 111+, Safari 16.4+) и secure context (HTTPS).
198
+
199
+ ## Лицензия
200
+
201
+ MIT
@@ -0,0 +1,47 @@
1
+ export { OPFS_MSG_QUOTA_EXCEEDED, OPFS_MSG_WRITE_SKIPPED_SIZE, OPFS_MSG_CACHE_LIMIT_REACHED, OPFS_MSG_EVICTION_COMPLETED, OPFS_MSG_WRITE_FAILED, OPFS_MSG_SKIP_QUOTA_EXCEEDED, } from '../opfsMessages.js';
2
+ export type { OpfsMessageType } from '../opfsMessages.js';
3
+ export interface OpfsMessagePayload {
4
+ url?: string;
5
+ size?: number;
6
+ limit?: number;
7
+ reason?: string;
8
+ }
9
+ export interface OpfsCachedResource {
10
+ url: string;
11
+ size: number;
12
+ type: string | undefined;
13
+ lastModified: string | undefined;
14
+ }
15
+ export declare function onOPFSQuotaExceeded(handler: (event: MessageEvent & {
16
+ data: {
17
+ type: string;
18
+ } & OpfsMessagePayload;
19
+ }) => void): () => void;
20
+ export declare function onOPFSWriteSkipped(handler: (event: MessageEvent & {
21
+ data: {
22
+ type: string;
23
+ } & OpfsMessagePayload;
24
+ }) => void): () => void;
25
+ export declare function onOPFSCacheLimitReached(handler: (event: MessageEvent & {
26
+ data: {
27
+ type: string;
28
+ } & OpfsMessagePayload;
29
+ }) => void): () => void;
30
+ export declare function onOPFSEvictionCompleted(handler: (event: MessageEvent & {
31
+ data: {
32
+ type: string;
33
+ } & OpfsMessagePayload;
34
+ }) => void): () => void;
35
+ export declare function onOPFSWriteFailed(handler: (event: MessageEvent & {
36
+ data: {
37
+ type: string;
38
+ } & OpfsMessagePayload;
39
+ }) => void): () => void;
40
+ export declare function onOPFSSkipQuotaExceeded(handler: (event: MessageEvent & {
41
+ data: {
42
+ type: string;
43
+ } & OpfsMessagePayload;
44
+ }) => void): () => void;
45
+ export declare function listOpfsCachedResources(): Promise<OpfsCachedResource[]>;
46
+ export declare function hasInOpfsCache(url: string): Promise<boolean>;
47
+ export declare function deleteFromOpfsCache(url: string): Promise<void>;
@@ -0,0 +1,115 @@
1
+ import { onServiceWorkerMessage } from '@budarin/pluggable-serviceworker/client/messaging';
2
+ import { OPFS_MSG_QUOTA_EXCEEDED, OPFS_MSG_WRITE_SKIPPED_SIZE, OPFS_MSG_CACHE_LIMIT_REACHED, OPFS_MSG_EVICTION_COMPLETED, OPFS_MSG_WRITE_FAILED, OPFS_MSG_SKIP_QUOTA_EXCEEDED, } from '../opfsMessages.js';
3
+ import { getOpfsDir } from '../opfsUtil.js';
4
+ import { MAX_META_JSON_BYTES, OPFS_META_FOOTER_LENGTH, } from '../opfsFormat.js';
5
+ import { urlToOpfsKey } from '../index.js';
6
+ export { OPFS_MSG_QUOTA_EXCEEDED, OPFS_MSG_WRITE_SKIPPED_SIZE, OPFS_MSG_CACHE_LIMIT_REACHED, OPFS_MSG_EVICTION_COMPLETED, OPFS_MSG_WRITE_FAILED, OPFS_MSG_SKIP_QUOTA_EXCEEDED, } from '../opfsMessages.js';
7
+ export function onOPFSQuotaExceeded(handler) {
8
+ return onServiceWorkerMessage(OPFS_MSG_QUOTA_EXCEEDED, handler);
9
+ }
10
+ export function onOPFSWriteSkipped(handler) {
11
+ return onServiceWorkerMessage(OPFS_MSG_WRITE_SKIPPED_SIZE, handler);
12
+ }
13
+ export function onOPFSCacheLimitReached(handler) {
14
+ return onServiceWorkerMessage(OPFS_MSG_CACHE_LIMIT_REACHED, handler);
15
+ }
16
+ export function onOPFSEvictionCompleted(handler) {
17
+ return onServiceWorkerMessage(OPFS_MSG_EVICTION_COMPLETED, handler);
18
+ }
19
+ export function onOPFSWriteFailed(handler) {
20
+ return onServiceWorkerMessage(OPFS_MSG_WRITE_FAILED, handler);
21
+ }
22
+ export function onOPFSSkipQuotaExceeded(handler) {
23
+ return onServiceWorkerMessage(OPFS_MSG_SKIP_QUOTA_EXCEEDED, handler);
24
+ }
25
+ async function getOpfsCacheDirOrUndefined() {
26
+ if (typeof navigator === 'undefined' ||
27
+ navigator?.storage == null ||
28
+ typeof navigator.storage.getDirectory !== 'function') {
29
+ return undefined;
30
+ }
31
+ const root = await navigator.storage.getDirectory();
32
+ try {
33
+ return await getOpfsDir(root, false);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ async function readMetadataFromFile(file) {
40
+ const size = file.size;
41
+ if (size < OPFS_META_FOOTER_LENGTH) {
42
+ return undefined;
43
+ }
44
+ const footerBlob = file.slice(size - OPFS_META_FOOTER_LENGTH, size);
45
+ const footerBuf = await footerBlob.arrayBuffer();
46
+ const metaLen = new DataView(footerBuf).getUint32(0, true);
47
+ if (metaLen === 0 ||
48
+ metaLen > MAX_META_JSON_BYTES ||
49
+ metaLen > size - OPFS_META_FOOTER_LENGTH) {
50
+ return undefined;
51
+ }
52
+ try {
53
+ const jsonBlob = file.slice(size - OPFS_META_FOOTER_LENGTH - metaLen, size - OPFS_META_FOOTER_LENGTH);
54
+ const text = await jsonBlob.text();
55
+ const metadata = JSON.parse(text);
56
+ return metadata;
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ export async function listOpfsCachedResources() {
63
+ const dir = await getOpfsCacheDirOrUndefined();
64
+ if (!dir) {
65
+ return [];
66
+ }
67
+ const result = [];
68
+ for await (const [, handle] of dir.entries()) {
69
+ if (handle.kind !== 'file') {
70
+ continue;
71
+ }
72
+ try {
73
+ const file = await handle.getFile();
74
+ const metadata = await readMetadataFromFile(file);
75
+ if (!metadata || !metadata.url) {
76
+ continue;
77
+ }
78
+ result.push({
79
+ url: metadata.url,
80
+ size: metadata.size,
81
+ type: metadata.type,
82
+ lastModified: metadata.lastModified,
83
+ });
84
+ }
85
+ catch {
86
+ }
87
+ }
88
+ return result;
89
+ }
90
+ export async function hasInOpfsCache(url) {
91
+ const dir = await getOpfsCacheDirOrUndefined();
92
+ if (!dir) {
93
+ return false;
94
+ }
95
+ const key = await urlToOpfsKey(url);
96
+ try {
97
+ await dir.getFileHandle(key);
98
+ return true;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ export async function deleteFromOpfsCache(url) {
105
+ const dir = await getOpfsCacheDirOrUndefined();
106
+ if (!dir) {
107
+ return;
108
+ }
109
+ const key = await urlToOpfsKey(url);
110
+ try {
111
+ await dir.removeEntry(key);
112
+ }
113
+ catch {
114
+ }
115
+ }
@@ -0,0 +1,26 @@
1
+ import type { Plugin } from '@budarin/pluggable-serviceworker';
2
+ export { OPFS_META_FOOTER_LENGTH, OPFS_FOLDER_NAME, KILOBYTE, MEGABYTE, GIGABYTE, type OpfsMetadata, } from './opfsFormat.js';
3
+ export { getOpfsDir, clearOpfsCache, configureOpfs, isOpfsAvailable, getMaxCacheFraction, type OpfsConfigOptions, } from './opfsUtil.js';
4
+ export { isBlacklisted, addToBlacklist, getStorageEstimate, getCacheLimit } from './opfsLru.js';
5
+ export type { StorageEstimate, CacheFileEntry, EnsureSpaceResult } from './opfsLru.js';
6
+ export { OPFS_MSG_QUOTA_EXCEEDED, OPFS_MSG_WRITE_SKIPPED_SIZE, OPFS_MSG_CACHE_LIMIT_REACHED, OPFS_MSG_EVICTION_COMPLETED, OPFS_MSG_WRITE_FAILED, OPFS_MSG_SKIP_QUOTA_EXCEEDED, } from './opfsMessages.js';
7
+ export type { OpfsMessageType } from './opfsMessages.js';
8
+ export type { WriteToOpfsOptions } from './opfsWrite.js';
9
+ export interface OpfsServeRangeOptions {
10
+ order?: number;
11
+ enableLogging?: boolean;
12
+ include?: string[];
13
+ exclude?: string[];
14
+ rangeResponseCacheControl?: string;
15
+ }
16
+ export declare function urlToOpfsKey(url: string): Promise<string>;
17
+ export declare function opfsServeRange(options?: OpfsServeRangeOptions): Plugin | undefined;
18
+ export { parseRangeHeader, build206Response, build206ResponseFromStream, createRangeExtractTransform, } from './opfsRangeUtil.js';
19
+ export type { RangeSpec, Build206Options } from './opfsRangeUtil.js';
20
+ export { writeToOpfs, metadataFromResponse } from './opfsWrite.js';
21
+ export { opfsPrecache } from './opfsPrecache.js';
22
+ export type { OpfsPrecacheOptions } from './opfsPrecache.js';
23
+ export { opfsRangeFromNetworkAndCache } from './opfsRangeFromNetworkAndCache.js';
24
+ export type { OpfsRangeFromNetworkAndCacheOptions } from './opfsRangeFromNetworkAndCache.js';
25
+ export { opfsBackgroundFetch } from './opfsBackgroundFetch.js';
26
+ export type { OpfsBackgroundFetchOptions } from './opfsBackgroundFetch.js';