@rockhall/electron-offline-content 0.4.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.
Files changed (196) hide show
  1. package/CHANGELOG.md +384 -0
  2. package/LICENSE +21 -0
  3. package/README.md +794 -0
  4. package/dist/internal/asset-file-name.cjs +13 -0
  5. package/dist/internal/asset-file-name.cjs.map +1 -0
  6. package/dist/internal/asset-file-name.d.cts +6 -0
  7. package/dist/internal/asset-file-name.d.cts.map +1 -0
  8. package/dist/internal/asset-file-name.d.ts +6 -0
  9. package/dist/internal/asset-file-name.d.ts.map +1 -0
  10. package/dist/internal/asset-file-name.js +12 -0
  11. package/dist/internal/asset-file-name.js.map +1 -0
  12. package/dist/internal/asset-key.cjs +30 -0
  13. package/dist/internal/asset-key.cjs.map +1 -0
  14. package/dist/internal/asset-key.d.cts +19 -0
  15. package/dist/internal/asset-key.d.cts.map +1 -0
  16. package/dist/internal/asset-key.d.ts +19 -0
  17. package/dist/internal/asset-key.d.ts.map +1 -0
  18. package/dist/internal/asset-key.js +27 -0
  19. package/dist/internal/asset-key.js.map +1 -0
  20. package/dist/internal/log-format.cjs +98 -0
  21. package/dist/internal/log-format.cjs.map +1 -0
  22. package/dist/internal/log-format.d.cts +10 -0
  23. package/dist/internal/log-format.d.cts.map +1 -0
  24. package/dist/internal/log-format.d.ts +10 -0
  25. package/dist/internal/log-format.d.ts.map +1 -0
  26. package/dist/internal/log-format.js +97 -0
  27. package/dist/internal/log-format.js.map +1 -0
  28. package/dist/internal/media-kind.cjs +46 -0
  29. package/dist/internal/media-kind.cjs.map +1 -0
  30. package/dist/internal/media-kind.d.cts +20 -0
  31. package/dist/internal/media-kind.d.cts.map +1 -0
  32. package/dist/internal/media-kind.d.ts +20 -0
  33. package/dist/internal/media-kind.d.ts.map +1 -0
  34. package/dist/internal/media-kind.js +45 -0
  35. package/dist/internal/media-kind.js.map +1 -0
  36. package/dist/internal/url-warn.cjs +14 -0
  37. package/dist/internal/url-warn.cjs.map +1 -0
  38. package/dist/internal/url-warn.d.cts +10 -0
  39. package/dist/internal/url-warn.d.cts.map +1 -0
  40. package/dist/internal/url-warn.d.ts +10 -0
  41. package/dist/internal/url-warn.d.ts.map +1 -0
  42. package/dist/internal/url-warn.js +13 -0
  43. package/dist/internal/url-warn.js.map +1 -0
  44. package/dist/internal/validation.cjs +222 -0
  45. package/dist/internal/validation.cjs.map +1 -0
  46. package/dist/internal/validation.d.cts +78 -0
  47. package/dist/internal/validation.d.cts.map +1 -0
  48. package/dist/internal/validation.d.ts +78 -0
  49. package/dist/internal/validation.d.ts.map +1 -0
  50. package/dist/internal/validation.js +196 -0
  51. package/dist/internal/validation.js.map +1 -0
  52. package/dist/main/asset-download.cjs +265 -0
  53. package/dist/main/asset-download.cjs.map +1 -0
  54. package/dist/main/asset-download.d.cts +12 -0
  55. package/dist/main/asset-download.d.cts.map +1 -0
  56. package/dist/main/asset-download.d.ts +12 -0
  57. package/dist/main/asset-download.d.ts.map +1 -0
  58. package/dist/main/asset-download.js +263 -0
  59. package/dist/main/asset-download.js.map +1 -0
  60. package/dist/main/database.cjs +473 -0
  61. package/dist/main/database.cjs.map +1 -0
  62. package/dist/main/database.d.cts +81 -0
  63. package/dist/main/database.d.cts.map +1 -0
  64. package/dist/main/database.d.ts +81 -0
  65. package/dist/main/database.d.ts.map +1 -0
  66. package/dist/main/database.js +472 -0
  67. package/dist/main/database.js.map +1 -0
  68. package/dist/main/index.cjs +22 -0
  69. package/dist/main/index.d.cts +7 -0
  70. package/dist/main/index.d.ts +7 -0
  71. package/dist/main/index.js +7 -0
  72. package/dist/main/media-cache.cjs +862 -0
  73. package/dist/main/media-cache.cjs.map +1 -0
  74. package/dist/main/media-cache.d.cts +134 -0
  75. package/dist/main/media-cache.d.cts.map +1 -0
  76. package/dist/main/media-cache.d.ts +134 -0
  77. package/dist/main/media-cache.d.ts.map +1 -0
  78. package/dist/main/media-cache.js +854 -0
  79. package/dist/main/media-cache.js.map +1 -0
  80. package/dist/main/storage-root-lock.cjs +124 -0
  81. package/dist/main/storage-root-lock.cjs.map +1 -0
  82. package/dist/main/storage-root-lock.d.cts +11 -0
  83. package/dist/main/storage-root-lock.d.cts.map +1 -0
  84. package/dist/main/storage-root-lock.d.ts +11 -0
  85. package/dist/main/storage-root-lock.d.ts.map +1 -0
  86. package/dist/main/storage-root-lock.js +120 -0
  87. package/dist/main/storage-root-lock.js.map +1 -0
  88. package/dist/main/store.cjs +197 -0
  89. package/dist/main/store.cjs.map +1 -0
  90. package/dist/main/store.d.cts +83 -0
  91. package/dist/main/store.d.cts.map +1 -0
  92. package/dist/main/store.d.ts +83 -0
  93. package/dist/main/store.d.ts.map +1 -0
  94. package/dist/main/store.js +195 -0
  95. package/dist/main/store.js.map +1 -0
  96. package/dist/preload/index.cjs +36 -0
  97. package/dist/preload/index.cjs.map +1 -0
  98. package/dist/preload/index.d.cts +14 -0
  99. package/dist/preload/index.d.cts.map +1 -0
  100. package/dist/preload/index.d.ts +14 -0
  101. package/dist/preload/index.d.ts.map +1 -0
  102. package/dist/preload/index.js +34 -0
  103. package/dist/preload/index.js.map +1 -0
  104. package/dist/react/index.cjs +199 -0
  105. package/dist/react/index.cjs.map +1 -0
  106. package/dist/react/index.d.cts +50 -0
  107. package/dist/react/index.d.cts.map +1 -0
  108. package/dist/react/index.d.ts +50 -0
  109. package/dist/react/index.d.ts.map +1 -0
  110. package/dist/react/index.js +191 -0
  111. package/dist/react/index.js.map +1 -0
  112. package/dist/renderer/helpers.cjs +36 -0
  113. package/dist/renderer/helpers.cjs.map +1 -0
  114. package/dist/renderer/helpers.d.cts +11 -0
  115. package/dist/renderer/helpers.d.cts.map +1 -0
  116. package/dist/renderer/helpers.d.ts +11 -0
  117. package/dist/renderer/helpers.d.ts.map +1 -0
  118. package/dist/renderer/helpers.js +35 -0
  119. package/dist/renderer/helpers.js.map +1 -0
  120. package/dist/renderer/index.cjs +20 -0
  121. package/dist/renderer/index.cjs.map +1 -0
  122. package/dist/renderer/index.d.cts +14 -0
  123. package/dist/renderer/index.d.cts.map +1 -0
  124. package/dist/renderer/index.d.ts +14 -0
  125. package/dist/renderer/index.d.ts.map +1 -0
  126. package/dist/renderer/index.js +14 -0
  127. package/dist/renderer/index.js.map +1 -0
  128. package/dist/renderer/runtime.cjs +278 -0
  129. package/dist/renderer/runtime.cjs.map +1 -0
  130. package/dist/renderer/runtime.d.cts +35 -0
  131. package/dist/renderer/runtime.d.cts.map +1 -0
  132. package/dist/renderer/runtime.d.ts +35 -0
  133. package/dist/renderer/runtime.d.ts.map +1 -0
  134. package/dist/renderer/runtime.js +273 -0
  135. package/dist/renderer/runtime.js.map +1 -0
  136. package/dist/renderer/window-globals.d.cts +9 -0
  137. package/dist/renderer/window-globals.d.cts.map +1 -0
  138. package/dist/renderer/window-globals.d.ts +9 -0
  139. package/dist/renderer/window-globals.d.ts.map +1 -0
  140. package/dist/shared/errors.cjs +102 -0
  141. package/dist/shared/errors.cjs.map +1 -0
  142. package/dist/shared/errors.d.cts +45 -0
  143. package/dist/shared/errors.d.cts.map +1 -0
  144. package/dist/shared/errors.d.ts +45 -0
  145. package/dist/shared/errors.d.ts.map +1 -0
  146. package/dist/shared/errors.js +93 -0
  147. package/dist/shared/errors.js.map +1 -0
  148. package/dist/shared/ipc.cjs +14 -0
  149. package/dist/shared/ipc.cjs.map +1 -0
  150. package/dist/shared/ipc.d.cts +12 -0
  151. package/dist/shared/ipc.d.cts.map +1 -0
  152. package/dist/shared/ipc.d.ts +12 -0
  153. package/dist/shared/ipc.d.ts.map +1 -0
  154. package/dist/shared/ipc.js +13 -0
  155. package/dist/shared/ipc.js.map +1 -0
  156. package/dist/shared/normalize.cjs +19 -0
  157. package/dist/shared/normalize.cjs.map +1 -0
  158. package/dist/shared/normalize.d.cts +11 -0
  159. package/dist/shared/normalize.d.cts.map +1 -0
  160. package/dist/shared/normalize.d.ts +11 -0
  161. package/dist/shared/normalize.d.ts.map +1 -0
  162. package/dist/shared/normalize.js +18 -0
  163. package/dist/shared/normalize.js.map +1 -0
  164. package/dist/shared/pagination.cjs +32 -0
  165. package/dist/shared/pagination.cjs.map +1 -0
  166. package/dist/shared/pagination.d.cts +14 -0
  167. package/dist/shared/pagination.d.cts.map +1 -0
  168. package/dist/shared/pagination.d.ts +14 -0
  169. package/dist/shared/pagination.d.ts.map +1 -0
  170. package/dist/shared/pagination.js +28 -0
  171. package/dist/shared/pagination.js.map +1 -0
  172. package/dist/shared/stem.cjs +16 -0
  173. package/dist/shared/stem.cjs.map +1 -0
  174. package/dist/shared/stem.d.cts +6 -0
  175. package/dist/shared/stem.d.cts.map +1 -0
  176. package/dist/shared/stem.d.ts +6 -0
  177. package/dist/shared/stem.d.ts.map +1 -0
  178. package/dist/shared/stem.js +14 -0
  179. package/dist/shared/stem.js.map +1 -0
  180. package/dist/shared/types.cjs +15 -0
  181. package/dist/shared/types.cjs.map +1 -0
  182. package/dist/shared/types.d.cts +234 -0
  183. package/dist/shared/types.d.cts.map +1 -0
  184. package/dist/shared/types.d.ts +234 -0
  185. package/dist/shared/types.d.ts.map +1 -0
  186. package/dist/shared/types.js +14 -0
  187. package/dist/shared/types.js.map +1 -0
  188. package/package.json +120 -0
  189. package/skills/authenticated-downloads/SKILL.md +203 -0
  190. package/skills/cache-configuration/SKILL.md +357 -0
  191. package/skills/cache-configuration/references/options.md +356 -0
  192. package/skills/getting-started/SKILL.md +407 -0
  193. package/skills/production-checklist/SKILL.md +397 -0
  194. package/skills/react-rendering/SKILL.md +424 -0
  195. package/skills/react-rendering/references/hooks.md +443 -0
  196. package/skills/store-authoring/SKILL.md +369 -0
