ente/web/packages/next/blob-cache.ts

234 lines
8.2 KiB
TypeScript
Raw Normal View History

2024-04-13 01:17:43 +00:00
import isElectron from "is-electron";
2024-04-13 02:34:43 +00:00
const blobCacheNames = [
2024-04-12 09:11:33 +00:00
"thumbs",
"face-crops",
2024-04-11 15:24:53 +00:00
// Desktop app only
2024-04-12 09:11:33 +00:00
"files",
] as const;
2024-04-12 14:54:48 +00:00
/**
2024-04-13 02:34:43 +00:00
* Namespaces into which our blob caches are divided
2024-04-12 14:54:48 +00:00
*
* Note that namespaces are just arbitrary (but predefined) strings to split the
* cached data into "folders", so to speak.
* */
2024-04-13 02:34:43 +00:00
export type BlobCacheNamespace = (typeof blobCacheNames)[number];
2024-04-11 15:24:53 +00:00
2024-04-12 14:40:14 +00:00
/**
2024-04-13 02:34:43 +00:00
* A namespaced blob cache.
2024-04-12 14:40:14 +00:00
*
2024-04-12 14:54:48 +00:00
* This cache is suitable for storing large amounts of data (entire files).
*
2024-04-12 14:40:14 +00:00
* To obtain a cache for a given namespace, use {@link openCache}. To clear all
* cached data (e.g. during logout), use {@link clearCaches}.
*
2024-04-12 14:54:48 +00:00
* [Note: Caching files]
*
2024-04-12 14:40:14 +00:00
* The underlying implementation of the cache is different depending on the
* runtime environment.
*
* * The preferred implementation, and the one that is used when we're running
* in a browser, is to use the standard [Web
* Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache).
*
* * However when running under Electron (when this code runs as part of our
* desktop app), a custom OPFS based cache is used instead. This is because
* Electron currently doesn't support using standard Web Cache API for data
* served by a custom protocol handler (See this
* [issue](https://github.com/electron/electron/issues/35033), and the
* underlying restriction that comes from
* [Chromium](https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/cache_storage/cache.cc;l=83-87?q=%22Request%20scheme%20%27%22&ss=chromium))
*
* [OPFS](https://web.dev/articles/origin-private-file-system) stands for Origin
* Private File System. It is a recent API that allows a web site to store
* reasonably large amounts of data. One option (that may still become possible
* in the future) was to always use OPFS for caching instead of this dual
* implementation, however currently [Safari does not support writing to OPFS
* outside of web
* workers](https://webkit.org/blog/12257/the-file-system-access-api-with-origin-private-file-system/)
* ([the WebKit bug](https://bugs.webkit.org/show_bug.cgi?id=231706)), so it's
* not trivial to use this as a full on replacement of the Web Cache in the
* browser. So for now we go with this split implementation.
2024-04-12 14:54:48 +00:00
*
* See also: [Note: Increased disk cache for the desktop app].
2024-04-12 14:40:14 +00:00
*/
2024-04-13 02:34:43 +00:00
export interface BlobCache {
2024-04-12 14:54:48 +00:00
/**
* Get the data corresponding to {@link key} (if found) from the cache.
*/
2024-04-13 02:34:43 +00:00
get: (key: string) => Promise<Blob | undefined>;
2024-04-13 01:49:11 +00:00
match: (key: string) => Promise<Response | undefined>;
2024-04-12 14:54:48 +00:00
/**
* Add the given {@link key}-value ({@link data}) pair to the cache.
*/
2024-04-11 15:24:53 +00:00
put: (key: string, data: Response) => Promise<void>;
2024-04-12 14:54:48 +00:00
/**
* Delete the data corresponding to the given {@link key}.
*
* The returned promise resolves to `true` if a cache entry was found,
* otherwise it resolves to `false`.
* */
2024-04-11 15:24:53 +00:00
delete: (key: string) => Promise<boolean>;
}
2024-04-12 14:54:48 +00:00
/**
2024-04-13 02:34:43 +00:00
* Return the {@link BlobCache} corresponding to the given {@link name}.
2024-04-12 14:54:48 +00:00
*
* @param name One of the arbitrary but predefined namespaces of type
2024-04-13 02:34:43 +00:00
* {@link BlobCacheNamespace} which group related data and allow us to use the
* same key across namespaces.
2024-04-12 14:54:48 +00:00
*/
2024-04-13 02:34:43 +00:00
export const openCache = async (
name: BlobCacheNamespace,
): Promise<BlobCache> =>
2024-04-13 01:17:43 +00:00
isElectron() ? openOPFSCacheWeb(name) : openWebCache(name);
2024-04-12 15:22:33 +00:00
2024-04-13 02:12:24 +00:00
/**
* [Note: ArrayBuffer vs Blob vs Uint8Array]
*
* ArrayBuffers are in memory, while blobs are unreified, and can directly point
* to on disk objects too.
*
* If we are just passing data around without necessarily needing to manipulate
* it, and we already have a blob, it's best to just pass that blob. Further,
* blobs also retains the file's encoding information , and are thus a layer
* above array buffers which are just raw byte sequences.
*
* ArrayBuffers are not directly manipulatable, which is where some sort of a
* typed array or a data view comes into the picture. The typed `Uint8Array` is
* a common way.
*
* To convert from ArrayBuffer to Uint8Array,
*
* new Uint8Array(arrayBuffer)
*
* Blobs are immutable, but a usual scenario is storing an entire file in a
* blob, and when the need comes to display it, we can obtain a URL for it using
*
* URL.createObjectURL(blob)
*
2024-04-13 02:42:38 +00:00
* Also note that a File is a Blob!
*
2024-04-13 02:48:11 +00:00
* To convert from a Blob to ArrayBuffer
*
* await blob.arrayBuffer()
*
2024-04-13 02:12:24 +00:00
* Refs:
* - https://github.com/yigitunallar/arraybuffer-vs-blob
* - https://stackoverflow.com/questions/11821096/what-is-the-difference-between-an-arraybuffer-and-a-blob
*/
2024-04-13 02:34:43 +00:00
/** An implementation of {@link BlobCache} using Web Cache APIs */
const openWebCache = async (name: BlobCacheNamespace) => {
2024-04-12 15:22:33 +00:00
const cache = await caches.open(name);
return {
2024-04-13 01:49:11 +00:00
get: async (key: string) => {
2024-04-13 02:42:38 +00:00
const res = await cache.match(key);
return await res?.blob();
2024-04-13 01:49:11 +00:00
},
2024-04-12 15:24:48 +00:00
match: (key: string) => {
2024-04-12 15:22:33 +00:00
return cache.match(key);
},
put: (key: string, data: Response) => {
return cache.put(key, data);
},
delete: (key: string) => {
return cache.delete(key);
},
};
};
2024-04-13 02:34:43 +00:00
/** An implementation of {@link BlobCache} using OPFS */
const openOPFSCacheWeb = async (name: BlobCacheNamespace) => {
2024-04-12 15:42:37 +00:00
// While all major browsers support OPFS now, their implementations still
// have various quirks. However, we don't need to handle all possible cases
// and can just instead use the APIs and guarantees Chromium provides since
// this code will only run in our Electron app (which'll use Chromium as the
// renderer).
//
2024-04-13 01:17:43 +00:00
// So for our purpose, these can serve as the doc for what's available:
2024-04-12 15:42:37 +00:00
// https://web.dev/articles/origin-private-file-system
2024-04-12 09:15:03 +00:00
const cache = await caches.open(name);
2024-04-12 15:42:37 +00:00
const root = await navigator.storage.getDirectory();
const _caches = await root.getDirectoryHandle("cache", { create: true });
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _cache = await _caches.getDirectoryHandle(name, { create: true });
2024-04-12 09:15:03 +00:00
return {
2024-04-13 01:49:11 +00:00
get: async (key: string) => {
2024-04-13 02:42:38 +00:00
try {
const fileHandle = await _cache.getFileHandle(key);
return await fileHandle.getFile();
} catch (e) {
if (e instanceof DOMException && e.name == "NotFoundError")
return undefined;
throw e;
}
2024-04-13 01:49:11 +00:00
},
2024-04-12 15:24:48 +00:00
match: (key: string) => {
2024-04-12 09:15:03 +00:00
return cache.match(key);
},
2024-04-12 15:53:22 +00:00
put: async (key: string, data: Response) => {
// const fileHandle = await _cache.getFileHandle(key, { create: true })
// await fileHandle.write(data);
// await fileHandle.close();
await cache.put(key, data);
2024-04-12 14:54:48 +00:00
},
delete: (key: string) => {
2024-04-12 15:53:22 +00:00
// try {
// await _cache.removeEntry(key);
// return true;
// } catch (e) {
// if (e instanceof DOMException && e.name == "NotFoundError")
// return false;
// throw e;
// }
2024-04-12 14:54:48 +00:00
return cache.delete(key);
},
2024-04-12 09:15:03 +00:00
};
};
2024-04-11 15:24:53 +00:00
export async function cached(
2024-04-13 02:34:43 +00:00
cacheName: BlobCacheNamespace,
2024-04-11 15:24:53 +00:00
id: string,
get: () => Promise<Blob>,
): Promise<Blob> {
2024-04-12 15:28:00 +00:00
const cache = await openCache(cacheName);
2024-04-11 15:24:53 +00:00
const cacheResponse = await cache.match(id);
let result: Blob;
if (cacheResponse) {
result = await cacheResponse.blob();
} else {
result = await get();
try {
await cache.put(id, new Response(result));
} catch (e) {
// TODO: handle storage full exception.
console.error("Error while storing file to cache: ", id);
}
}
return result;
}
/**
* Delete all cached data.
*
* Meant for use during logout, to reset the state of the user's account.
*/
2024-04-12 15:42:37 +00:00
export const clearCaches = async () =>
2024-04-13 01:17:43 +00:00
isElectron() ? clearOPFSCaches() : clearWebCaches();
2024-04-12 15:42:37 +00:00
export const clearWebCaches = async () => {
2024-04-13 02:34:43 +00:00
await Promise.all(blobCacheNames.map((name) => caches.delete(name)));
2024-04-11 15:24:53 +00:00
};
2024-04-12 15:42:37 +00:00
export const clearOPFSCaches = async () => {
const root = await navigator.storage.getDirectory();
await root.removeEntry("cache", { recursive: true });
};