@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
@@ -0,0 +1,854 @@
1
+ import { MEDIA_CACHE_IPC } from "../shared/ipc.js";
2
+ import { DataValidationError, StorageLimitError, StoreExpiredError, StoreValidationError, toSerializedError } from "../shared/errors.js";
3
+ import { validateFlatManifest } from "../shared/normalize.js";
4
+ import { normalizeStem } from "../shared/stem.js";
5
+ import { formatMediaCacheConsoleLine } from "../internal/log-format.js";
6
+ import { mediaCacheStoragePathSchema, optionalPaginationInputSchema, parseWithSchema, stringInputSchema } from "../internal/validation.js";
7
+ import { MediaCacheDatabase } from "./database.js";
8
+ import { consoleWarnResolveAssetBaseUrlFallback } from "../internal/url-warn.js";
9
+ import { hashKey } from "../internal/asset-key.js";
10
+ import { acquireStorageRootLock, disableStorageRootLockForTests, enableStorageRootLockForTests, resetStorageRootLocksForTests } from "./storage-root-lock.js";
11
+ import { AssetDownloader, DEFAULT_RESERVE_FREE_BYTES, effectiveReserveFreeBytes } from "./asset-download.js";
12
+ import { createRequire } from "node:module";
13
+ import { EventEmitter } from "node:events";
14
+ import { createReadStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
15
+ import { statfs } from "node:fs/promises";
16
+ import { Readable } from "node:stream";
17
+ import { setTimeout } from "node:timers/promises";
18
+ import { dirname, join } from "node:path";
19
+ //#region src/main/media-cache.ts
20
+ const requireElectron = createRequire(import.meta.url);
21
+ const DEFAULT_STALE_DELETE_MS = 10080 * 60 * 1e3;
22
+ const DEFAULT_SYNC_HISTORY_LIMIT = 50;
23
+ const LOG_LEVEL_WEIGHT = {
24
+ debug: 10,
25
+ info: 20,
26
+ warn: 30,
27
+ error: 40
28
+ };
29
+ let mediaCacheProtocolSchemesPrivileged = false;
30
+ function isModuleNotFoundError(error) {
31
+ return error instanceof Error && "code" in error && error.code === "MODULE_NOT_FOUND";
32
+ }
33
+ /** True when not a production build; Electron `forge start` often leaves `NODE_ENV` unset. */
34
+ function isNonProductionNodeEnv() {
35
+ return process.env.NODE_ENV !== "production";
36
+ }
37
+ /**
38
+ * Registers the privileged `media:` scheme once per process. Call happens when constructing
39
+ * {@link MediaCache} in offline mode so consumers do not need a separate bootstrap step.
40
+ *
41
+ * No-ops when `electron` cannot be loaded (e.g. unit tests outside Electron). When Electron is
42
+ * available, failures from `protocol.registerSchemesAsPrivileged` propagate (including calling
43
+ * too late after `app.ready`).
44
+ */
45
+ function ensureMediaCacheProtocolSchemesPrivileged() {
46
+ if (mediaCacheProtocolSchemesPrivileged) return;
47
+ let protocol;
48
+ try {
49
+ ({protocol} = requireElectron("electron"));
50
+ } catch (error) {
51
+ if (isModuleNotFoundError(error)) return;
52
+ throw error;
53
+ }
54
+ if (protocol == null || typeof protocol.registerSchemesAsPrivileged !== "function") return;
55
+ protocol.registerSchemesAsPrivileged([{
56
+ scheme: "media",
57
+ privileges: {
58
+ standard: true,
59
+ secure: true,
60
+ supportFetchAPI: true,
61
+ stream: true
62
+ }
63
+ }]);
64
+ mediaCacheProtocolSchemesPrivileged = true;
65
+ }
66
+ /**
67
+ * Clears the internal `media:` scheme registration flag so subsequent {@link MediaCache}
68
+ * construction runs registration again. **Unit tests only**; do not use in application code.
69
+ * @internal
70
+ */
71
+ function resetMediaCacheProtocolRegistrationStateForTests() {
72
+ mediaCacheProtocolSchemesPrivileged = false;
73
+ }
74
+ /**
75
+ * Clears any held storage-root locks so tests can run multiple scenarios in one process.
76
+ * **Unit tests only**; do not use in application code.
77
+ * @internal
78
+ */
79
+ const resetMediaCacheStorageRootLocksForTests = resetStorageRootLocksForTests;
80
+ /**
81
+ * Disables storage-root exclusivity checks so tests can exercise multiple cache instances freely.
82
+ * **Unit tests only**; do not use in application code.
83
+ * @internal
84
+ */
85
+ const disableMediaCacheStorageRootLockForTests = disableStorageRootLockForTests;
86
+ /**
87
+ * Re-enables storage-root exclusivity checks after test-only overrides.
88
+ * **Unit tests only**; do not use in application code.
89
+ * @internal
90
+ */
91
+ const enableMediaCacheStorageRootLockForTests = enableStorageRootLockForTests;
92
+ /** Constructs a {@link MediaCacheMain} instance with the given options (does not start sync until `start` or `syncNow`). */
93
+ function createMediaCache(options) {
94
+ return new MediaCache(options);
95
+ }
96
+ var MediaCache = class {
97
+ options;
98
+ events = new EventEmitter();
99
+ deps;
100
+ /** When no custom handler is configured, log to the main-process console in development. */
101
+ defaultDevelopmentConsole;
102
+ /** Custom structured log sink when configured via `options.logging.onLog`. */
103
+ logHandler;
104
+ /** Built-in console line shape when {@link defaultDevelopmentConsole} is active. */
105
+ logFormat;
106
+ /** Minimum severity emitted for the currently active log sink. */
107
+ effectiveLogLevel;
108
+ devPassthrough;
109
+ assetBaseUrlOrigin;
110
+ storageRootLock = null;
111
+ db = null;
112
+ storageRoot = null;
113
+ status;
114
+ syncPromise = null;
115
+ ipcAttached = false;
116
+ protocolRegistered = false;
117
+ constructor(options, deps) {
118
+ this.options = options;
119
+ this.deps = {
120
+ fetchImpl: deps?.fetchImpl ?? globalThis.fetch.bind(globalThis),
121
+ now: deps?.now ?? Date.now,
122
+ sleep: deps?.sleep ?? setTimeout,
123
+ resolveAppPath: deps?.resolveAppPath ?? resolveElectronAppPath,
124
+ statfs: deps?.statfs ?? statfs
125
+ };
126
+ const logging = normalizeLoggingOptions(options.logging);
127
+ this.logHandler = logging.onLog;
128
+ this.defaultDevelopmentConsole = this.logHandler == null && isNonProductionNodeEnv() && process.env.VITEST !== "true";
129
+ this.logFormat = logging.format;
130
+ this.effectiveLogLevel = logging.level ?? (this.logHandler == null && this.defaultDevelopmentConsole ? "debug" : "info");
131
+ this.devPassthrough = options.devPassthrough ?? process.env.NODE_ENV === "development";
132
+ if (this.devPassthrough) this.assetBaseUrlOrigin = normalizeAssetBaseUrl(options.assetBaseUrl);
133
+ else {
134
+ if (options.assetBaseUrl) throw new Error("assetBaseUrl has no effect when devPassthrough is false. Set devPassthrough: true or remove assetBaseUrl.");
135
+ this.assetBaseUrlOrigin = null;
136
+ }
137
+ if (this.devPassthrough) this.emitLog("info", "dev_passthrough_active", {
138
+ source: options.devPassthrough === true ? "option" : "node_env",
139
+ node_env: process.env.NODE_ENV ?? null
140
+ });
141
+ if (this.devPassthrough && this.options.onSyncFailure && this.options.onSyncFailure !== "throw") this.emitLog("warn", "dev_passthrough_ignores_sync_failure_mode", { configured_mode: this.options.onSyncFailure });
142
+ this.status = {
143
+ phase: "idle",
144
+ storageRoot: null,
145
+ activeGenerationId: null,
146
+ progress: null,
147
+ lastRun: null,
148
+ error: null,
149
+ updatedAt: this.deps.now()
150
+ };
151
+ if (!this.devPassthrough) ensureMediaCacheProtocolSchemesPrivileged();
152
+ }
153
+ async start() {
154
+ await this.registerProtocol();
155
+ await this.attachIpc();
156
+ await this.syncNow();
157
+ }
158
+ async syncNow() {
159
+ await this.ensureInitialized();
160
+ if (this.syncPromise) {
161
+ this.emitLog("debug", "sync_reused", {
162
+ phase: this.status.phase,
163
+ active_generation_id: this.status.activeGenerationId
164
+ });
165
+ return this.syncPromise;
166
+ }
167
+ this.syncPromise = this.runSync().finally(() => {
168
+ this.syncPromise = null;
169
+ });
170
+ return this.syncPromise;
171
+ }
172
+ async getStatus() {
173
+ await this.ensureInitialized();
174
+ return this.status;
175
+ }
176
+ async getAsset(key) {
177
+ const hashedKey = hashKey(key);
178
+ await this.ensureInitialized();
179
+ return this.db.getAsset(hashedKey);
180
+ }
181
+ async listByIndex(indexName, value, pagination) {
182
+ const validatedIndexName = parseWithSchema(stringInputSchema, indexName, "index name");
183
+ const validatedValue = parseWithSchema(stringInputSchema, value, "index value");
184
+ const validatedPagination = parseWithSchema(optionalPaginationInputSchema, pagination, "index pagination input");
185
+ await this.ensureInitialized();
186
+ return this.db.listByIndex(validatedIndexName, validatedValue, validatedPagination);
187
+ }
188
+ async findByFileStem(stem, pagination) {
189
+ const validatedStem = parseWithSchema(stringInputSchema, stem, "file stem");
190
+ const validatedPagination = parseWithSchema(optionalPaginationInputSchema, pagination, "file stem pagination input");
191
+ await this.ensureInitialized();
192
+ return this.db.findByFileStem(normalizeStem(validatedStem), validatedPagination);
193
+ }
194
+ async registerProtocol(options) {
195
+ await this.ensureInitialized();
196
+ if (this.devPassthrough) {
197
+ this.protocolRegistered = true;
198
+ this.emitLog("debug", "protocol_registration_skipped", { reason: "dev_passthrough" });
199
+ return;
200
+ }
201
+ if (this.protocolRegistered) {
202
+ this.emitLog("debug", "protocol_registration_skipped", { reason: "already_registered" });
203
+ return;
204
+ }
205
+ const electron = options?.session ? null : await import("electron");
206
+ const session = options?.session ?? electron?.session?.defaultSession;
207
+ if (!session || typeof session.protocol?.handle !== "function") {
208
+ this.emitLog("debug", "protocol_registration_skipped", { reason: "session_unavailable" });
209
+ return;
210
+ }
211
+ const fetchFile = options?.fetchFile ?? (async (request, filePath) => createFileResponse(filePath, request));
212
+ session.protocol.handle("media", async (request) => {
213
+ const parsed = new URL(request.url);
214
+ const parts = parsed.pathname.split("/").filter(Boolean);
215
+ if (parsed.hostname !== "asset" || parts.length !== 1) return new Response("Not found", { status: 404 });
216
+ let assetKey;
217
+ try {
218
+ assetKey = decodeURIComponent(parts[0]);
219
+ } catch {
220
+ return new Response("Not found", { status: 404 });
221
+ }
222
+ const target = this.db.getProtocolAssetTarget(assetKey);
223
+ if (!target) {
224
+ this.emitLog("debug", "protocol_request_not_found", {
225
+ asset_key: assetKey,
226
+ method: request.method
227
+ });
228
+ return new Response("Not found", { status: 404 });
229
+ }
230
+ if (!target.absolutePath || !existsSync(target.absolutePath)) {
231
+ this.emitLog("debug", "protocol_request_file_missing", {
232
+ asset_key: assetKey,
233
+ method: request.method
234
+ });
235
+ return new Response("Not found", { status: 404 });
236
+ }
237
+ this.emitLog("debug", "protocol_request_local_resolved", {
238
+ asset_key: assetKey,
239
+ method: request.method,
240
+ range: request.headers.get("range")
241
+ });
242
+ return fetchFile(request, target.absolutePath);
243
+ });
244
+ this.protocolRegistered = true;
245
+ this.emitLog("info", "protocol_registered", {});
246
+ }
247
+ async attachIpc(options) {
248
+ await this.ensureInitialized();
249
+ if (this.ipcAttached) {
250
+ this.emitLog("debug", "ipc_attach_skipped", { reason: "already_attached" });
251
+ return;
252
+ }
253
+ const electron = options?.ipcMain ? null : await import("electron");
254
+ const ipcMain = options?.ipcMain ?? electron?.ipcMain;
255
+ if (!ipcMain || typeof ipcMain.handle !== "function") {
256
+ this.emitLog("debug", "ipc_attach_skipped", { reason: "ipc_main_unavailable" });
257
+ return;
258
+ }
259
+ ipcMain.handle(MEDIA_CACHE_IPC.getStatus, async () => this.getStatus());
260
+ ipcMain.handle(MEDIA_CACHE_IPC.syncNow, async () => this.syncNow());
261
+ ipcMain.handle(MEDIA_CACHE_IPC.getAsset, async (_event, key) => this.getAsset(key));
262
+ ipcMain.handle(MEDIA_CACHE_IPC.listByIndex, async (_event, indexName, value, pagination) => this.listByIndex(indexName, value, pagination));
263
+ ipcMain.handle(MEDIA_CACHE_IPC.findByFileStem, async (_event, stem, pagination) => this.findByFileStem(stem, pagination));
264
+ if (electron) this.events.on(MEDIA_CACHE_IPC.statusChanged, (status) => {
265
+ for (const window of electron.BrowserWindow.getAllWindows()) window.webContents.send(MEDIA_CACHE_IPC.statusChanged, status);
266
+ });
267
+ this.ipcAttached = true;
268
+ this.emitLog("info", "ipc_attached", {});
269
+ }
270
+ async ensureInitialized() {
271
+ if (this.db) return;
272
+ this.storageRoot = await resolveStorageRoot(this.options.storagePath, this.deps.resolveAppPath);
273
+ mkdirSync(this.storageRoot, { recursive: true });
274
+ this.storageRootLock ??= acquireStorageRootLock(this.storageRoot, this);
275
+ mkdirSync(join(this.storageRoot, "temp"), { recursive: true });
276
+ mkdirSync(join(this.storageRoot, "blobs"), { recursive: true });
277
+ if (!this.devPassthrough) this.emitLog("info", "cache_storage_location", { storage_root: this.storageRoot });
278
+ this.db = new MediaCacheDatabase(this.storageRoot, {
279
+ devPassthrough: this.devPassthrough,
280
+ assetBaseUrlOrigin: this.assetBaseUrlOrigin,
281
+ onWarn: (contextLabel, err) => {
282
+ if (this.logHandler != null || this.defaultDevelopmentConsole) this.emitLog("warn", "resolve_asset_base_url_fallback", {
283
+ context_label: contextLabel,
284
+ error: err != null ? String(err) : void 0
285
+ });
286
+ else consoleWarnResolveAssetBaseUrlFallback(contextLabel, err);
287
+ }
288
+ });
289
+ if (this.devPassthrough) this.prepareDevRuntimeState();
290
+ let storedStatus = null;
291
+ let activeGenerationId = null;
292
+ if (!this.devPassthrough) {
293
+ activeGenerationId = this.reconcileOrphanedStagedGenerations();
294
+ try {
295
+ storedStatus = this.db.loadStatus();
296
+ } catch (error) {
297
+ if (!(error instanceof DataValidationError)) throw error;
298
+ this.emitLog("warn", "status_snapshot_invalid", {
299
+ error_code: error.code,
300
+ error_message: error.message
301
+ });
302
+ }
303
+ if (storedStatus) this.status = storedStatus;
304
+ else if (activeGenerationId !== null) this.status = {
305
+ ...this.status,
306
+ phase: "ready",
307
+ activeGenerationId,
308
+ progress: null,
309
+ error: null
310
+ };
311
+ this.status.activeGenerationId = activeGenerationId;
312
+ }
313
+ this.status = {
314
+ ...this.status,
315
+ storageRoot: this.storageRoot
316
+ };
317
+ this.emitLog("info", "cache_initialized", {
318
+ storage_root: this.storageRoot,
319
+ active_generation_id: this.status.activeGenerationId,
320
+ dev_passthrough_enabled: this.devPassthrough
321
+ });
322
+ }
323
+ reconcileOrphanedStagedGenerations() {
324
+ const activeGenerationId = this.db.getActiveGenerationId();
325
+ const stagedGenerationIds = this.db.listStagedGenerationIds().filter((generationId) => generationId !== activeGenerationId);
326
+ if (stagedGenerationIds.length === 0) return activeGenerationId;
327
+ for (const stagedGenerationId of stagedGenerationIds) {
328
+ this.cleanupStagedGenerationFiles(stagedGenerationId, activeGenerationId);
329
+ this.db.deleteGeneration(stagedGenerationId);
330
+ }
331
+ this.emitLog("warn", "orphaned_staged_generations_removed", {
332
+ active_generation_id: activeGenerationId,
333
+ removed_generation_ids: stagedGenerationIds,
334
+ removed_generation_count: stagedGenerationIds.length
335
+ });
336
+ return activeGenerationId;
337
+ }
338
+ prepareDevRuntimeState() {
339
+ this.emitLog("warn", "dev_passthrough_clearing_state", {
340
+ storage_root: this.storageRoot,
341
+ reason: "devPassthrough=true clears all local state on startup"
342
+ });
343
+ this.db.clearAllState();
344
+ rmSync(join(this.storageRoot, "blobs"), {
345
+ recursive: true,
346
+ force: true
347
+ });
348
+ rmSync(join(this.storageRoot, "temp"), {
349
+ recursive: true,
350
+ force: true
351
+ });
352
+ mkdirSync(join(this.storageRoot, "blobs"), { recursive: true });
353
+ mkdirSync(join(this.storageRoot, "temp"), { recursive: true });
354
+ this.status = {
355
+ ...this.status,
356
+ phase: "idle",
357
+ activeGenerationId: null,
358
+ progress: null,
359
+ lastRun: null,
360
+ error: null,
361
+ updatedAt: this.deps.now()
362
+ };
363
+ }
364
+ async runSync() {
365
+ const now = this.deps.now();
366
+ const runId = this.db.createSyncRun(now);
367
+ const stats = {
368
+ totalAssets: 0,
369
+ downloadedAssets: 0,
370
+ skippedAssets: 0,
371
+ bytesDownloaded: 0
372
+ };
373
+ this.updateStatus({
374
+ phase: "syncing",
375
+ error: null,
376
+ progress: {
377
+ runId,
378
+ phase: "resolving-store",
379
+ totalAssets: 0,
380
+ completedAssets: 0,
381
+ downloadedAssets: 0,
382
+ skippedAssets: 0,
383
+ bytesDownloaded: 0
384
+ }
385
+ });
386
+ this.emitLog("info", "sync_started", {
387
+ run_id: runId,
388
+ active_generation_id: this.status.activeGenerationId
389
+ });
390
+ let stagedGenerationId = null;
391
+ try {
392
+ const manifest = validateFlatManifest((await this.options.resolveStore())._serialize());
393
+ this.assertStoreNotExpired(manifest, runId);
394
+ stagedGenerationId = this.db.createStagedGeneration(manifest, now);
395
+ this.emitLog("info", "store_resolved", {
396
+ run_id: runId,
397
+ staged_generation_id: stagedGenerationId,
398
+ index_count: manifest.indexDefinitions.length,
399
+ asset_count: manifest.assets.length
400
+ });
401
+ const currentGenerationId = this.db.getActiveGenerationId();
402
+ const currentAssets = currentGenerationId ? this.db.getGenerationAssets(currentGenerationId) : [];
403
+ const stagedAssets = this.db.getGenerationAssets(stagedGenerationId);
404
+ stats.totalAssets = stagedAssets.length;
405
+ this.updateProgress((progress) => ({
406
+ ...progress,
407
+ phase: "diffing",
408
+ totalAssets: stagedAssets.length
409
+ }));
410
+ const manifestAssetMap = new Map(manifest.assets.map((asset) => [asset.key, asset]));
411
+ const currentMap = new Map(currentAssets.map((row) => [row.assetKey, row]));
412
+ const downloads = [];
413
+ for (const row of stagedAssets) {
414
+ const manifestAsset = manifestAssetMap.get(row.assetKey);
415
+ if (!manifestAsset) throw new StoreValidationError(`Asset "${row.assetKey}" not found in serialized store.`);
416
+ const activeRow = currentMap.get(row.assetKey);
417
+ const activeRelativePath = activeRow?.relativePath ?? null;
418
+ const normalizedActiveRelativePath = activeRelativePath === null ? null : normalizeStoredRelativePath(activeRelativePath);
419
+ const nextVersion = manifestAsset.version;
420
+ const canReuseActiveBlob = normalizedActiveRelativePath !== null && existsSync(join(this.storageRoot, normalizedActiveRelativePath));
421
+ if (this.devPassthrough) {
422
+ stats.skippedAssets += 1;
423
+ continue;
424
+ }
425
+ if (canReuseActiveBlob) {
426
+ if (getResolvedVersionFromPath(normalizedActiveRelativePath) === nextVersion) {
427
+ this.db.setAssetDownloadState(stagedGenerationId, row.assetKey, normalizedActiveRelativePath, activeRow?.mimeType ?? null);
428
+ stats.skippedAssets += 1;
429
+ continue;
430
+ }
431
+ }
432
+ downloads.push({
433
+ assetKey: row.assetKey,
434
+ version: manifestAsset.version,
435
+ fileName: manifestAsset.fileName,
436
+ byteLength: manifestAsset.byteLength
437
+ });
438
+ }
439
+ this.emitLog("info", "sync_diffed", {
440
+ run_id: runId,
441
+ total_assets: stagedAssets.length,
442
+ download_count: downloads.length,
443
+ skipped_assets: stats.skippedAssets,
444
+ dev_passthrough_enabled: this.devPassthrough
445
+ });
446
+ await this.pruneExpiredDeletions();
447
+ this.cleanupObsoletePartialDownloads(downloads);
448
+ if (!this.devPassthrough) {
449
+ await this.enforceStorageLimits(downloads);
450
+ this.updateProgress((progress) => ({
451
+ ...progress,
452
+ phase: "downloading",
453
+ totalAssets: stagedAssets.length,
454
+ completedAssets: stats.skippedAssets,
455
+ skippedAssets: stats.skippedAssets
456
+ }));
457
+ for (const queuedDownload of downloads) {
458
+ this.assertStoreNotExpired(manifest, runId, queuedDownload);
459
+ const download = {
460
+ ...queuedDownload,
461
+ request: { url: manifestAssetMap.get(queuedDownload.assetKey).url }
462
+ };
463
+ this.emitLog("debug", "asset_download_started", {
464
+ run_id: runId,
465
+ asset_key: download.assetKey,
466
+ version: download.version,
467
+ url: download.request.url
468
+ });
469
+ const { relativePath, fallbackMimeType } = await this.createAssetDownloader().downloadAsset(download, (chunkBytes) => {
470
+ stats.bytesDownloaded += chunkBytes;
471
+ this.updateProgress((progress) => ({
472
+ ...progress,
473
+ bytesDownloaded: stats.bytesDownloaded
474
+ }));
475
+ });
476
+ this.db.setAssetDownloadState(stagedGenerationId, download.assetKey, relativePath, fallbackMimeType);
477
+ stats.downloadedAssets += 1;
478
+ this.emitLog("debug", "asset_download_completed", {
479
+ run_id: runId,
480
+ asset_key: download.assetKey,
481
+ relative_path: relativePath
482
+ });
483
+ this.updateProgress((progress) => ({
484
+ ...progress,
485
+ completedAssets: stats.downloadedAssets + stats.skippedAssets,
486
+ downloadedAssets: stats.downloadedAssets,
487
+ skippedAssets: stats.skippedAssets,
488
+ bytesDownloaded: stats.bytesDownloaded
489
+ }));
490
+ }
491
+ } else this.updateProgress((progress) => ({
492
+ ...progress,
493
+ completedAssets: stagedAssets.length,
494
+ skippedAssets: stagedAssets.length
495
+ }));
496
+ this.updateProgress((progress) => ({
497
+ ...progress,
498
+ phase: "committing"
499
+ }));
500
+ const previousGenerationId = this.db.activateGeneration(stagedGenerationId, this.deps.now());
501
+ this.db.clearPendingDeletionsForGeneration(stagedGenerationId);
502
+ if (previousGenerationId) this.markRemovedAssetsForDeletion(previousGenerationId, stagedGenerationId);
503
+ this.emitLog("info", "generation_committed", {
504
+ run_id: runId,
505
+ previous_generation_id: previousGenerationId,
506
+ active_generation_id: stagedGenerationId
507
+ });
508
+ this.updateProgress((progress) => ({
509
+ ...progress,
510
+ phase: "pruning"
511
+ }));
512
+ await this.pruneExpiredDeletions();
513
+ const summary = this.db.completeSyncRun(runId, "success", this.deps.now(), stats);
514
+ this.db.pruneSyncHistory(this.options.syncHistoryLimit ?? DEFAULT_SYNC_HISTORY_LIMIT);
515
+ this.emitLog("info", "sync_completed", {
516
+ run_id: runId,
517
+ active_generation_id: stagedGenerationId,
518
+ total_assets: summary.stats.totalAssets,
519
+ downloaded_assets: summary.stats.downloadedAssets,
520
+ skipped_assets: summary.stats.skippedAssets,
521
+ bytes_downloaded: summary.stats.bytesDownloaded
522
+ });
523
+ this.updateStatus({
524
+ phase: "ready",
525
+ activeGenerationId: stagedGenerationId,
526
+ progress: null,
527
+ lastRun: summary,
528
+ error: null
529
+ });
530
+ } catch (error) {
531
+ if (stagedGenerationId !== null) {
532
+ this.cleanupStagedGenerationFiles(stagedGenerationId, this.db.getActiveGenerationId());
533
+ this.db.deleteGeneration(stagedGenerationId);
534
+ }
535
+ const serialized = toSerializedError(error);
536
+ const summary = this.db.completeSyncRun(runId, "error", this.deps.now(), stats, serialized.code, serialized.message);
537
+ this.emitLog("error", "sync_failed", {
538
+ run_id: runId,
539
+ active_generation_id: this.db.getActiveGenerationId(),
540
+ error_code: serialized.code,
541
+ error_message: serialized.message,
542
+ total_assets: summary.stats.totalAssets,
543
+ downloaded_assets: summary.stats.downloadedAssets,
544
+ skipped_assets: summary.stats.skippedAssets,
545
+ bytes_downloaded: summary.stats.bytesDownloaded
546
+ });
547
+ this.updateStatus({
548
+ phase: this.devPassthrough || this.options.onSyncFailure === "throw" ? "error" : this.db.getActiveGenerationId() ? "ready" : "error",
549
+ activeGenerationId: this.db.getActiveGenerationId(),
550
+ progress: null,
551
+ lastRun: summary,
552
+ error: serialized
553
+ });
554
+ if (this.devPassthrough || this.options.onSyncFailure === "throw") throw error;
555
+ }
556
+ }
557
+ assertStoreNotExpired(manifest, runId, download) {
558
+ if (!manifest.expiresAt) return;
559
+ const expiresAtMs = Date.parse(manifest.expiresAt);
560
+ const now = this.deps.now();
561
+ if (Number.isNaN(expiresAtMs) || now < expiresAtMs) return;
562
+ this.emitLog("warn", "store_expired", {
563
+ run_id: runId,
564
+ expires_at: manifest.expiresAt,
565
+ now_ms: now,
566
+ asset_key: download?.assetKey
567
+ });
568
+ const assetLabel = download ? ` before downloading ${download.assetKey}` : "";
569
+ throw new StoreExpiredError(`Store URLs expired at ${manifest.expiresAt}${assetLabel}. Refresh the store and retry sync.`);
570
+ }
571
+ async enforceStorageLimits(downloads) {
572
+ const estimatedBlobBytes = downloads.reduce((sum, download) => sum + (download.byteLength ?? 0), 0);
573
+ const estimatedRemainingDownloadBytes = downloads.reduce((sum, download) => sum + this.createAssetDownloader().remainingDownloadBytes(download), 0);
574
+ if (this.options.maxCacheBytes !== void 0) {
575
+ const currentBytes = this.currentBytesOnDisk(join(this.storageRoot, "blobs"));
576
+ if (currentBytes + estimatedBlobBytes > this.options.maxCacheBytes) {
577
+ this.emitLog("warn", "storage_limit_exceeded", {
578
+ current_bytes: currentBytes,
579
+ estimated_download_bytes: estimatedBlobBytes,
580
+ max_cache_bytes: this.options.maxCacheBytes
581
+ });
582
+ throw new StorageLimitError(`Estimated cache size ${currentBytes + estimatedBlobBytes} exceeds maxCacheBytes ${this.options.maxCacheBytes}.`);
583
+ }
584
+ }
585
+ const stats = await this.deps.statfs(this.storageRoot);
586
+ const availableBytes = Number(stats.bavail) * Number(stats.bsize);
587
+ const reserve = effectiveReserveFreeBytes(this.options.reserveFreeBytes);
588
+ if (availableBytes - estimatedRemainingDownloadBytes < reserve) {
589
+ this.emitLog("warn", "storage_reserve_violation", {
590
+ available_bytes: availableBytes,
591
+ estimated_download_bytes: estimatedRemainingDownloadBytes,
592
+ reserve_free_bytes: reserve
593
+ });
594
+ throw new StorageLimitError(`Estimated download size ${estimatedRemainingDownloadBytes} leaves less than reserveFreeBytes ${reserve}.`);
595
+ }
596
+ }
597
+ async ensureFileSpaceCommit() {
598
+ await this.createAssetDownloader().ensureFileSpaceCommit();
599
+ }
600
+ cleanupObsoletePartialDownloads(downloads) {
601
+ this.createAssetDownloader().cleanupObsoletePartialDownloads(downloads);
602
+ }
603
+ createAssetDownloader() {
604
+ return new AssetDownloader(this.storageRoot, this.deps, {
605
+ reserveFreeBytes: this.options.reserveFreeBytes,
606
+ emitLog: (level, event, fields = {}) => this.emitLog(level, event, fields)
607
+ });
608
+ }
609
+ cleanupStagedGenerationFiles(stagedGenerationId, activeGenerationId) {
610
+ const activePaths = new Set(activeGenerationId ? this.db.getGenerationAssets(activeGenerationId).flatMap((row) => row.relativePath ? [normalizeStoredRelativePath(row.relativePath)] : []) : []);
611
+ for (const row of this.db.getGenerationAssets(stagedGenerationId)) {
612
+ if (!row.relativePath) continue;
613
+ const normalizedRelativePath = normalizeStoredRelativePath(row.relativePath);
614
+ if (activePaths.has(normalizedRelativePath)) continue;
615
+ const absolutePath = join(this.storageRoot, normalizedRelativePath);
616
+ rmSync(absolutePath, { force: true });
617
+ pruneEmptyParents(absolutePath, this.storageRoot);
618
+ }
619
+ }
620
+ markRemovedAssetsForDeletion(previousGenerationId, stagedGenerationId) {
621
+ const previousAssets = this.db.getGenerationAssets(previousGenerationId);
622
+ const nextAssets = new Map(this.db.getGenerationAssets(stagedGenerationId).map((row) => [row.assetKey, row.relativePath]));
623
+ const deleteAfterMs = this.deps.now() + (this.options.staleDeleteAfterMs ?? DEFAULT_STALE_DELETE_MS);
624
+ let markedCount = 0;
625
+ for (const row of previousAssets) {
626
+ const nextRelativePath = nextAssets.get(row.assetKey);
627
+ if (row.relativePath && nextRelativePath !== row.relativePath) {
628
+ this.db.markPendingDeletion(row.assetKey, row.relativePath, previousGenerationId, createPendingDeletionKey(row.assetKey, row.relativePath), deleteAfterMs);
629
+ markedCount += 1;
630
+ }
631
+ }
632
+ this.emitLog("debug", "assets_marked_for_deletion", {
633
+ previous_generation_id: previousGenerationId,
634
+ active_generation_id: stagedGenerationId,
635
+ marked_count: markedCount,
636
+ delete_after_ms: deleteAfterMs
637
+ });
638
+ }
639
+ async pruneExpiredDeletions() {
640
+ const expired = this.db.getExpiredPendingDeletions(this.deps.now());
641
+ if (expired.length === 0) {
642
+ this.emitLog("debug", "deletion_prune_skipped", { expired_count: 0 });
643
+ return;
644
+ }
645
+ for (const deletion of expired) {
646
+ const absolutePath = join(this.storageRoot, normalizeStoredRelativePath(deletion.relativePath));
647
+ rmSync(absolutePath, { force: true });
648
+ pruneEmptyParents(absolutePath, this.storageRoot);
649
+ }
650
+ this.db.deletePendingDeletions(expired.map((item) => item.deletionKey));
651
+ this.emitLog("debug", "assets_pruned", { pruned_count: expired.length });
652
+ }
653
+ currentBytesOnDisk(directory) {
654
+ if (!existsSync(directory)) return 0;
655
+ const stats = statSync(directory);
656
+ if (stats.isFile()) return stats.size;
657
+ return readdirSync(directory).reduce((sum, entry) => sum + this.currentBytesOnDisk(join(directory, entry)), 0);
658
+ }
659
+ updateProgress(transform) {
660
+ if (!this.status.progress) return;
661
+ this.updateStatus({ progress: transform(this.status.progress) });
662
+ }
663
+ updateStatus(partial) {
664
+ this.status = {
665
+ ...this.status,
666
+ ...partial,
667
+ updatedAt: this.deps.now()
668
+ };
669
+ this.db?.saveStatus(this.status, this.status.updatedAt);
670
+ this.events.emit(MEDIA_CACHE_IPC.statusChanged, this.status);
671
+ }
672
+ emitLog(level, event, fields = {}) {
673
+ if (this.logHandler == null && !this.defaultDevelopmentConsole) return;
674
+ const threshold = LOG_LEVEL_WEIGHT[this.effectiveLogLevel];
675
+ if (LOG_LEVEL_WEIGHT[level] < threshold) return;
676
+ const entry = {
677
+ timestamp: new Date(this.deps.now()).toISOString(),
678
+ level,
679
+ event,
680
+ service: "rockhall-electron-offline-content",
681
+ component: "media-cache",
682
+ ...fields
683
+ };
684
+ if (this.logHandler != null) {
685
+ try {
686
+ this.logHandler(entry);
687
+ } catch {}
688
+ return;
689
+ }
690
+ writeDefaultDevelopmentConsoleLog(level, entry, this.logFormat);
691
+ }
692
+ };
693
+ function normalizeLoggingOptions(logging) {
694
+ if (logging?.onLog != null && logging.format !== void 0) throw new Error("MediaCacheOptions.logging.format cannot be set when logging.onLog is provided.");
695
+ const format = logging?.format;
696
+ if (format !== void 0 && format !== "english" && format !== "json") throw new Error(`Invalid MediaCacheOptions.logging.format: expected "english" | "json", received ${JSON.stringify(format)}`);
697
+ return {
698
+ onLog: logging?.onLog ?? null,
699
+ level: logging?.level,
700
+ format: format ?? "english"
701
+ };
702
+ }
703
+ function writeDefaultDevelopmentConsoleLog(level, entry, format) {
704
+ const line = formatMediaCacheConsoleLine(entry, format);
705
+ switch (level) {
706
+ case "debug":
707
+ console.debug(line);
708
+ break;
709
+ case "info":
710
+ console.log(line);
711
+ break;
712
+ case "warn":
713
+ console.warn(line);
714
+ break;
715
+ case "error":
716
+ console.error(line);
717
+ break;
718
+ default: console.log(line);
719
+ }
720
+ }
721
+ function createPendingDeletionKey(assetKey, relativePath) {
722
+ return JSON.stringify([assetKey, relativePath]);
723
+ }
724
+ function normalizeAssetBaseUrl(assetBaseUrl) {
725
+ if (!assetBaseUrl) return null;
726
+ let parsed;
727
+ try {
728
+ parsed = new URL(assetBaseUrl);
729
+ } catch {
730
+ throw new Error(`assetBaseUrl is not a valid URL: "${assetBaseUrl}"`);
731
+ }
732
+ if (parsed.username || parsed.password) throw new Error("assetBaseUrl must not include credentials.");
733
+ if (parsed.search || parsed.hash) throw new Error("assetBaseUrl must not include a query string or hash fragment.");
734
+ if (parsed.pathname !== "/" && parsed.pathname !== "") throw new Error("assetBaseUrl must be an origin without a path.");
735
+ return parsed.origin;
736
+ }
737
+ function pruneEmptyParents(pathToFile, storageRoot) {
738
+ let current = dirname(pathToFile);
739
+ while (current.startsWith(storageRoot) && current !== storageRoot) {
740
+ if (existsSync(current) && readdirSync(current).length === 0) {
741
+ rmSync(current, {
742
+ recursive: true,
743
+ force: true
744
+ });
745
+ current = dirname(current);
746
+ continue;
747
+ }
748
+ break;
749
+ }
750
+ }
751
+ function getResolvedVersionFromPath(relativePath) {
752
+ const parts = relativePath.split(/[\\/]/);
753
+ return parts.length >= 4 ? decodeURIComponent(parts.at(-2)) : null;
754
+ }
755
+ function normalizeStoredRelativePath(relativePath) {
756
+ return relativePath.split(/[\\/]/).join("/");
757
+ }
758
+ async function resolveElectronAppPath(name) {
759
+ return (await import("electron")).app.getPath(name);
760
+ }
761
+ async function resolveStorageRoot(input, resolveAppPath) {
762
+ const storagePath = parseWithSchema(mediaCacheStoragePathSchema, input, "storage path");
763
+ return join(await resolveAppPath(storagePath.appPath), ...storagePath.segments ?? []);
764
+ }
765
+ function createFileResponse(filePath, request) {
766
+ const size = statSync(filePath).size;
767
+ const rangeHeader = request.headers.get("range");
768
+ const mimeType = inferMimeType(filePath);
769
+ const baseHeaders = new Headers({
770
+ "accept-ranges": "bytes",
771
+ "content-type": mimeType
772
+ });
773
+ if (request.method === "HEAD") {
774
+ baseHeaders.set("content-length", String(size));
775
+ return new Response(null, {
776
+ status: 200,
777
+ headers: baseHeaders
778
+ });
779
+ }
780
+ if (!rangeHeader) {
781
+ baseHeaders.set("content-length", String(size));
782
+ return new Response(Readable.toWeb(createReadStream(filePath)), {
783
+ status: 200,
784
+ headers: baseHeaders
785
+ });
786
+ }
787
+ const parsedRange = parseByteRange(rangeHeader, size);
788
+ if (!parsedRange) {
789
+ baseHeaders.set("content-range", `bytes */${size}`);
790
+ return new Response(null, {
791
+ status: 416,
792
+ headers: baseHeaders
793
+ });
794
+ }
795
+ const { start, end } = parsedRange;
796
+ const chunkLength = end - start + 1;
797
+ baseHeaders.set("content-length", String(chunkLength));
798
+ baseHeaders.set("content-range", `bytes ${start}-${end}/${size}`);
799
+ return new Response(Readable.toWeb(createReadStream(filePath, {
800
+ start,
801
+ end
802
+ })), {
803
+ status: 206,
804
+ headers: baseHeaders
805
+ });
806
+ }
807
+ function parseByteRange(rangeHeader, size) {
808
+ if (!rangeHeader.startsWith("bytes=")) return null;
809
+ const value = rangeHeader.slice(6).trim();
810
+ if (value.length === 0 || value.includes(",")) return null;
811
+ const [startText, endText] = value.split("-", 2);
812
+ if (startText === void 0 || endText === void 0) return null;
813
+ if (startText === "") {
814
+ const suffixLength = Number.parseInt(endText, 10);
815
+ if (!Number.isFinite(suffixLength) || suffixLength <= 0) return null;
816
+ const start = Math.max(size - suffixLength, 0);
817
+ const end = size - 1;
818
+ return start <= end ? {
819
+ start,
820
+ end
821
+ } : null;
822
+ }
823
+ const start = Number.parseInt(startText, 10);
824
+ const end = endText === "" ? size - 1 : Number.parseInt(endText, 10);
825
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
826
+ if (start < 0 || end < start || start >= size) return null;
827
+ return {
828
+ start,
829
+ end: Math.min(end, size - 1)
830
+ };
831
+ }
832
+ function inferMimeType(filePath) {
833
+ const lower = filePath.toLowerCase();
834
+ if (lower.endsWith(".mp4")) return "video/mp4";
835
+ if (lower.endsWith(".webm")) return "video/webm";
836
+ if (lower.endsWith(".mov")) return "video/quicktime";
837
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
838
+ if (lower.endsWith(".png")) return "image/png";
839
+ if (lower.endsWith(".gif")) return "image/gif";
840
+ if (lower.endsWith(".webp")) return "image/webp";
841
+ if (lower.endsWith(".vtt")) return "text/vtt";
842
+ if (lower.endsWith(".srt")) return "application/x-subrip";
843
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
844
+ if (lower.endsWith(".wav")) return "audio/wav";
845
+ if (lower.endsWith(".html")) return "text/html; charset=utf-8";
846
+ if (lower.endsWith(".txt")) return "text/plain; charset=utf-8";
847
+ if (lower.endsWith(".json")) return "application/json; charset=utf-8";
848
+ if (lower.endsWith(".pdf")) return "application/pdf";
849
+ return "application/octet-stream";
850
+ }
851
+ //#endregion
852
+ export { DEFAULT_RESERVE_FREE_BYTES, MediaCache, createMediaCache, disableMediaCacheStorageRootLockForTests, effectiveReserveFreeBytes, enableMediaCacheStorageRootLockForTests, resetMediaCacheProtocolRegistrationStateForTests, resetMediaCacheStorageRootLocksForTests };
853
+
854
+ //# sourceMappingURL=media-cache.js.map