@file-viewer/ppt 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +99 -173
- package/NOTICE +19 -19
- package/README.md +333 -113
- package/frame-cache.mjs +731 -0
- package/index.d.ts +116 -37
- package/index.mjs +633 -98
- package/manifest.json +25 -11
- package/package.json +7 -2
- package/ppt-font-cjk.otf +0 -0
- package/ppt-native.wasm +0 -0
- package/worker.mjs +920 -0
package/frame-cache.mjs
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, best-effort cache for final watermarked PNG frames.
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally storage-only. It does not parse PPT data, inspect
|
|
5
|
+
* font assets, or transform renderer output. Every storage failure degrades to a
|
|
6
|
+
* cache miss so IndexedDB can never become a rendering dependency.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DB_VERSION = 1;
|
|
10
|
+
const FRAME_STORE = "frames";
|
|
11
|
+
const META_STORE = "meta";
|
|
12
|
+
const DOCUMENT_INDEX = "byDocument";
|
|
13
|
+
const ACCESS_INDEX = "byLastAccess";
|
|
14
|
+
const USAGE_KEY = "usage";
|
|
15
|
+
|
|
16
|
+
const MIB = 1024 * 1024;
|
|
17
|
+
const DEFAULT_DB_NAME = "flyfish-ppt-frame-cache-v1";
|
|
18
|
+
const DEFAULT_MAX_BYTES = 256 * MIB;
|
|
19
|
+
const DEFAULT_MAX_ENTRIES = 256;
|
|
20
|
+
const DEFAULT_MAX_ITEM_BYTES = 32 * MIB;
|
|
21
|
+
const MAX_DATABASE_NAME_LENGTH = 128;
|
|
22
|
+
const MAX_DOCUMENT_ID_LENGTH = 256;
|
|
23
|
+
const MAX_VARIANT_LENGTH = 256;
|
|
24
|
+
const MAX_PAGE_INDEX = 10_000_000;
|
|
25
|
+
const MAX_DIMENSION = 65_535;
|
|
26
|
+
const OPEN_TIMEOUT_MS = 1_000;
|
|
27
|
+
|
|
28
|
+
function clampInteger(value, fallback, minimum, maximum) {
|
|
29
|
+
let numeric;
|
|
30
|
+
try {
|
|
31
|
+
numeric = Number(value);
|
|
32
|
+
} catch {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
if (!Number.isFinite(numeric)) {
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
return Math.min(maximum, Math.max(minimum, Math.trunc(numeric)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeBoundedString(value, maximumLength, { trim = false } = {}) {
|
|
42
|
+
if (typeof value !== "string") {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const normalized = trim ? value.trim() : value;
|
|
46
|
+
if (normalized.length === 0 || normalized.length > maximumLength) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pure limit normalization used by both production code and unit tests.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeFrameCacheLimits(options = {}) {
|
|
56
|
+
const source = options && typeof options === "object" ? options : {};
|
|
57
|
+
const maxBytes = clampInteger(source.maxBytes, DEFAULT_MAX_BYTES, 0, 2 * 1024 * MIB);
|
|
58
|
+
const maxEntries = clampInteger(source.maxEntries, DEFAULT_MAX_ENTRIES, 0, 4_096);
|
|
59
|
+
const defaultItemBytes = Math.min(DEFAULT_MAX_ITEM_BYTES, maxBytes);
|
|
60
|
+
const configuredItemBytes = source.maxItemBytes ?? source.maxEntryBytes;
|
|
61
|
+
const maxItemBytes = maxBytes === 0
|
|
62
|
+
? 0
|
|
63
|
+
: clampInteger(configuredItemBytes, defaultItemBytes, 0, maxBytes);
|
|
64
|
+
return Object.freeze({ maxBytes, maxEntries, maxItemBytes });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pure cache-key normalization. Invalid or potentially collision-prone inputs
|
|
69
|
+
* return null instead of being truncated.
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeFrameCacheKey(input, pageIndex, variant = "") {
|
|
72
|
+
const source = input && typeof input === "object"
|
|
73
|
+
? input
|
|
74
|
+
: { documentId: input, pageIndex, variant };
|
|
75
|
+
|
|
76
|
+
const documentId = normalizeBoundedString(source.documentId, MAX_DOCUMENT_ID_LENGTH, { trim: true });
|
|
77
|
+
let normalizedPageIndex;
|
|
78
|
+
let normalizedVariant;
|
|
79
|
+
try {
|
|
80
|
+
normalizedPageIndex = Number(source.pageIndex);
|
|
81
|
+
normalizedVariant = source.variant == null ? "" : String(source.variant);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
documentId == null
|
|
88
|
+
|| !Number.isSafeInteger(normalizedPageIndex)
|
|
89
|
+
|| normalizedPageIndex < 0
|
|
90
|
+
|| normalizedPageIndex > MAX_PAGE_INDEX
|
|
91
|
+
|| normalizedVariant.length > MAX_VARIANT_LENGTH
|
|
92
|
+
) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Length-prefixing prevents separators inside opaque identifiers from
|
|
97
|
+
// creating collisions.
|
|
98
|
+
const key = `v1:${documentId.length}:${documentId}:${normalizedPageIndex}:${normalizedVariant.length}:${normalizedVariant}`;
|
|
99
|
+
return Object.freeze({
|
|
100
|
+
key,
|
|
101
|
+
documentId,
|
|
102
|
+
pageIndex: normalizedPageIndex,
|
|
103
|
+
variant: normalizedVariant,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createFrameCacheKey(documentId, pageIndex, variant = "") {
|
|
108
|
+
return normalizeFrameCacheKey(documentId, pageIndex, variant)?.key ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeDatabaseName(value) {
|
|
112
|
+
return normalizeBoundedString(value, MAX_DATABASE_NAME_LENGTH, { trim: true }) ?? DEFAULT_DB_NAME;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isPngBlob(blob) {
|
|
116
|
+
return Boolean(
|
|
117
|
+
blob
|
|
118
|
+
&& typeof blob === "object"
|
|
119
|
+
&& Number.isSafeInteger(blob.size)
|
|
120
|
+
&& blob.size > 0
|
|
121
|
+
&& typeof blob.arrayBuffer === "function"
|
|
122
|
+
&& typeof blob.type === "string"
|
|
123
|
+
&& blob.type.toLowerCase() === "image/png",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeDimension(value) {
|
|
128
|
+
let numeric;
|
|
129
|
+
try {
|
|
130
|
+
numeric = Number(value);
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return Number.isSafeInteger(numeric) && numeric > 0 && numeric <= MAX_DIMENSION
|
|
135
|
+
? numeric
|
|
136
|
+
: null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizePutArguments(keyInput, frameInput, limits) {
|
|
140
|
+
const frame = frameInput && typeof frameInput === "object" ? frameInput : keyInput;
|
|
141
|
+
const key = normalizeFrameCacheKey(keyInput);
|
|
142
|
+
const width = normalizeDimension(frame?.width);
|
|
143
|
+
const height = normalizeDimension(frame?.height);
|
|
144
|
+
const blob = frame?.blob;
|
|
145
|
+
|
|
146
|
+
if (key == null || width == null || height == null || !isPngBlob(blob)) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (blob.size > limits.maxItemBytes || blob.size > limits.maxBytes) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return { ...key, blob, width, height, byteSize: blob.size };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function requestResult(request) {
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
request.onsuccess = () => resolve(request.result);
|
|
158
|
+
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function transactionError(transaction, fallbackMessage) {
|
|
163
|
+
return transaction.error ?? new Error(fallbackMessage);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function openDatabase(factory, dbName) {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
let settled = false;
|
|
169
|
+
let request;
|
|
170
|
+
let timer = null;
|
|
171
|
+
|
|
172
|
+
const settle = (callback, value) => {
|
|
173
|
+
if (settled) {
|
|
174
|
+
if (value && typeof value.close === "function") {
|
|
175
|
+
value.close();
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
settled = true;
|
|
180
|
+
if (timer != null) {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
}
|
|
183
|
+
callback(value);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
request = factory.open(dbName, DB_VERSION);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
settle(reject, error);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
request.onupgradeneeded = () => {
|
|
194
|
+
const database = request.result;
|
|
195
|
+
let frameStore;
|
|
196
|
+
if (!database.objectStoreNames.contains(FRAME_STORE)) {
|
|
197
|
+
frameStore = database.createObjectStore(FRAME_STORE, { keyPath: "key" });
|
|
198
|
+
} else {
|
|
199
|
+
frameStore = request.transaction.objectStore(FRAME_STORE);
|
|
200
|
+
}
|
|
201
|
+
if (!frameStore.indexNames.contains(DOCUMENT_INDEX)) {
|
|
202
|
+
frameStore.createIndex(DOCUMENT_INDEX, "documentId", { unique: false });
|
|
203
|
+
}
|
|
204
|
+
if (!frameStore.indexNames.contains(ACCESS_INDEX)) {
|
|
205
|
+
frameStore.createIndex(ACCESS_INDEX, "lastAccess", { unique: false });
|
|
206
|
+
}
|
|
207
|
+
if (!database.objectStoreNames.contains(META_STORE)) {
|
|
208
|
+
database.createObjectStore(META_STORE, { keyPath: "key" });
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
request.onsuccess = () => settle(resolve, request.result);
|
|
212
|
+
request.onerror = () => settle(reject, request.error ?? new Error("Unable to open IndexedDB"));
|
|
213
|
+
request.onblocked = () => settle(reject, new Error("IndexedDB open was blocked"));
|
|
214
|
+
|
|
215
|
+
if (typeof setTimeout === "function") {
|
|
216
|
+
timer = setTimeout(() => {
|
|
217
|
+
settle(reject, new Error("IndexedDB open timed out"));
|
|
218
|
+
}, OPEN_TIMEOUT_MS);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isStoredFrameValid(record) {
|
|
224
|
+
return Boolean(
|
|
225
|
+
record
|
|
226
|
+
&& typeof record === "object"
|
|
227
|
+
&& typeof record.key === "string"
|
|
228
|
+
&& typeof record.documentId === "string"
|
|
229
|
+
&& Number.isSafeInteger(record.pageIndex)
|
|
230
|
+
&& normalizeDimension(record.width) != null
|
|
231
|
+
&& normalizeDimension(record.height) != null
|
|
232
|
+
&& Number.isSafeInteger(record.byteSize)
|
|
233
|
+
&& record.byteSize > 0
|
|
234
|
+
&& isPngBlob(record.blob)
|
|
235
|
+
&& record.blob.size === record.byteSize,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function reconcileUsage(database, limits) {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
let transaction;
|
|
242
|
+
try {
|
|
243
|
+
transaction = database.transaction([FRAME_STORE, META_STORE], "readwrite");
|
|
244
|
+
} catch (error) {
|
|
245
|
+
reject(error);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const frames = transaction.objectStore(FRAME_STORE);
|
|
250
|
+
const meta = transaction.objectStore(META_STORE);
|
|
251
|
+
let totalBytes = 0;
|
|
252
|
+
let totalEntries = 0;
|
|
253
|
+
|
|
254
|
+
const writeUsage = () => {
|
|
255
|
+
meta.put({
|
|
256
|
+
key: USAGE_KEY,
|
|
257
|
+
totalBytes,
|
|
258
|
+
totalEntries,
|
|
259
|
+
updatedAt: Date.now(),
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const withinLimits = () => (
|
|
264
|
+
totalBytes <= limits.maxBytes
|
|
265
|
+
&& totalEntries <= limits.maxEntries
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const trimOldest = () => {
|
|
269
|
+
if (withinLimits()) {
|
|
270
|
+
writeUsage();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const trimRequest = frames.index(ACCESS_INDEX).openCursor();
|
|
274
|
+
trimRequest.onsuccess = () => {
|
|
275
|
+
const cursor = trimRequest.result;
|
|
276
|
+
if (cursor == null) {
|
|
277
|
+
totalBytes = 0;
|
|
278
|
+
totalEntries = 0;
|
|
279
|
+
writeUsage();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const record = cursor.value;
|
|
283
|
+
cursor.delete();
|
|
284
|
+
if (isStoredFrameValid(record)) {
|
|
285
|
+
totalBytes = Math.max(0, totalBytes - record.byteSize);
|
|
286
|
+
totalEntries = Math.max(0, totalEntries - 1);
|
|
287
|
+
}
|
|
288
|
+
if (withinLimits()) {
|
|
289
|
+
writeUsage();
|
|
290
|
+
} else {
|
|
291
|
+
cursor.continue();
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const cursorRequest = frames.openCursor();
|
|
297
|
+
cursorRequest.onsuccess = () => {
|
|
298
|
+
const cursor = cursorRequest.result;
|
|
299
|
+
if (cursor == null) {
|
|
300
|
+
trimOldest();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const record = cursor.value;
|
|
304
|
+
if (isStoredFrameValid(record) && record.byteSize <= limits.maxItemBytes) {
|
|
305
|
+
totalBytes += record.byteSize;
|
|
306
|
+
totalEntries += 1;
|
|
307
|
+
} else {
|
|
308
|
+
cursor.delete();
|
|
309
|
+
}
|
|
310
|
+
cursor.continue();
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
transaction.oncomplete = () => resolve({ totalBytes, totalEntries });
|
|
314
|
+
transaction.onerror = () => reject(transactionError(transaction, "IndexedDB reconciliation failed"));
|
|
315
|
+
transaction.onabort = () => reject(transactionError(transaction, "IndexedDB reconciliation was aborted"));
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function readUsageRecord(value) {
|
|
320
|
+
const totalBytes = Number.isSafeInteger(value?.totalBytes) && value.totalBytes >= 0
|
|
321
|
+
? value.totalBytes
|
|
322
|
+
: 0;
|
|
323
|
+
const totalEntries = Number.isSafeInteger(value?.totalEntries) && value.totalEntries >= 0
|
|
324
|
+
? value.totalEntries
|
|
325
|
+
: 0;
|
|
326
|
+
return { totalBytes, totalEntries };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function putFrame(database, record, limits) {
|
|
330
|
+
return new Promise((resolve, reject) => {
|
|
331
|
+
let transaction;
|
|
332
|
+
try {
|
|
333
|
+
transaction = database.transaction([FRAME_STORE, META_STORE], "readwrite");
|
|
334
|
+
} catch (error) {
|
|
335
|
+
reject(error);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const frames = transaction.objectStore(FRAME_STORE);
|
|
340
|
+
const meta = transaction.objectStore(META_STORE);
|
|
341
|
+
let existing;
|
|
342
|
+
let usage;
|
|
343
|
+
let existingReady = false;
|
|
344
|
+
let usageReady = false;
|
|
345
|
+
let started = false;
|
|
346
|
+
|
|
347
|
+
const failRequest = () => {
|
|
348
|
+
try {
|
|
349
|
+
transaction.abort();
|
|
350
|
+
} catch {
|
|
351
|
+
// The transaction may already have been aborted by the browser.
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const finalizeWrite = () => {
|
|
356
|
+
const now = record.lastAccess;
|
|
357
|
+
frames.put(record);
|
|
358
|
+
meta.put({
|
|
359
|
+
key: USAGE_KEY,
|
|
360
|
+
totalBytes: usage.totalBytes,
|
|
361
|
+
totalEntries: usage.totalEntries,
|
|
362
|
+
updatedAt: now,
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const evictIfNeeded = () => {
|
|
367
|
+
if (started || !existingReady || !usageReady) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
started = true;
|
|
371
|
+
|
|
372
|
+
const oldBytes = isStoredFrameValid(existing) ? existing.byteSize : 0;
|
|
373
|
+
const oldEntries = isStoredFrameValid(existing) ? 1 : 0;
|
|
374
|
+
usage.totalBytes = Math.max(0, usage.totalBytes - oldBytes) + record.byteSize;
|
|
375
|
+
usage.totalEntries = Math.max(0, usage.totalEntries - oldEntries) + 1;
|
|
376
|
+
|
|
377
|
+
const withinLimits = () => (
|
|
378
|
+
usage.totalBytes <= limits.maxBytes
|
|
379
|
+
&& usage.totalEntries <= limits.maxEntries
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
if (withinLimits()) {
|
|
383
|
+
finalizeWrite();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const cursorRequest = frames.index(ACCESS_INDEX).openCursor();
|
|
388
|
+
cursorRequest.onsuccess = () => {
|
|
389
|
+
const cursor = cursorRequest.result;
|
|
390
|
+
if (cursor == null) {
|
|
391
|
+
if (withinLimits()) {
|
|
392
|
+
finalizeWrite();
|
|
393
|
+
} else {
|
|
394
|
+
failRequest();
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const candidate = cursor.value;
|
|
400
|
+
if (candidate?.key !== record.key && isStoredFrameValid(candidate)) {
|
|
401
|
+
cursor.delete();
|
|
402
|
+
usage.totalBytes = Math.max(0, usage.totalBytes - candidate.byteSize);
|
|
403
|
+
usage.totalEntries = Math.max(0, usage.totalEntries - 1);
|
|
404
|
+
} else if (candidate?.key !== record.key) {
|
|
405
|
+
cursor.delete();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (withinLimits()) {
|
|
409
|
+
finalizeWrite();
|
|
410
|
+
} else {
|
|
411
|
+
cursor.continue();
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
cursorRequest.onerror = failRequest;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const existingRequest = frames.get(record.key);
|
|
418
|
+
existingRequest.onsuccess = () => {
|
|
419
|
+
existing = existingRequest.result;
|
|
420
|
+
existingReady = true;
|
|
421
|
+
evictIfNeeded();
|
|
422
|
+
};
|
|
423
|
+
existingRequest.onerror = failRequest;
|
|
424
|
+
|
|
425
|
+
const usageRequest = meta.get(USAGE_KEY);
|
|
426
|
+
usageRequest.onsuccess = () => {
|
|
427
|
+
usage = readUsageRecord(usageRequest.result);
|
|
428
|
+
usageReady = true;
|
|
429
|
+
evictIfNeeded();
|
|
430
|
+
};
|
|
431
|
+
usageRequest.onerror = failRequest;
|
|
432
|
+
|
|
433
|
+
transaction.oncomplete = () => resolve(true);
|
|
434
|
+
transaction.onerror = () => reject(transactionError(transaction, "IndexedDB frame write failed"));
|
|
435
|
+
transaction.onabort = () => reject(transactionError(transaction, "IndexedDB frame write was aborted"));
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function getFrame(database, key, nextAccess) {
|
|
440
|
+
return new Promise((resolve, reject) => {
|
|
441
|
+
let transaction;
|
|
442
|
+
try {
|
|
443
|
+
transaction = database.transaction(FRAME_STORE, "readwrite");
|
|
444
|
+
} catch (error) {
|
|
445
|
+
reject(error);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const store = transaction.objectStore(FRAME_STORE);
|
|
450
|
+
let result = null;
|
|
451
|
+
const request = store.get(key.key);
|
|
452
|
+
request.onsuccess = () => {
|
|
453
|
+
const record = request.result;
|
|
454
|
+
if (!isStoredFrameValid(record)) {
|
|
455
|
+
if (record != null) {
|
|
456
|
+
store.delete(key.key);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
record.lastAccess = nextAccess();
|
|
461
|
+
store.put(record);
|
|
462
|
+
result = {
|
|
463
|
+
blob: record.blob,
|
|
464
|
+
width: record.width,
|
|
465
|
+
height: record.height,
|
|
466
|
+
byteSize: record.byteSize,
|
|
467
|
+
lastAccess: record.lastAccess,
|
|
468
|
+
};
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
transaction.oncomplete = () => resolve(result);
|
|
472
|
+
transaction.onerror = () => reject(transactionError(transaction, "IndexedDB frame read failed"));
|
|
473
|
+
transaction.onabort = () => reject(transactionError(transaction, "IndexedDB frame read was aborted"));
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function deleteFrame(database, key) {
|
|
478
|
+
return new Promise((resolve, reject) => {
|
|
479
|
+
let transaction;
|
|
480
|
+
try {
|
|
481
|
+
transaction = database.transaction([FRAME_STORE, META_STORE], "readwrite");
|
|
482
|
+
} catch (error) {
|
|
483
|
+
reject(error);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const frames = transaction.objectStore(FRAME_STORE);
|
|
488
|
+
const meta = transaction.objectStore(META_STORE);
|
|
489
|
+
let deleted = false;
|
|
490
|
+
const getRequest = frames.get(key.key);
|
|
491
|
+
getRequest.onsuccess = () => {
|
|
492
|
+
const existing = getRequest.result;
|
|
493
|
+
if (existing == null) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
deleted = true;
|
|
497
|
+
const existingIsValid = isStoredFrameValid(existing);
|
|
498
|
+
const usageRequest = meta.get(USAGE_KEY);
|
|
499
|
+
usageRequest.onsuccess = () => {
|
|
500
|
+
const usage = readUsageRecord(usageRequest.result);
|
|
501
|
+
frames.delete(key.key);
|
|
502
|
+
meta.put({
|
|
503
|
+
key: USAGE_KEY,
|
|
504
|
+
totalBytes: Math.max(0, usage.totalBytes - (existingIsValid ? existing.byteSize : 0)),
|
|
505
|
+
totalEntries: Math.max(0, usage.totalEntries - (existingIsValid ? 1 : 0)),
|
|
506
|
+
updatedAt: Date.now(),
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
transaction.oncomplete = () => resolve(deleted);
|
|
512
|
+
transaction.onerror = () => reject(transactionError(transaction, "IndexedDB frame delete failed"));
|
|
513
|
+
transaction.onabort = () => reject(transactionError(transaction, "IndexedDB frame delete was aborted"));
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function clearDocumentFrames(database, documentId) {
|
|
518
|
+
return new Promise((resolve, reject) => {
|
|
519
|
+
let transaction;
|
|
520
|
+
try {
|
|
521
|
+
transaction = database.transaction([FRAME_STORE, META_STORE], "readwrite");
|
|
522
|
+
} catch (error) {
|
|
523
|
+
reject(error);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const frames = transaction.objectStore(FRAME_STORE);
|
|
528
|
+
const meta = transaction.objectStore(META_STORE);
|
|
529
|
+
let usage = null;
|
|
530
|
+
let cursorFinished = false;
|
|
531
|
+
let deletedEntries = 0;
|
|
532
|
+
let deletedBytes = 0;
|
|
533
|
+
let finalized = false;
|
|
534
|
+
|
|
535
|
+
const maybeFinalize = () => {
|
|
536
|
+
if (finalized || usage == null || !cursorFinished) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
finalized = true;
|
|
540
|
+
meta.put({
|
|
541
|
+
key: USAGE_KEY,
|
|
542
|
+
totalBytes: Math.max(0, usage.totalBytes - deletedBytes),
|
|
543
|
+
totalEntries: Math.max(0, usage.totalEntries - deletedEntries),
|
|
544
|
+
updatedAt: Date.now(),
|
|
545
|
+
});
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const usageRequest = meta.get(USAGE_KEY);
|
|
549
|
+
usageRequest.onsuccess = () => {
|
|
550
|
+
usage = readUsageRecord(usageRequest.result);
|
|
551
|
+
maybeFinalize();
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const cursorRequest = frames.index(DOCUMENT_INDEX).openCursor(documentId);
|
|
555
|
+
cursorRequest.onsuccess = () => {
|
|
556
|
+
const cursor = cursorRequest.result;
|
|
557
|
+
if (cursor == null) {
|
|
558
|
+
cursorFinished = true;
|
|
559
|
+
maybeFinalize();
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const record = cursor.value;
|
|
563
|
+
if (isStoredFrameValid(record)) {
|
|
564
|
+
deletedEntries += 1;
|
|
565
|
+
deletedBytes += record.byteSize;
|
|
566
|
+
}
|
|
567
|
+
cursor.delete();
|
|
568
|
+
cursor.continue();
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
transaction.oncomplete = () => resolve(deletedEntries);
|
|
572
|
+
transaction.onerror = () => reject(transactionError(transaction, "IndexedDB document clear failed"));
|
|
573
|
+
transaction.onabort = () => reject(transactionError(transaction, "IndexedDB document clear was aborted"));
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Create a cache facade that is safe in Window and DedicatedWorkerGlobalScope.
|
|
579
|
+
* The facade is synchronous; all storage methods are asynchronous and never
|
|
580
|
+
* throw for storage availability, quota, cloning, or transaction failures.
|
|
581
|
+
*/
|
|
582
|
+
export function createFrameCache(options = {}) {
|
|
583
|
+
const source = options && typeof options === "object" ? options : {};
|
|
584
|
+
const limits = normalizeFrameCacheLimits(source);
|
|
585
|
+
const dbName = normalizeDatabaseName(source.dbName);
|
|
586
|
+
const factory = source.indexedDB ?? globalThis.indexedDB;
|
|
587
|
+
const enabled = source.enabled !== false;
|
|
588
|
+
let closed = false;
|
|
589
|
+
let disabledReason = !enabled
|
|
590
|
+
? "disabled"
|
|
591
|
+
: factory && typeof factory.open === "function"
|
|
592
|
+
? null
|
|
593
|
+
: "unavailable";
|
|
594
|
+
let databasePromise = null;
|
|
595
|
+
let lastAccess = 0;
|
|
596
|
+
|
|
597
|
+
const nextAccess = () => {
|
|
598
|
+
lastAccess = Math.max(Date.now(), lastAccess + 1);
|
|
599
|
+
return lastAccess;
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const disable = (error) => {
|
|
603
|
+
if (disabledReason == null) {
|
|
604
|
+
disabledReason = typeof error?.name === "string" && error.name
|
|
605
|
+
? error.name
|
|
606
|
+
: "storage-error";
|
|
607
|
+
}
|
|
608
|
+
if (databasePromise != null) {
|
|
609
|
+
databasePromise.then((database) => database?.close()).catch(() => {});
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const getDatabase = async () => {
|
|
614
|
+
if (closed || disabledReason != null) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
if (databasePromise == null) {
|
|
618
|
+
databasePromise = openDatabase(factory, dbName)
|
|
619
|
+
.then(async (database) => {
|
|
620
|
+
database.onversionchange = () => {
|
|
621
|
+
database.close();
|
|
622
|
+
disable({ name: "version-change" });
|
|
623
|
+
};
|
|
624
|
+
await reconcileUsage(database, limits);
|
|
625
|
+
return database;
|
|
626
|
+
})
|
|
627
|
+
.catch((error) => {
|
|
628
|
+
disable(error);
|
|
629
|
+
return null;
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
return databasePromise;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const run = async (operation, fallback) => {
|
|
636
|
+
if (closed || disabledReason != null) {
|
|
637
|
+
return fallback;
|
|
638
|
+
}
|
|
639
|
+
const database = await getDatabase();
|
|
640
|
+
if (database == null) {
|
|
641
|
+
return fallback;
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
return await operation(database);
|
|
645
|
+
} catch (error) {
|
|
646
|
+
// QuotaExceededError, Safari's InvalidStateError / TransactionInactiveError,
|
|
647
|
+
// private-mode failures, and structured-clone failures all become misses.
|
|
648
|
+
disable(error);
|
|
649
|
+
return fallback;
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
return Object.freeze({
|
|
654
|
+
async get(keyInput) {
|
|
655
|
+
const key = normalizeFrameCacheKey(keyInput);
|
|
656
|
+
if (key == null) {
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
return run((database) => getFrame(database, key, nextAccess), null);
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
async put(keyInput, frameInput) {
|
|
663
|
+
const input = normalizePutArguments(keyInput, frameInput, limits);
|
|
664
|
+
if (input == null) {
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
const record = {
|
|
668
|
+
...input,
|
|
669
|
+
lastAccess: nextAccess(),
|
|
670
|
+
};
|
|
671
|
+
return run((database) => putFrame(database, record, limits), false);
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
async delete(keyInput) {
|
|
675
|
+
const key = normalizeFrameCacheKey(keyInput);
|
|
676
|
+
if (key == null) {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
return run((database) => deleteFrame(database, key), false);
|
|
680
|
+
},
|
|
681
|
+
|
|
682
|
+
async clearDocument(documentId) {
|
|
683
|
+
const normalized = normalizeBoundedString(documentId, MAX_DOCUMENT_ID_LENGTH, { trim: true });
|
|
684
|
+
if (normalized == null) {
|
|
685
|
+
return 0;
|
|
686
|
+
}
|
|
687
|
+
return run((database) => clearDocumentFrames(database, normalized), 0);
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
async stats() {
|
|
691
|
+
const unavailable = () => ({
|
|
692
|
+
available: false,
|
|
693
|
+
disabledReason: closed ? "closed" : disabledReason ?? "unavailable",
|
|
694
|
+
entries: 0,
|
|
695
|
+
totalBytes: 0,
|
|
696
|
+
...limits,
|
|
697
|
+
});
|
|
698
|
+
if (closed || disabledReason != null) {
|
|
699
|
+
return unavailable();
|
|
700
|
+
}
|
|
701
|
+
const result = await run(async (database) => {
|
|
702
|
+
let transaction;
|
|
703
|
+
try {
|
|
704
|
+
transaction = database.transaction(META_STORE, "readonly");
|
|
705
|
+
} catch (error) {
|
|
706
|
+
throw error;
|
|
707
|
+
}
|
|
708
|
+
const value = await requestResult(transaction.objectStore(META_STORE).get(USAGE_KEY));
|
|
709
|
+
const usage = readUsageRecord(value);
|
|
710
|
+
return {
|
|
711
|
+
available: true,
|
|
712
|
+
disabledReason: null,
|
|
713
|
+
entries: usage.totalEntries,
|
|
714
|
+
totalBytes: usage.totalBytes,
|
|
715
|
+
...limits,
|
|
716
|
+
};
|
|
717
|
+
}, null);
|
|
718
|
+
return result ?? unavailable();
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
close() {
|
|
722
|
+
if (closed) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
closed = true;
|
|
726
|
+
if (databasePromise != null) {
|
|
727
|
+
databasePromise.then((database) => database?.close()).catch(() => {});
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
}
|