package/README.md ADDED
@@ -0,0 +1,794 @@
1
+ # `@rockhall/electron-offline-content`
2
+
3
+ Download, index, and serve offline media content in Electron apps.
4
+
5
+ - Flat key-value asset store with user-defined secondary indexes
6
+ - Disk-backed binary asset cache with atomic downloads
7
+ - Privileged `media://` protocol for renderer-safe local URLs
8
+ - Preload bridge and React hooks for renderer access
9
+ - Dev passthrough mode for local development without downloading assets
10
+
11
+ ## Table of contents
12
+
13
+ - [When not to use this package](#when-not-to-use-this-package)
14
+ - [Prerequisites](#prerequisites)
15
+ - [Install](#install)
16
+ - [Quick start](#quick-start)
17
+ - [Store authoring](#store-authoring)
18
+ - [Secondary indexes and querying](#secondary-indexes-and-querying)
19
+ - [Dev passthrough mode](#dev-passthrough-mode)
20
+ - [Signed URLs and authentication](#signed-urls-and-authentication)
21
+ - [Error handling and sync failures](#error-handling-and-sync-failures)
22
+ - [Logging](#logging)
23
+ - [Storage limits](#storage-limits)
24
+ - [API reference](#api-reference)
25
+ - [Notes](#notes)
26
+ - [Example apps](#example-apps)
27
+
28
+ ## When not to use this package
29
+
30
+ This package is opinionated. It codifies a specific content-sync model for kiosk-style Electron apps rather than trying to be a general-purpose cache layer.
31
+
32
+ - **General-purpose HTTP cache** -- this syncs a full asset store of offline content; it is not a generic fetch cache or service worker replacement.
33
+ - **Incremental or on-demand fetching** -- v1 syncs the entire catalog on every run. If your app needs lazy loading or partial sync, this is not the right fit.
34
+ - **Non-Electron apps** -- the package depends on Electron APIs (`app.getPath`, `session.protocol`, `contextBridge`).
35
+ - **Small ephemeral data** -- if you just need key-value storage or simple config persistence, `localStorage` or a lightweight store is simpler.
36
+
37
+ ## Prerequisites
38
+
39
+ - Node.js >= 24 (`node:sqlite` is used for the local metadata index)
40
+ - pnpm 11.1.0
41
+ - Electron >= 40
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pnpm add @rockhall/electron-offline-content
47
+ ```
48
+
49
+ `react >= 18` and `react-dom >= 18` are optional peer dependencies, needed only when using `@rockhall/electron-offline-content/react`.
50
+
51
+ ## Quick start
52
+
53
+ A minimal integration touches the main process, a preload script, and the renderer. The store typically comes from an external source (a CMS, an API, a static config), so we keep the build logic in its own file.
54
+
55
+ ### 1. Build a store
56
+
57
+ Create a module that fetches your content catalog and builds a flat asset store. This function will be called on every sync.
58
+
59
+ ```ts
60
+ // fetch-content.ts
61
+ import { createMediaStore } from "@rockhall/electron-offline-content/main";
62
+
63
+ export async function resolveStore() {
64
+ const response = await fetch("https://cms.example.com/api/videos");
65
+ const videos = await response.json();
66
+
67
+ const store = createMediaStore();
68
+ const collection = store.defineIndex("collection");
69
+
70
+ for (const video of videos) {
71
+ store.add(["videos", video.slug, "main"], {
72
+ version: video.updatedAt,
73
+ mimeType: "video/mp4",
74
+ url: video.fileUrl,
75
+ indexes: [collection("videos")],
76
+ });
77
+ }
78
+
79
+ return store;
80
+ }
81
+ ```
82
+
83
+ ### 2. Main process
84
+
85
+ Import your `resolveStore` and wire up the cache. Create the cache **before** `app.whenReady()` so the privileged `media:` scheme registers in time.
86
+
87
+ ```ts
88
+ // main.ts
89
+ import { app } from "electron";
90
+ import { createMediaCache } from "@rockhall/electron-offline-content/main";
91
+ import { resolveStore } from "./fetch-content.js";
92
+
93
+ const mediaCache = createMediaCache({
94
+ storagePath: {
95
+ appPath: "temp",
96
+ segments: ["my-app", "offline-media"],
97
+ },
98
+ resolveStore,
99
+ });
100
+
101
+ await app.whenReady();
102
+ await mediaCache.start(); // registers protocol, attaches IPC, runs initial sync
103
+ ```
104
+
105
+ ### 3. Preload
106
+
107
+ Expose the IPC bridge on `window.mediaCache` so the renderer can query the cache.
108
+
109
+ ```ts
110
+ import { exposeMediaCacheBridge } from "@rockhall/electron-offline-content/preload";
111
+
112
+ exposeMediaCacheBridge();
113
+ ```
114
+
115
+ ### 4. Renderer (React)
116
+
117
+ Wrap your app in `MediaCacheProvider` and use hooks to access content.
118
+
119
+ ```tsx
120
+ import {
121
+ MediaCacheProvider,
122
+ useMediaByIndex,
123
+ useMediaAsset,
124
+ useMediaBridge,
125
+ } from "@rockhall/electron-offline-content/react";
126
+
127
+ function App() {
128
+ const videos = useMediaByIndex("collection", "videos", { limit: 20 });
129
+ const { errors } = useMediaBridge();
130
+
131
+ if (videos.loading) {
132
+ return <div>Loading...</div>;
133
+ }
134
+ if (errors.primaryError) {
135
+ return <div>{errors.primaryError.message}</div>;
136
+ }
137
+
138
+ return (
139
+ <div>
140
+ {videos.data?.items.map((asset) => (
141
+ <video key={asset.key} src={asset.url} title={asset.displayKey} controls />
142
+ ))}
143
+ </div>
144
+ );
145
+ }
146
+
147
+ export function Root() {
148
+ return (
149
+ <MediaCacheProvider>
150
+ <App />
151
+ </MediaCacheProvider>
152
+ );
153
+ }
154
+ ```
155
+
156
+ ## Store authoring
157
+
158
+ The store describes every downloadable asset and its metadata. `resolveStore` must return a full authoritative snapshot each time it is called -- the package diffs it against the local catalog and downloads only what changed.
159
+
160
+ ### Store shape
161
+
162
+ A store is a flat collection of keyed **assets**, each tagged with optional **secondary indexes**:
163
+
164
+ ```ts
165
+ import { createMediaStore } from "@rockhall/electron-offline-content/main";
166
+
167
+ const store = createMediaStore({
168
+ expiresAt: "2026-03-10T18:00:00.000Z", // optional global URL expiration cutoff
169
+ });
170
+
171
+ const gallery = store.defineIndex("gallery");
172
+ const role = store.defineIndex("role");
173
+
174
+ store.add(["lobby", "spring-campaign", "video"], {
175
+ version: "2026-03-10.1",
176
+ mimeType: "video/mp4",
177
+ url: "https://cdn.example.com/spring-campaign.mp4",
178
+ indexes: [gallery("lobby"), role("primary")],
179
+ });
180
+
181
+ store.add(["lobby", "spring-campaign", "poster"], {
182
+ version: "2026-03-10.1",
183
+ mimeType: "image/jpeg",
184
+ url: "https://cdn.example.com/spring-campaign-poster.jpg",
185
+ indexes: [gallery("lobby"), role("poster")],
186
+ });
187
+ ```
188
+
189
+ Pass a non-empty string or a non-empty array of non-empty string segments as the first argument to `store.add()` (the `AssetKeyInput` type). Segments are joined for a human-readable `displayKey` on resolved assets; internally the package stores a **SHA-256 hash** (first 16 hex characters) as the stable `key`. Either form can be used with `getAsset()` / `useMediaAsset()` as long as it matches what you passed to `add()`.
190
+
191
+ ### Building stores from arrays
192
+
193
+ When your source data is array-shaped, iterate and call `store.add` for each entry:
194
+
195
+ ```ts
196
+ import {
197
+ createMediaStore,
198
+ } from "@rockhall/electron-offline-content/main";
199
+
200
+ type VideoRow = {
201
+ id: string;
202
+ version: string;
203
+ title: string;
204
+ fileUrl: string;
205
+ posterUrl: string;
206
+ };
207
+
208
+ const videos: VideoRow[] = /* from CMS/API */;
209
+
210
+ const store = createMediaStore();
211
+ const collection = store.defineIndex("collection");
212
+
213
+ for (const v of videos) {
214
+ store.add(["videos", v.id, "main"], {
215
+ version: v.version,
216
+ mimeType: "video/mp4",
217
+ url: v.fileUrl,
218
+ metadata: { title: v.title },
219
+ indexes: [collection("videos")],
220
+ });
221
+ store.add(["videos", v.id, "poster"], {
222
+ version: v.version,
223
+ mimeType: "image/jpeg",
224
+ url: v.posterUrl,
225
+ metadata: { title: v.title },
226
+ indexes: [collection("videos")],
227
+ });
228
+ }
229
+ ```
230
+
231
+ ### Callable index handles and `IndexTag`
232
+
233
+ `store.defineIndex` returns a `MediaIndex` value: a callable function (typed as an interface) plus read-only metadata (`indexName`, `cardinality`, `required`). Call the handle with a string (single-value indexes) or `string[]` (multi-value indexes) to produce an `IndexTag` instance. Pass those tags in the `indexes` array on each `store.add(assetKey, input)` call (`assetKey` is `AssetKeyInput`):
234
+
235
+ ```ts
236
+ const gallery = store.defineIndex("gallery");
237
+
238
+ store.add(["photos", "photo-1"], {
239
+ version: "v1",
240
+ mimeType: "image/jpeg",
241
+ url: "https://cdn.example.com/photo-1.jpg",
242
+ indexes: [gallery("nature")],
243
+ });
244
+
245
+ store.add(["photos", "photo-2"], {
246
+ version: "v1",
247
+ mimeType: "image/jpeg",
248
+ url: "https://cdn.example.com/photo-2.jpg",
249
+ indexes: [gallery("wildlife")],
250
+ });
251
+
252
+ // Index name is available on the handle without invoking it
253
+ console.log(gallery.indexName); // "gallery"
254
+ ```
255
+
256
+ ### Signed URL expiration
257
+
258
+ If your store contains pre-signed asset URLs with a shared TTL, set `expiresAt` so the sync fails fast with a clear error once those URLs are stale:
259
+
260
+ ```ts
261
+ const store = createMediaStore({
262
+ expiresAt: "2026-03-10T18:00:00.000Z",
263
+ });
264
+ ```
265
+
266
+ The cache checks `expiresAt` immediately after store resolution and again before each late-queue download starts, so once `now >= expiresAt` the run fails with `STORE_EXPIRED` instead of surfacing a later opaque HTTP 403.
267
+
268
+ ### Validation rules
269
+
270
+ - Asset keys must be unique (storage identity is a SHA-256–derived hash; duplicates are rejected).
271
+ - Key inputs must be a non-empty string or a non-empty array of non-empty strings; there is no further key content validation.
272
+ - `version` is required on every asset (the package is version-driven for cache busting).
273
+ - `mimeType` is required and must be a valid `type/subtype` string.
274
+ - `fileName` is optional; when omitted, derived from the URL basename.
275
+ - `url` must use `http` or `https`.
276
+ - `expiresAt` is optional; when present, it must be an ISO 8601 timestamp.
277
+ - Indexes referenced in `store.add` must have been declared with `store.defineIndex` first.
278
+ - Built-in index names (`mimeType`, `mediaKind`) cannot be used with `defineIndex`.
279
+
280
+ ## Secondary indexes and querying
281
+
282
+ Indexes are the primary way to organize and query assets in the flat store.
283
+
284
+ ### Defining indexes
285
+
286
+ Call `store.defineIndex(name)` before adding assets that reference the index. Each index has a cardinality (`"single"` or `"multi"`) and can be marked as required:
287
+
288
+ ```ts
289
+ const store = createMediaStore();
290
+
291
+ // Single-value index (default): each asset maps to one string value
292
+ const collection = store.defineIndex("collection");
293
+
294
+ // Multi-value index: each asset can map to multiple string values
295
+ const tags = store.defineIndex("tags", { cardinality: "multi" });
296
+
297
+ // Required index: every asset must provide a value
298
+ const category = store.defineIndex("category", { required: true });
299
+ ```
300
+
301
+ ### Tagging assets with indexes
302
+
303
+ Pass `IndexTag` values in the `indexes` array when calling `store.add` (each tag comes from calling a `defineIndex` handle):
304
+
305
+ ```ts
306
+ store.add(["forest", "video"], {
307
+ version: "v1",
308
+ mimeType: "video/mp4",
309
+ url: "https://cdn.example.com/forest.mp4",
310
+ indexes: [collection("nature"), tags(["forest", "outdoor", "4k"]), category("video")],
311
+ });
312
+ ```
313
+
314
+ ### Querying by index
315
+
316
+ From the main process, use `cache.listByIndex(indexName, value)`:
317
+
318
+ ```ts
319
+ const natureVideos = await mediaCache.listByIndex("collection", "nature", { limit: 20 });
320
+ ```
321
+
322
+ From React, use the `useMediaByIndex` hook:
323
+
324
+ ```tsx
325
+ const natureVideos = useMediaByIndex("collection", "nature", { limit: 20 });
326
+ ```
327
+
328
+ ### Built-in indexes
329
+
330
+ Two indexes are automatically populated for every asset without any `defineIndex` call:
331
+
332
+ | Index | Source | Example values |
333
+ | ----------- | ----------------------------------------------- | ----------------------------------------------------------------------------- |
334
+ | `mimeType` | The asset's `mimeType` field | `"video/mp4"`, `"image/jpeg"` |
335
+ | `mediaKind` | Derived from `mimeType` via `mediaKindFromMime` | `"video"`, `"image"`, `"audio"`, `"document"`, `"html"`, `"text"`, `"binary"` |
336
+
337
+ Query all images:
338
+
339
+ ```tsx
340
+ const images = useMediaByIndex("mediaKind", "image", { limit: 50 });
341
+ ```
342
+
343
+ ### Namespace-like grouping via indexes
344
+
345
+ If your app has a namespace concept, model it as an index:
346
+
347
+ ```ts
348
+ const namespace = store.defineIndex("namespace");
349
+
350
+ store.add(["lobby", "welcome-video"], {
351
+ version: "v1",
352
+ mimeType: "video/mp4",
353
+ url: "https://cdn.example.com/welcome.mp4",
354
+ indexes: [namespace("lobby")],
355
+ });
356
+
357
+ store.add(["exhibits", "hubble"], {
358
+ version: "v1",
359
+ mimeType: "image/jpeg",
360
+ url: "https://cdn.example.com/hubble.jpg",
361
+ indexes: [namespace("exhibits")],
362
+ });
363
+ ```
364
+
365
+ Then query:
366
+
367
+ ```tsx
368
+ const lobbyAssets = useMediaByIndex("namespace", "lobby", { limit: 20 });
369
+ ```
370
+
371
+ ### File stem search
372
+
373
+ `useFileStemMatch` finds assets by the normalized filename stem (name without extension) of their download URL:
374
+
375
+ ```tsx
376
+ const matches = useFileStemMatch("spring-campaign", { limit: 10 });
377
+ ```
378
+
379
+ ## Dev passthrough mode
380
+
381
+ In dev passthrough mode, the package skips downloading asset blobs and returns direct remote URLs from the store instead of `media://` URLs. Store metadata is still committed locally so all query APIs continue to work.
382
+
383
+ `devPassthrough` defaults to `process.env.NODE_ENV === "development"`. You can override this explicitly:
384
+
385
+ ```ts
386
+ const mediaCache = createMediaCache({
387
+ storagePath: { appPath: "temp", segments: ["my-app"] },
388
+ devPassthrough: process.env.FOO !== "true",
389
+ resolveStore: async () => store,
390
+ });
391
+ ```
392
+
393
+ `assetBaseUrl` is an optional origin override for passthrough mode. It replaces only the origin of each asset's URL (preserving path and query string). It must be an origin only -- no path, query string, hash, or credentials.
394
+
395
+ **Limitations in v1:** dev passthrough is limited to public assets. Assets that require signed URLs or other authenticated downloads are not supported in this mode unless the URL itself embeds auth (for example a presigned URL). Startup is fail-fast (sync failures always throw; `onSyncFailure` is ignored).
396
+
397
+ ## Signed URLs and authentication
398
+
399
+ Downloads use a plain `GET` to each asset’s `url` string. There is no separate per-asset request hook and no support for custom HTTP methods or headers—put credentials into the URL (typically a presigned URL) when you build the store in `resolveStore()`.
400
+
401
+ **Signed URLs** -- generate pre-signed URLs at store build time:
402
+
403
+ ```ts
404
+ import { createMediaStore } from "@rockhall/electron-offline-content/main";
405
+
406
+ async function resolveStore() {
407
+ const store = createMediaStore({
408
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(), // 1 hour
409
+ });
410
+
411
+ const videos = await fetchCatalog();
412
+ for (const video of videos) {
413
+ const signedUrl = await getS3PresignedUrl(video.s3Key);
414
+ store.add(["videos", video.id], {
415
+ version: video.version,
416
+ mimeType: "video/mp4",
417
+ url: signedUrl,
418
+ });
419
+ }
420
+
421
+ return store;
422
+ }
423
+ ```
424
+
425
+ Set `expiresAt` on the store to the earliest shared URL expiry. The cache checks `expiresAt` immediately after store resolution and again before each download starts, so expired URLs fail with `STORE_EXPIRED` rather than an opaque HTTP 403.
426
+
427
+ ## Error handling and sync failures
428
+
429
+ ### Sync failure modes
430
+
431
+ `onSyncFailure` controls what happens when a sync run fails while a previous generation exists on disk:
432
+
433
+ - `"serve-last-snapshot"` (default) -- the previous committed snapshot remains active. The cache continues serving content.
434
+ - `"throw"` -- the sync failure propagates. Use this when stale content is not acceptable.
435
+
436
+ ```ts
437
+ const mediaCache = createMediaCache({
438
+ storagePath: { appPath: "temp", segments: ["my-app"] },
439
+ onSyncFailure: "throw",
440
+ resolveStore: async () => store,
441
+ });
442
+ ```
443
+
444
+ ### Error classes
445
+
446
+ All errors extend `MediaCacheError`, which carries a `code` string for programmatic handling:
447
+
448
+ | Error | Code | When |
449
+ | ----------------------- | ------------------------- | ---------------------------------------------------------------------- |
450
+ | `StoreValidationError` | `STORE_VALIDATION_ERROR` | Store is malformed (duplicate keys, missing fields, undefined indexes) |
451
+ | `StoreExpiredError` | `STORE_EXPIRED` | Store-declared asset URLs are past `expiresAt` |
452
+ | `DataValidationError` | `DATA_VALIDATION_ERROR` | Persisted state fails validation |
453
+ | `StorageOwnershipError` | `STORAGE_OWNERSHIP_ERROR` | Another process or instance owns the storage root |
454
+ | `StorageLimitError` | `STORAGE_LIMIT_ERROR` | Disk full, `maxCacheBytes` exceeded, or `reserveFreeBytes` violated |
455
+ | `SyncFailureError` | `SYNC_FAILURE` | Network or HTTP failure downloading assets |
456
+
457
+ ### Renderer error aggregation
458
+
459
+ `useMediaCacheErrors()` combines sync errors and all active query errors under the current `MediaCacheProvider` into a single view:
460
+
461
+ ```tsx
462
+ const featured = useMediaAsset(["space", "hubble-cosmos", "main"]);
463
+ const catalog = useMediaByIndex("collection", "space", { limit: 20 });
464
+ const errors = useMediaCacheErrors();
465
+
466
+ if (errors.hasError) {
467
+ console.error(errors.primaryError);
468
+ }
469
+ ```
470
+
471
+ `errors.primaryError` is the single most relevant error for display. `errors.syncError`, `errors.statusError`, and `errors.queryErrors` are available for more granular handling.
472
+
473
+ ## Logging
474
+
475
+ When `logging?.onLog` is omitted and `NODE_ENV` is not `"production"`, the package prints to the main-process console. Lines are human-readable English by default.
476
+
477
+ ### Custom log sink
478
+
479
+ Pass `logging.onLog` to receive structured `MediaCacheLogEvent` objects and forward them to your logger (pino, logtape, etc.):
480
+
481
+ ```ts
482
+ const mediaCache = createMediaCache({
483
+ storagePath: { appPath: "temp", segments: ["my-app"] },
484
+ logging: {
485
+ level: "info",
486
+ onLog: (entry) => {
487
+ logger.log(entry.level, entry.event, entry);
488
+ },
489
+ },
490
+ resolveStore: async () => store,
491
+ });
492
+ ```
493
+
494
+ ### Built-in console formatting
495
+
496
+ Use `logging.format` only when you want the package's built-in development console sink:
497
+
498
+ ```ts
499
+ const mediaCache = createMediaCache({
500
+ storagePath: { appPath: "temp", segments: ["my-app"] },
501
+ logging: {
502
+ level: "debug",
503
+ format: "json",
504
+ },
505
+ resolveStore: async () => store,
506
+ });
507
+ ```
508
+
509
+ ### Log options
510
+
511
+ | Option | Default | Description |
512
+ | ---------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- |
513
+ | `logging.onLog` | `undefined` | Structured log callback. Replaces the built-in console sink. |
514
+ | `logging.level` | `"debug"` (console) / `"info"` (`onLog`) | Minimum severity emitted. |
515
+ | `logging.format` | `"english"` | Built-in console line format: `"english"` or `"json"`. Cannot be used with `onLog`. |
516
+
517
+ ### Notable events
518
+
519
+ - `resolve_asset_base_url_fallback` (warn) -- a stored asset URL could not be parsed during origin override in passthrough mode.
520
+ - `dev_passthrough_ignores_sync_failure_mode` (warn) -- `devPassthrough` is true and `onSyncFailure` is not `"throw"`.
521
+ - `store_expired` (warn) -- the store declared `expiresAt` and the sync reached or passed it before download work completed.
522
+ - `protocol_request_not_found` (debug) -- no matching generation or asset for a `media://` request.
523
+ - `protocol_request_file_missing` (debug) -- asset exists in DB but file is absent on disk.
524
+
525
+ ## Storage limits
526
+
527
+ Configure disk usage guardrails to prevent the cache from consuming unbounded space:
528
+
529
+ ```ts
530
+ const mediaCache = createMediaCache({
531
+ storagePath: { appPath: "temp", segments: ["my-app"] },
532
+ maxCacheBytes: 10 * 1024 * 1024 * 1024, // 10 GB
533
+ reserveFreeBytes: 1 * 1024 * 1024 * 1024, // keep 1 GB free
534
+ staleDeleteAfterMs: 7 * 24 * 60 * 60 * 1000, // 7 days (default)
535
+ resolveStore: async () => store,
536
+ });
537
+ ```
538
+
539
+ | Option | Default | Description |
540
+ | -------------------- | --------------------- | -------------------------------------------------------------------------------- |
541
+ | `maxCacheBytes` | `undefined` | Soft cap on total bytes of cached asset files. |
542
+ | `reserveFreeBytes` | 1 GiB (`1024³` bytes) | Minimum free disk space to preserve on the cache volume. Set **`0`** to disable. |
543
+ | `staleDeleteAfterMs` | 7 days | Grace period before assets removed from the store are deleted from disk. |
544
+
545
+ When limits are exceeded, the sync raises `StorageLimitError`. The configured `onSyncFailure` mode then applies.
546
+
547
+ Omitting `reserveFreeBytes` still enforces a **1 GiB** cushion for the OS and other apps on the same volume. Stores that barely fit on a full disk may need a larger disk, a lower explicit `reserveFreeBytes`, or **`reserveFreeBytes: 0`** to restore the old "no reservation" behavior.
548
+
549
+ Assets removed from the store are not deleted immediately. They are marked for grace-period deletion and pruned after `staleDeleteAfterMs` expires.
550
+
551
+ ## API reference
552
+
553
+ ### `@rockhall/electron-offline-content/main`
554
+
555
+ #### `createMediaStore(options?)`
556
+
557
+ Creates a `MediaStore` instance for populating in a `resolveStore` callback.
558
+
559
+ **`MediaStoreOptions`**
560
+
561
+ | Option | Type | Required | Description |
562
+ | ------------- | -------- | -------- | ----------------------------------------------------------------- |
563
+ | `snapshotId` | `string` | no | Opaque id for correlation, debugging, or multi-source merges. |
564
+ | `retrievedAt` | `string` | no | ISO 8601 timestamp describing when the store payload was built. |
565
+ | `expiresAt` | `string` | no | ISO 8601 timestamp after which asset URLs are treated as expired. |
566
+
567
+ #### `MediaStore`
568
+
569
+ Returned by `createMediaStore`. Build the store imperatively by defining indexes and adding assets.
570
+
571
+ | Method | Returns | Description |
572
+ | ----------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------- |
573
+ | `defineIndex(name, options?)` | `MediaIndex` | Register a secondary index. Returns a callable handle. Options: `{ cardinality?: "single" \| "multi", required?: boolean }`. |
574
+ | `add(key, input)` | `void` | Add an asset. `key` is `AssetKeyInput` (`string` or `readonly string[]`); `input` is a `MediaAssetInput`. |
575
+ | `_serialize()` | `FlatManifest` | Internal: serializes for the sync engine. Not part of the public consumer API. |
576
+
577
+ **`MediaAssetInput`**
578
+
579
+ | Field | Type | Required | Description |
580
+ | ------------ | --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
581
+ | `version` | `string` | yes | Bump triggers re-download. |
582
+ | `mimeType` | `string` | yes | Valid `type/subtype` MIME type. |
583
+ | `fileName` | `string` | no | Override for the filename; derived from the URL when omitted. |
584
+ | `byteLength` | `number` | no | Expected file size in bytes; used for storage limit pre-checks. |
585
+ | `url` | `string` | yes | `http` or `https` download URL (use presigned URLs when auth is required). There is no `source` wrapper and no `MediaRemoteSource` type—only this flat string. |
586
+ | `metadata` | `Record<string, JsonValue>` | no | Arbitrary key-value metadata returned on resolved assets. |
587
+ | `indexes` | `IndexTag[]` | no | Tags from calling each `defineIndex` handle with a value (string or `string[]` for multi indexes). |
588
+
589
+ #### `MediaIndex`
590
+
591
+ Callable function type (interface) returned by `MediaStore.defineIndex`. Invoking it with a value produces an `IndexTag`. The handle also exposes read-only `indexName`, `cardinality`, and `required`.
592
+
593
+ ```ts
594
+ const gallery = store.defineIndex("gallery");
595
+ store.add(["photos", "photo-1"], {
596
+ version: "v1",
597
+ mimeType: "image/jpeg",
598
+ url: "https://cdn.example.com/photo-1.jpg",
599
+ indexes: [gallery("nature")],
600
+ });
601
+ console.log(gallery.indexName); // "gallery"
602
+ ```
603
+
604
+ #### `IndexTag`
605
+
606
+ Class whose instances are produced by calling a `MediaIndex` handle. Used as elements of `MediaAssetInput.indexes`. Exported from `@rockhall/electron-offline-content/main`.
607
+
608
+ #### `AssetKeyInput`
609
+
610
+ Type alias: `string | readonly string[]`. Used for `MediaStore.add`, `getAsset`, and `useMediaAsset`. Arrays are joined with `/` for `displayKey` and hashed for stable `key` identity.
611
+
612
+ #### `mediaKindFromMime(mimeType)`
613
+
614
+ Derives a coarse `MediaKind` from a MIME type string:
615
+
616
+ | Input pattern | Result |
617
+ | -------------------- | ------------ |
618
+ | `video/*` | `"video"` |
619
+ | `image/*` | `"image"` |
620
+ | `audio/*` | `"audio"` |
621
+ | `text/html` | `"html"` |
622
+ | `text/*` | `"text"` |
623
+ | Known document types | `"document"` |
624
+ | `application/json` | `"text"` |
625
+ | Everything else | `"binary"` |
626
+
627
+ #### `createMediaCache(options)`
628
+
629
+ Creates a `MediaCacheMain` instance. Call before `app.whenReady()` in offline mode.
630
+
631
+ **`MediaCacheOptions`**
632
+
633
+ | Option | Type | Required | Description |
634
+ | -------------------- | -------------------------- | -------- | ----------------------------------------------------------------------------------------------- |
635
+ | `storagePath` | `MediaCacheStoragePath` | yes | `{ appPath, segments? }` -- resolved via `app.getPath(appPath)` plus optional subpath segments. |
636
+ | `resolveStore` | callback | yes | Returns `MediaStore` or a `Promise<MediaStore>` for each sync. |
637
+ | `devPassthrough` | `boolean` | no | Skip downloads, return remote URLs. Auto-enabled when `NODE_ENV === "development"`. |
638
+ | `assetBaseUrl` | `string` | no | Origin override for dev passthrough (origin only, no path/query/hash). |
639
+ | `onSyncFailure` | `SyncFailureMode` | no | Behavior when a sync fails after a prior snapshot exists (`serve-last-snapshot` or `throw`). |
640
+ | `maxCacheBytes` | `number` | no | Soft cap on total cached bytes. |
641
+ | `reserveFreeBytes` | `number` | no | Minimum free disk bytes to preserve. Default **1 GiB**; **`0`** disables. |
642
+ | `staleDeleteAfterMs` | `number` | no | Grace period (ms) before pruning removed assets. Default 7 days. |
643
+ | `syncHistoryLimit` | `number` | no | Max completed sync runs retained in SQLite. Default 50. |
644
+ | `logging` | `MediaCacheLoggingOptions` | no | Nested logging config for either a custom sink or built-in console formatting. |
645
+
646
+ #### `MediaCacheMain`
647
+
648
+ Returned by `createMediaCache`. Requires exclusive ownership of its resolved storage root.
649
+
650
+ | Method | Returns | Description |
651
+ | -------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------- |
652
+ | `start()` | `Promise<void>` | One-call setup: register protocol, attach IPC, run initial sync. |
653
+ | `syncNow()` | `Promise<void>` | Run or join a sync. Concurrent callers share one run. |
654
+ | `getStatus()` | `Promise<MediaCacheStatus>` | Current phase, progress, last run, and error. |
655
+ | `getAsset(key)` | `Promise<ResolvedMediaAsset \| null>` | Single asset by `AssetKeyInput`, or `null` if missing. |
656
+ | `listByIndex(indexName, value, pagination?)` | `Promise<PaginationResult<ResolvedMediaAsset>>` | Assets matching a secondary index value, paginated. |
657
+ | `findByFileStem(stem, pagination?)` | `Promise<PaginationResult<FileStemMatch>>` | Search by normalized filename stem. |
658
+ | `registerProtocol(options?)` | `Promise<void>` | Register the `media:` handler on a session. |
659
+ | `attachIpc(options?)` | `Promise<void>` | Wire `ipcMain` handlers and broadcast status to renderer windows. |
660
+
661
+ In kiosk-style apps, call `app.requestSingleInstanceLock()` before constructing the cache. The package enforces storage-root exclusivity itself, but the instance lock prevents a second Electron process from launching.
662
+
663
+ #### Key types
664
+
665
+ **ResolvedMediaAsset** -- `{ key, displayKey, version, mimeType, kind: MediaKind, byteLength?, url, metadata: Record<string, JsonValue>, indexes: Record<string, string | string[]> }`. `key` is the stable storage hash; `displayKey` is the original human-readable key (string or segment path joined with `/`). `kind` is derived from `mimeType` via `mediaKindFromMime()`. `url` is a `media://asset/{encodedKey}` URL in offline mode or a remote URL in passthrough mode.
666
+
667
+ **FileStemMatch** -- `{ asset: ResolvedMediaAsset }`
668
+
669
+ **MediaKind** -- `"video" | "image" | "audio" | "document" | "html" | "text" | "binary"`
670
+
671
+ **MediaCacheStatus** -- `{ phase, storageRoot, activeGenerationId, progress, lastRun, error, updatedAt }`. `phase` is `"idle" | "syncing" | "ready" | "error"`.
672
+
673
+ **PaginationInput** -- `{ limit?, cursor? }`
674
+
675
+ **PaginationResult\<T\>** -- `{ items: T[], nextCursor: string | null }`
676
+
677
+ See the published `.d.ts` files for full type definitions.
678
+
679
+ ### `@rockhall/electron-offline-content/preload`
680
+
681
+ #### `exposeMediaCacheBridge(options?)`
682
+
683
+ Calls `contextBridge.exposeInMainWorld` to put the `MediaCacheBridge` on `window.mediaCache` (or a custom key via `options.key`). Returns the bridge instance.
684
+
685
+ #### `createMediaCacheBridge()`
686
+
687
+ Builds a `MediaCacheBridge` without calling `contextBridge`. Use this if you manage `contextBridge` yourself.
688
+
689
+ ### `@rockhall/electron-offline-content/react`
690
+
691
+ All hooks require a `MediaCacheProvider` ancestor (or `window.mediaCache` as fallback).
692
+
693
+ #### `MediaCacheProvider`
694
+
695
+ Context provider. If your preload uses the default `window.mediaCache` key, you can omit the `bridge` prop.
696
+
697
+ ```tsx
698
+ <MediaCacheProvider bridge={customBridge}>
699
+ <App />
700
+ </MediaCacheProvider>
701
+ ```
702
+
703
+ #### `useMediaBridge()`
704
+
705
+ Returns the active bridge methods together with shared `status`, top-level composite `phase` (`MediaCachePhase`: cache phase or `"loading"` before the first snapshot), and aggregated `errors`.
706
+
707
+ ```tsx
708
+ const { syncNow, status, phase, errors } = useMediaBridge();
709
+ ```
710
+
711
+ Use this when you need imperative bridge access without wiring separate status and error hooks.
712
+
713
+ #### `useMediaCacheStatus()`
714
+
715
+ Returns `UseMediaCacheStatusResult`: the same fields as `AsyncState<MediaCacheStatus>` plus top-level `phase` (`MediaCachePhase`). Subscribes to live status updates and exposes `refresh()`.
716
+
717
+ #### `useMediaAsset(key, options?)`
718
+
719
+ Fetches a single asset by `AssetKeyInput` (same string or segment array you used in `store.add`). Returns `AsyncState<ResolvedMediaAsset | null>`.
720
+
721
+ Options: `{ refetchOnSyncComplete? }`
722
+
723
+ ```tsx
724
+ const asset = useMediaAsset(["videos", "welcome", "main"]);
725
+
726
+ if (asset.data) {
727
+ return <video src={asset.data.url} title={asset.data.displayKey} controls />;
728
+ }
729
+ ```
730
+
731
+ #### `useMediaByIndex(indexName, value, options?)`
732
+
733
+ Lists assets matching a secondary index value. Returns `AsyncState<PaginationResult<ResolvedMediaAsset>>`.
734
+
735
+ Options: `{ limit?, cursor?, refetchOnSyncComplete? }`
736
+
737
+ ```tsx
738
+ const videos = useMediaByIndex("collection", "exhibits", { limit: 20 });
739
+
740
+ if (videos.data) {
741
+ return videos.data.items.map((asset) => (
742
+ <video key={asset.key} src={asset.url} title={asset.displayKey} controls />
743
+ ));
744
+ }
745
+ ```
746
+
747
+ #### `useFileStemMatch(stem, options?)`
748
+
749
+ Returns `AsyncState<PaginationResult<FileStemMatch>>`. Searches by normalized filename stem.
750
+
751
+ Options: `{ limit?, cursor?, refetchOnSyncComplete? }`
752
+
753
+ #### `useMediaCacheReady()`
754
+
755
+ Returns `AsyncState<MediaCacheReadyState>`. Lightweight readiness gate: `{ ready, syncing, phase, activeGenerationId, syncError }`.
756
+
757
+ ```tsx
758
+ const ready = useMediaCacheReady();
759
+ if (!ready.data?.ready) return <p>Preparing offline content...</p>;
760
+ ```
761
+
762
+ #### `useMediaCacheErrors()`
763
+
764
+ Aggregates sync and provider-wide query errors into `MediaCacheErrors`: `{ syncError, statusError, queryErrors, hasError, primaryError }`.
765
+
766
+ #### `AsyncState<T>`
767
+
768
+ Hooks such as `useMediaAsset`, `useMediaByIndex`, `useFileStemMatch`, `useMediaCacheReady`, and `useMediaCacheStatus` return this shape:
769
+
770
+ ```ts
771
+ {
772
+ data: T | null; // latest resolved value
773
+ loading: boolean; // true during initial load or refresh
774
+ error: Error | null; // last request error
775
+ refresh: () => Promise<void>;
776
+ }
777
+ ```
778
+
779
+ ## Notes
780
+
781
+ - v1 requires consumers to own cache busting through asset versions.
782
+ - v1 treats every asset as required for snapshot commit.
783
+ - Storage root exclusivity: `MediaCache` acquires exclusive ownership of its `storageRoot`. If `start()` fails after ownership is established, reuse the same instance or restart the process rather than constructing a replacement cache for the same root.
784
+
785
+ ## Example apps
786
+
787
+ Two example apps demonstrate end-to-end wiring. Each is a standalone Electron Forge + React + Vite project.
788
+
789
+ - [examples/local/](examples/local/) -- uses a loopback HTTP server with small local fixtures. Also used by `pack:verify` on CI pushes to `main`.
790
+ - [examples/nasa/](examples/nasa/) -- uses public NASA SVS URLs for heavier manual demos (not run in CI).
791
+
792
+ Both examples exercise sync status, index-based listing, single-asset lookup, file-stem search, and rendering images and video from `media://` URLs (offline mode) or direct remote URLs (dev passthrough).
793
+
794
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the examples locally.