@siglum/engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/package.json +55 -0
- package/src/bundles.js +545 -0
- package/src/compiler.js +1164 -0
- package/src/ctan.js +818 -0
- package/src/hash.js +102 -0
- package/src/index.js +32 -0
- package/src/storage.js +642 -0
- package/src/utils.js +33 -0
- package/src/worker.js +2217 -0
- package/types/bundles.d.ts +143 -0
- package/types/compiler.d.ts +288 -0
- package/types/ctan.d.ts +156 -0
- package/types/hash.d.ts +25 -0
- package/types/index.d.ts +5 -0
- package/types/storage.d.ts +124 -0
- package/types/utils.d.ts +16 -0
package/src/storage.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @siglum/engine/storage
|
|
3
|
+
* Storage utilities for caching bundles, manifests, compiled PDFs, and CTAN packages.
|
|
4
|
+
* Uses @siglum/filesystem for persistent file operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { fileSystem } from '@siglum/filesystem';
|
|
8
|
+
|
|
9
|
+
function getFileSystem() {
|
|
10
|
+
return fileSystem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let wasmCacheMounted = false;
|
|
14
|
+
async function ensureWasmCacheMounted() {
|
|
15
|
+
if (wasmCacheMounted) return true;
|
|
16
|
+
const fs = await getFileSystem();
|
|
17
|
+
if (!fs) return false;
|
|
18
|
+
try {
|
|
19
|
+
await fs.mountAuto('/wasm-cache');
|
|
20
|
+
wasmCacheMounted = true;
|
|
21
|
+
return true;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.warn('Failed to mount wasm-cache filesystem:', e);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const IDB_NAME = 'siglum-ctan-cache';
|
|
29
|
+
const IDB_STORE = 'packages';
|
|
30
|
+
const CTAN_CACHE_VERSION = 9; // Bumped to force refetch from TexLive 2025 (enumitem v3.11 fix)
|
|
31
|
+
const BUNDLE_CACHE_VERSION = 4;
|
|
32
|
+
const MANIFEST_CACHE_VERSION = 5; // Bumped: consolidated metadata into bundles.json
|
|
33
|
+
|
|
34
|
+
let idbCache = null;
|
|
35
|
+
|
|
36
|
+
// IndexedDB operations
|
|
37
|
+
async function openIDBCache() {
|
|
38
|
+
if (idbCache) return idbCache;
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const request = indexedDB.open(IDB_NAME, 1);
|
|
41
|
+
request.onerror = () => reject(request.error);
|
|
42
|
+
request.onsuccess = () => {
|
|
43
|
+
idbCache = request.result;
|
|
44
|
+
resolve(idbCache);
|
|
45
|
+
};
|
|
46
|
+
request.onupgradeneeded = (event) => {
|
|
47
|
+
const db = event.target.result;
|
|
48
|
+
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
|
49
|
+
db.createObjectStore(IDB_STORE, { keyPath: 'name' });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get metadata for a cached CTAN package.
|
|
57
|
+
* @param {string} packageName - Package name
|
|
58
|
+
* @returns {Promise<Object|null>} Package metadata or null
|
|
59
|
+
*/
|
|
60
|
+
export async function getPackageMeta(packageName) {
|
|
61
|
+
try {
|
|
62
|
+
const db = await openIDBCache();
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const tx = db.transaction(IDB_STORE, 'readonly');
|
|
65
|
+
const store = tx.objectStore(IDB_STORE);
|
|
66
|
+
const request = store.get(packageName);
|
|
67
|
+
request.onerror = () => resolve(null);
|
|
68
|
+
request.onsuccess = () => resolve(request.result);
|
|
69
|
+
});
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Save metadata for a CTAN package.
|
|
77
|
+
* @param {string} packageName - Package name
|
|
78
|
+
* @param {Object} meta - Package metadata
|
|
79
|
+
* @returns {Promise<boolean>} True if saved successfully
|
|
80
|
+
*/
|
|
81
|
+
export async function savePackageMeta(packageName, meta) {
|
|
82
|
+
try {
|
|
83
|
+
const db = await openIDBCache();
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const tx = db.transaction(IDB_STORE, 'readwrite');
|
|
86
|
+
const store = tx.objectStore(IDB_STORE);
|
|
87
|
+
const request = store.put({ name: packageName, ...meta, timestamp: Date.now() });
|
|
88
|
+
request.onerror = () => resolve(false);
|
|
89
|
+
request.onsuccess = () => resolve(true);
|
|
90
|
+
});
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* List all cached CTAN packages and their metadata.
|
|
98
|
+
* @returns {Promise<Object[]>} Array of package metadata objects
|
|
99
|
+
*/
|
|
100
|
+
export async function listAllCachedPackages() {
|
|
101
|
+
try {
|
|
102
|
+
const db = await openIDBCache();
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const tx = db.transaction(IDB_STORE, 'readonly');
|
|
105
|
+
const store = tx.objectStore(IDB_STORE);
|
|
106
|
+
const request = store.getAll();
|
|
107
|
+
request.onerror = () => resolve([]);
|
|
108
|
+
request.onsuccess = () => resolve(request.result || []);
|
|
109
|
+
});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Mount for manifests
|
|
116
|
+
let manifestsMounted = false;
|
|
117
|
+
async function ensureManifestsMounted() {
|
|
118
|
+
if (manifestsMounted) return true;
|
|
119
|
+
const fs = await getFileSystem();
|
|
120
|
+
if (!fs) return false;
|
|
121
|
+
try {
|
|
122
|
+
await fs.mountAuto('/manifests');
|
|
123
|
+
manifestsMounted = true;
|
|
124
|
+
return true;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.warn('Failed to mount manifests filesystem:', e);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Mount for format cache
|
|
132
|
+
let fmtCacheMounted = false;
|
|
133
|
+
async function ensureFmtCacheMounted() {
|
|
134
|
+
if (fmtCacheMounted) return true;
|
|
135
|
+
const fs = await getFileSystem();
|
|
136
|
+
if (!fs) return false;
|
|
137
|
+
try {
|
|
138
|
+
await fs.mountAuto('/fmt-cache');
|
|
139
|
+
fmtCacheMounted = true;
|
|
140
|
+
return true;
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.warn('Failed to mount fmt-cache filesystem:', e);
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Mount for texlive/CTAN cache
|
|
148
|
+
let texliveMounted = false;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Ensure the /texlive filesystem is mounted for CTAN package storage.
|
|
152
|
+
* @returns {Promise<boolean>} True if mounted successfully
|
|
153
|
+
*/
|
|
154
|
+
export async function ensureTexliveMounted() {
|
|
155
|
+
if (texliveMounted) return true;
|
|
156
|
+
const fs = await getFileSystem();
|
|
157
|
+
if (!fs) return false;
|
|
158
|
+
try {
|
|
159
|
+
await fs.mountAuto('/texlive');
|
|
160
|
+
texliveMounted = true;
|
|
161
|
+
return true;
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.warn('Failed to mount texlive filesystem:', e);
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Bundle cache operations
|
|
169
|
+
let bundleCacheMounted = false;
|
|
170
|
+
|
|
171
|
+
async function ensureBundleCacheMounted() {
|
|
172
|
+
if (bundleCacheMounted) return true;
|
|
173
|
+
const fs = await getFileSystem();
|
|
174
|
+
if (!fs) return false;
|
|
175
|
+
try {
|
|
176
|
+
await fs.mountAuto('/bundle-cache');
|
|
177
|
+
bundleCacheMounted = true;
|
|
178
|
+
|
|
179
|
+
// Check version and clear if outdated
|
|
180
|
+
try {
|
|
181
|
+
const versionStr = await fs.readFile('/bundle-cache/version');
|
|
182
|
+
const version = parseInt(versionStr) || 0;
|
|
183
|
+
if (version < BUNDLE_CACHE_VERSION) {
|
|
184
|
+
if (version > 0) {
|
|
185
|
+
console.log(`Bundle cache version upgrade (${version} → ${BUNDLE_CACHE_VERSION}), clearing...`);
|
|
186
|
+
}
|
|
187
|
+
await fs.rmdir('/bundle-cache', { recursive: true });
|
|
188
|
+
await fs.mountAuto('/bundle-cache');
|
|
189
|
+
}
|
|
190
|
+
} catch (e) {
|
|
191
|
+
// Version file doesn't exist, will be created on first write
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Write current version
|
|
195
|
+
await fs.writeFile('/bundle-cache/version', String(BUNDLE_CACHE_VERSION));
|
|
196
|
+
return true;
|
|
197
|
+
} catch (e) {
|
|
198
|
+
console.warn('Failed to mount bundle-cache filesystem:', e);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get a bundle from the cache.
|
|
205
|
+
* @param {string} bundleName - Bundle name
|
|
206
|
+
* @returns {Promise<ArrayBuffer|null>} Bundle data or null if not cached
|
|
207
|
+
*/
|
|
208
|
+
export async function getBundleFromCache(bundleName) {
|
|
209
|
+
try {
|
|
210
|
+
const fs = await getFileSystem();
|
|
211
|
+
if (!fs || !await ensureBundleCacheMounted()) return null;
|
|
212
|
+
|
|
213
|
+
const data = await fs.readBinary(`/bundle-cache/bundles/${bundleName}.data`);
|
|
214
|
+
return data?.buffer || null;
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Save a bundle to the cache.
|
|
222
|
+
* @param {string} bundleName - Bundle name
|
|
223
|
+
* @param {ArrayBuffer|SharedArrayBuffer} data - Bundle data
|
|
224
|
+
* @returns {Promise<boolean>} True if saved successfully
|
|
225
|
+
*/
|
|
226
|
+
export async function saveBundleToCache(bundleName, data) {
|
|
227
|
+
try {
|
|
228
|
+
const fs = await getFileSystem();
|
|
229
|
+
if (!fs || !await ensureBundleCacheMounted()) return false;
|
|
230
|
+
|
|
231
|
+
await fs.mkdir('/bundle-cache/bundles');
|
|
232
|
+
// Convert SharedArrayBuffer to regular ArrayBuffer for IndexedDB compatibility
|
|
233
|
+
// (SharedArrayBuffer can't be serialized for storage)
|
|
234
|
+
let buffer = data;
|
|
235
|
+
if (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer) {
|
|
236
|
+
buffer = new ArrayBuffer(data.byteLength);
|
|
237
|
+
new Uint8Array(buffer).set(new Uint8Array(data));
|
|
238
|
+
}
|
|
239
|
+
await fs.writeBinary(`/bundle-cache/bundles/${bundleName}.data`, new Uint8Array(buffer));
|
|
240
|
+
return true;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
console.warn(`Failed to save bundle ${bundleName}:`, e);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get a manifest from the cache.
|
|
249
|
+
* @param {string} name - Manifest name (without .json extension)
|
|
250
|
+
* @returns {Promise<Object|null>} Parsed manifest or null
|
|
251
|
+
*/
|
|
252
|
+
export async function getManifestFromCache(name) {
|
|
253
|
+
try {
|
|
254
|
+
const fs = await getFileSystem();
|
|
255
|
+
if (!fs || !await ensureManifestsMounted()) return null;
|
|
256
|
+
|
|
257
|
+
const content = await fs.readFile(`/manifests/${name}.json`);
|
|
258
|
+
return JSON.parse(content);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Save a manifest to the cache.
|
|
266
|
+
* @param {string} name - Manifest name (without .json extension)
|
|
267
|
+
* @param {Object} data - Manifest data
|
|
268
|
+
* @returns {Promise<boolean>} True if saved successfully
|
|
269
|
+
*/
|
|
270
|
+
export async function saveManifestToCache(name, data) {
|
|
271
|
+
try {
|
|
272
|
+
const fs = await getFileSystem();
|
|
273
|
+
if (!fs || !await ensureManifestsMounted()) return false;
|
|
274
|
+
|
|
275
|
+
await fs.writeFile(`/manifests/${name}.json`, JSON.stringify(data), { createParents: true });
|
|
276
|
+
return true;
|
|
277
|
+
} catch (e) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get the cached manifest version number.
|
|
284
|
+
* @returns {Promise<number>} Version number (0 if not set)
|
|
285
|
+
*/
|
|
286
|
+
export async function getManifestVersion() {
|
|
287
|
+
try {
|
|
288
|
+
const fs = await getFileSystem();
|
|
289
|
+
if (!fs || !await ensureManifestsMounted()) return 0;
|
|
290
|
+
|
|
291
|
+
const content = await fs.readFile('/manifests/version');
|
|
292
|
+
return parseInt(content) || 0;
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Save the manifest version number.
|
|
300
|
+
* @param {number} version - Version number
|
|
301
|
+
* @returns {Promise<boolean>} True if saved successfully
|
|
302
|
+
*/
|
|
303
|
+
export async function saveManifestVersion(version) {
|
|
304
|
+
try {
|
|
305
|
+
const fs = await getFileSystem();
|
|
306
|
+
if (!fs || !await ensureManifestsMounted()) return false;
|
|
307
|
+
|
|
308
|
+
await fs.writeFile('/manifests/version', String(version), { createParents: true });
|
|
309
|
+
return true;
|
|
310
|
+
} catch (e) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Aux file cache
|
|
316
|
+
const AUX_STORE = 'aux-cache';
|
|
317
|
+
let auxCacheDb = null;
|
|
318
|
+
const auxMemoryCache = new Map();
|
|
319
|
+
|
|
320
|
+
async function openAuxCacheDb() {
|
|
321
|
+
if (auxCacheDb) return auxCacheDb;
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
const request = indexedDB.open('siglum-aux-cache', 1);
|
|
324
|
+
request.onerror = () => reject(request.error);
|
|
325
|
+
request.onsuccess = () => {
|
|
326
|
+
auxCacheDb = request.result;
|
|
327
|
+
resolve(auxCacheDb);
|
|
328
|
+
};
|
|
329
|
+
request.onupgradeneeded = (event) => {
|
|
330
|
+
const db = event.target.result;
|
|
331
|
+
if (!db.objectStoreNames.contains(AUX_STORE)) {
|
|
332
|
+
db.createObjectStore(AUX_STORE, { keyPath: 'hash' });
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Get cached aux files for a preamble hash.
|
|
340
|
+
* @param {string} preambleHash - Hash of the document preamble
|
|
341
|
+
* @returns {Promise<{hash: string, files: Object, timestamp: number}|null>} Cached entry or null
|
|
342
|
+
*/
|
|
343
|
+
export async function getAuxCache(preambleHash) {
|
|
344
|
+
if (auxMemoryCache.has(preambleHash)) {
|
|
345
|
+
return auxMemoryCache.get(preambleHash);
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const db = await openAuxCacheDb();
|
|
349
|
+
return new Promise((resolve) => {
|
|
350
|
+
const tx = db.transaction(AUX_STORE, 'readonly');
|
|
351
|
+
const store = tx.objectStore(AUX_STORE);
|
|
352
|
+
const request = store.get(preambleHash);
|
|
353
|
+
request.onerror = () => resolve(null);
|
|
354
|
+
request.onsuccess = () => {
|
|
355
|
+
const result = request.result;
|
|
356
|
+
if (result) auxMemoryCache.set(preambleHash, result);
|
|
357
|
+
resolve(result);
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
} catch (e) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Save aux files for a preamble hash.
|
|
367
|
+
* @param {string} preambleHash - Hash of the document preamble
|
|
368
|
+
* @param {Object} auxFiles - Aux files to cache
|
|
369
|
+
* @returns {Promise<void>}
|
|
370
|
+
*/
|
|
371
|
+
export async function saveAuxCache(preambleHash, auxFiles) {
|
|
372
|
+
const entry = { hash: preambleHash, files: auxFiles, timestamp: Date.now() };
|
|
373
|
+
auxMemoryCache.set(preambleHash, entry);
|
|
374
|
+
try {
|
|
375
|
+
const db = await openAuxCacheDb();
|
|
376
|
+
const tx = db.transaction(AUX_STORE, 'readwrite');
|
|
377
|
+
const store = tx.objectStore(AUX_STORE);
|
|
378
|
+
store.put(entry);
|
|
379
|
+
} catch (e) {}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Document cache for compiled PDFs
|
|
383
|
+
const DOC_STORE = 'doc-cache';
|
|
384
|
+
let docCacheDb = null;
|
|
385
|
+
const docMemoryCache = new Map();
|
|
386
|
+
const MAX_DOC_CACHE_SIZE = 10;
|
|
387
|
+
|
|
388
|
+
async function openDocCacheDb() {
|
|
389
|
+
if (docCacheDb) return docCacheDb;
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
391
|
+
const request = indexedDB.open('siglum-doc-cache', 1);
|
|
392
|
+
request.onerror = () => reject(request.error);
|
|
393
|
+
request.onsuccess = () => {
|
|
394
|
+
docCacheDb = request.result;
|
|
395
|
+
resolve(docCacheDb);
|
|
396
|
+
};
|
|
397
|
+
request.onupgradeneeded = (event) => {
|
|
398
|
+
const db = event.target.result;
|
|
399
|
+
if (!db.objectStoreNames.contains(DOC_STORE)) {
|
|
400
|
+
db.createObjectStore(DOC_STORE, { keyPath: 'key' });
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Re-export hashDocument from centralized hash module (BLAKE3-WASM)
|
|
407
|
+
export { hashDocument } from './hash.js';
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Get a cached compiled PDF.
|
|
411
|
+
* @param {string} docHash - Document content hash
|
|
412
|
+
* @param {string} engine - Engine used ('pdflatex', 'xelatex', 'lualatex')
|
|
413
|
+
* @returns {Promise<Uint8Array|null>} PDF data or null if not cached
|
|
414
|
+
*/
|
|
415
|
+
export async function getCachedPdf(docHash, engine) {
|
|
416
|
+
const key = docHash + '_' + engine;
|
|
417
|
+
if (docMemoryCache.has(key)) {
|
|
418
|
+
return docMemoryCache.get(key);
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const db = await openDocCacheDb();
|
|
422
|
+
return new Promise((resolve) => {
|
|
423
|
+
const tx = db.transaction(DOC_STORE, 'readonly');
|
|
424
|
+
const store = tx.objectStore(DOC_STORE);
|
|
425
|
+
const request = store.get(key);
|
|
426
|
+
request.onerror = () => resolve(null);
|
|
427
|
+
request.onsuccess = () => {
|
|
428
|
+
const result = request.result;
|
|
429
|
+
if (result) {
|
|
430
|
+
docMemoryCache.set(key, result.pdfData);
|
|
431
|
+
}
|
|
432
|
+
resolve(result?.pdfData || null);
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
} catch (e) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Save a compiled PDF to the cache.
|
|
442
|
+
* @param {string} docHash - Document content hash
|
|
443
|
+
* @param {string} engine - Engine used ('pdflatex', 'xelatex', 'lualatex')
|
|
444
|
+
* @param {Uint8Array} pdfData - PDF data
|
|
445
|
+
* @returns {Promise<void>}
|
|
446
|
+
*/
|
|
447
|
+
export async function saveCachedPdf(docHash, engine, pdfData) {
|
|
448
|
+
const key = docHash + '_' + engine;
|
|
449
|
+
docMemoryCache.set(key, pdfData);
|
|
450
|
+
|
|
451
|
+
// Limit memory cache size
|
|
452
|
+
if (docMemoryCache.size > MAX_DOC_CACHE_SIZE) {
|
|
453
|
+
const firstKey = docMemoryCache.keys().next().value;
|
|
454
|
+
docMemoryCache.delete(firstKey);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const db = await openDocCacheDb();
|
|
459
|
+
const tx = db.transaction(DOC_STORE, 'readwrite');
|
|
460
|
+
const store = tx.objectStore(DOC_STORE);
|
|
461
|
+
store.put({ key, pdfData, timestamp: Date.now() });
|
|
462
|
+
} catch (e) {}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Get the path for a format file.
|
|
467
|
+
* @param {string} fmtKey - Format key
|
|
468
|
+
* @returns {string} Path to format file
|
|
469
|
+
*/
|
|
470
|
+
export function getFmtPath(fmtKey) {
|
|
471
|
+
return `fmt-cache/${fmtKey}.fmt`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Clear all cached CTAN package metadata.
|
|
476
|
+
* @returns {Promise<boolean>} True if cleared successfully
|
|
477
|
+
*/
|
|
478
|
+
export async function clearCTANCache() {
|
|
479
|
+
try {
|
|
480
|
+
const db = await openIDBCache();
|
|
481
|
+
const tx = db.transaction(IDB_STORE, 'readwrite');
|
|
482
|
+
const store = tx.objectStore(IDB_STORE);
|
|
483
|
+
store.clear();
|
|
484
|
+
await new Promise(r => tx.oncomplete = r);
|
|
485
|
+
return true;
|
|
486
|
+
} catch (e) {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// WASM cache - stores COMPILED WebAssembly.Module in IndexedDB for instant instantiation
|
|
492
|
+
const WASM_CACHE_VERSION = 2; // Bump to invalidate old byte caches
|
|
493
|
+
const WASM_DB_NAME = 'siglum-wasm-cache';
|
|
494
|
+
const WASM_STORE = 'modules';
|
|
495
|
+
|
|
496
|
+
let wasmCacheDb = null;
|
|
497
|
+
|
|
498
|
+
async function openWasmCacheDb() {
|
|
499
|
+
if (wasmCacheDb) return wasmCacheDb;
|
|
500
|
+
return new Promise((resolve, reject) => {
|
|
501
|
+
const request = indexedDB.open(WASM_DB_NAME, WASM_CACHE_VERSION);
|
|
502
|
+
request.onerror = () => reject(request.error);
|
|
503
|
+
request.onsuccess = () => {
|
|
504
|
+
wasmCacheDb = request.result;
|
|
505
|
+
resolve(wasmCacheDb);
|
|
506
|
+
};
|
|
507
|
+
request.onupgradeneeded = (event) => {
|
|
508
|
+
const db = event.target.result;
|
|
509
|
+
// Clear old stores on version upgrade
|
|
510
|
+
for (const name of db.objectStoreNames) {
|
|
511
|
+
db.deleteObjectStore(name);
|
|
512
|
+
}
|
|
513
|
+
db.createObjectStore(WASM_STORE, { keyPath: 'key' });
|
|
514
|
+
};
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Get cached WASM bytes and compile to WebAssembly.Module.
|
|
520
|
+
* @returns {Promise<WebAssembly.Module|null>} Compiled module or null
|
|
521
|
+
*/
|
|
522
|
+
export async function getCompiledWasmModule() {
|
|
523
|
+
try {
|
|
524
|
+
const db = await openWasmCacheDb();
|
|
525
|
+
return new Promise((resolve) => {
|
|
526
|
+
const tx = db.transaction(WASM_STORE, 'readonly');
|
|
527
|
+
const store = tx.objectStore(WASM_STORE);
|
|
528
|
+
const request = store.get('busytex');
|
|
529
|
+
request.onerror = () => resolve(null);
|
|
530
|
+
request.onsuccess = async () => {
|
|
531
|
+
const result = request.result;
|
|
532
|
+
if (result?.bytes instanceof Uint8Array) {
|
|
533
|
+
try {
|
|
534
|
+
const module = await WebAssembly.compile(result.bytes);
|
|
535
|
+
resolve(module);
|
|
536
|
+
} catch {
|
|
537
|
+
resolve(null);
|
|
538
|
+
}
|
|
539
|
+
} else {
|
|
540
|
+
resolve(null);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
});
|
|
544
|
+
} catch (e) {
|
|
545
|
+
console.warn('Failed to get cached WASM:', e);
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Save WASM bytes to IndexedDB for future compilation.
|
|
552
|
+
* @param {Uint8Array} bytes - WASM bytes
|
|
553
|
+
* @returns {Promise<boolean>} True if saved successfully
|
|
554
|
+
*/
|
|
555
|
+
export async function saveWasmBytes(bytes) {
|
|
556
|
+
try {
|
|
557
|
+
const db = await openWasmCacheDb();
|
|
558
|
+
return new Promise((resolve) => {
|
|
559
|
+
const tx = db.transaction(WASM_STORE, 'readwrite');
|
|
560
|
+
const store = tx.objectStore(WASM_STORE);
|
|
561
|
+
const request = store.put({ key: 'busytex', bytes, timestamp: Date.now() });
|
|
562
|
+
request.onerror = () => resolve(false);
|
|
563
|
+
request.onsuccess = () => resolve(true);
|
|
564
|
+
});
|
|
565
|
+
} catch {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// WASM Memory Snapshot Cache - stores initialized WASM heap for instant restore
|
|
571
|
+
// This caches the WASM linear memory after first successful initialization
|
|
572
|
+
// Restoring from snapshot skips the ~3-5s initialization overhead
|
|
573
|
+
const MEMORY_SNAPSHOT_VERSION = 1;
|
|
574
|
+
const MEMORY_SNAPSHOT_PATH = '/wasm-cache/memory-snapshot.bin';
|
|
575
|
+
const MEMORY_SNAPSHOT_META_PATH = '/wasm-cache/memory-snapshot-meta.json';
|
|
576
|
+
|
|
577
|
+
// Prevent concurrent save operations (race condition protection)
|
|
578
|
+
let snapshotSaveInProgress = false;
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Save WASM memory snapshot for instant restore on next load.
|
|
582
|
+
* @param {WebAssembly.Memory|Uint8Array} memoryOrSnapshot - Memory object or snapshot bytes
|
|
583
|
+
* @param {Object} [metadata] - Optional metadata to save with snapshot
|
|
584
|
+
* @returns {Promise<boolean>} True if saved successfully
|
|
585
|
+
*/
|
|
586
|
+
export async function saveWasmMemorySnapshot(memoryOrSnapshot, metadata = {}) {
|
|
587
|
+
// Prevent concurrent saves - only one save operation at a time
|
|
588
|
+
if (snapshotSaveInProgress) {
|
|
589
|
+
console.log('Memory snapshot save already in progress, skipping');
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
snapshotSaveInProgress = true;
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
if (!await ensureWasmCacheMounted()) {
|
|
596
|
+
console.warn('Cannot save memory snapshot - filesystem not available');
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Accept either a memory object (with .buffer) or a Uint8Array directly
|
|
601
|
+
// This avoids unnecessary copies when we already have a Uint8Array
|
|
602
|
+
const snapshot = memoryOrSnapshot instanceof Uint8Array
|
|
603
|
+
? memoryOrSnapshot
|
|
604
|
+
: new Uint8Array(memoryOrSnapshot.buffer);
|
|
605
|
+
|
|
606
|
+
const byteLength = snapshot.byteLength;
|
|
607
|
+
|
|
608
|
+
// Write snapshot binary - fileSystem handles any necessary copying internally
|
|
609
|
+
const fs = await getFileSystem();
|
|
610
|
+
if (!fs) return false;
|
|
611
|
+
await fs.writeBinary(MEMORY_SNAPSHOT_PATH, snapshot, { createParents: true, silent: true });
|
|
612
|
+
|
|
613
|
+
// Write metadata as JSON (small, no optimization needed)
|
|
614
|
+
const metaData = {
|
|
615
|
+
byteLength,
|
|
616
|
+
metadata,
|
|
617
|
+
timestamp: Date.now(),
|
|
618
|
+
version: MEMORY_SNAPSHOT_VERSION,
|
|
619
|
+
};
|
|
620
|
+
await fs.writeFile(MEMORY_SNAPSHOT_META_PATH, JSON.stringify(metaData), { silent: true });
|
|
621
|
+
|
|
622
|
+
console.log(`Saved WASM memory snapshot (${(byteLength / 1024 / 1024).toFixed(1)}MB)`);
|
|
623
|
+
return true;
|
|
624
|
+
} catch (e) {
|
|
625
|
+
console.warn('Failed to save memory snapshot:', e);
|
|
626
|
+
return false;
|
|
627
|
+
} finally {
|
|
628
|
+
snapshotSaveInProgress = false;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Current CTAN cache version. Bump to invalidate cached packages.
|
|
634
|
+
* @type {number}
|
|
635
|
+
*/
|
|
636
|
+
export { CTAN_CACHE_VERSION };
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Current manifest cache version. Bump to invalidate cached manifests.
|
|
640
|
+
* @type {number}
|
|
641
|
+
*/
|
|
642
|
+
export { MANIFEST_CACHE_VERSION };
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Utility functions for siglum-engine
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a batched logger that collects messages and flushes them once per animation frame.
|
|
5
|
+
* This prevents DOM thrashing when the compiler emits many log messages rapidly.
|
|
6
|
+
*
|
|
7
|
+
* @param {function(string[]): void} onFlush - Called with array of messages to display
|
|
8
|
+
* @returns {function(string): void} - Logger function to pass to SiglumCompiler's onLog option
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const compiler = new SiglumCompiler({
|
|
12
|
+
* onLog: createBatchedLogger((messages) => {
|
|
13
|
+
* statusDiv.textContent += messages.join('\n') + '\n';
|
|
14
|
+
* statusDiv.scrollTop = statusDiv.scrollHeight;
|
|
15
|
+
* }),
|
|
16
|
+
* });
|
|
17
|
+
*/
|
|
18
|
+
export function createBatchedLogger(onFlush) {
|
|
19
|
+
let buffer = [];
|
|
20
|
+
let flushScheduled = false;
|
|
21
|
+
|
|
22
|
+
return function log(msg) {
|
|
23
|
+
buffer.push(msg);
|
|
24
|
+
if (!flushScheduled) {
|
|
25
|
+
flushScheduled = true;
|
|
26
|
+
requestAnimationFrame(() => {
|
|
27
|
+
onFlush(buffer);
|
|
28
|
+
buffer = [];
|
|
29
|
+
flushScheduled = false;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|