-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathopfsStore.ts
More file actions
242 lines (221 loc) · 7.05 KB
/
Copy pathopfsStore.ts
File metadata and controls
242 lines (221 loc) · 7.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
/**
* OPFS-backed byte store with LRU eviction — generic SDK primitive.
*
* Gene: `sdk.media.gen1` · sibling of `frontend.social.messenger.opfs.gen1`.
*
* Why this lives in the SDK (not per-app):
* The messenger already proved OPFS + LRU as durable cytoplasmic storage for
* large attachments. Every other surface (storage explorer, dashboard file
* preview, widget sandbox) wants the same thing. Lifting the primitive into
* the SDK removes duplication and lets third-party integrators reuse it.
*
* Design invariants:
* - **Tenant-isolated**: every call takes a `namespace` (e.g. `'messenger'`,
* `'storage-thumbs'`) so one surface can't starve another's cache.
* - **LRU per namespace**: each namespace owns its own `.lru.json`.
* - **Browser-only**: feature-detects `navigator.storage.getDirectory` and
* throws a named `OpfsUnavailableError` on non-browser / older Safari.
* - **Zero deps**: raw `FileSystemDirectoryHandle` API.
*
* Typical usage:
*
* const cache = createOpfsStore({
* namespace: 'storage-thumbs',
* capBytes: 256 * 1024 * 1024,
* });
* let blob = await cache.get(keyHash);
* if (!blob) {
* blob = await generateThumbnail(source);
* await cache.put(keyHash, blob);
* }
*
* Keys are treated as opaque strings but we normalize to a safe filename —
* callers usually pass a SHA-256 of the source bytes.
*/
const SAFE_KEY_RE = /[^A-Za-z0-9._-]+/g;
const LRU_FILENAME = '.lru.json';
export class OpfsUnavailableError extends Error {
constructor(msg = 'OPFS is unavailable in this runtime') {
super(msg);
this.name = 'OpfsUnavailableError';
}
}
export interface OpfsStoreOptions {
/** Directory name under OPFS root. Must be filesystem-safe. */
namespace: string;
/** Hard byte cap for this namespace; oldest entries evicted first. Default 128 MB. */
capBytes?: number;
/** Override for tests. */
root?: () => Promise<FileSystemDirectoryHandle>;
}
export interface OpfsStore {
readonly namespace: string;
has(key: string): Promise<boolean>;
get(key: string): Promise<Blob | null>;
/** Insert/overwrite, updates LRU, may trigger eviction. */
put(key: string, blob: Blob): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
usageBytes(): Promise<number>;
}
interface LruRecord {
key: string;
size: number;
lastAt: number;
}
async function defaultRoot(): Promise<FileSystemDirectoryHandle> {
const nav =
typeof navigator !== 'undefined' ? navigator : (undefined as Navigator | undefined);
const getDir = nav?.storage?.getDirectory?.bind(nav.storage);
if (!getDir) {
throw new OpfsUnavailableError();
}
return getDir();
}
function safeKey(key: string): string {
const s = String(key ?? '').trim();
if (!s) throw new Error('opfs_empty_key');
return s.replace(SAFE_KEY_RE, '_').slice(0, 200);
}
async function ensureDir(
root: FileSystemDirectoryHandle,
name: string
): Promise<FileSystemDirectoryHandle> {
return root.getDirectoryHandle(name, { create: true });
}
export function isOpfsAvailable(): boolean {
try {
return (
typeof navigator !== 'undefined' &&
!!navigator.storage &&
typeof navigator.storage.getDirectory === 'function'
);
} catch {
return false;
}
}
export function createOpfsStore(opts: OpfsStoreOptions): OpfsStore {
const namespace = String(opts.namespace || '').replace(SAFE_KEY_RE, '_');
if (!namespace) throw new Error('opfs_namespace_required');
const cap = Math.max(1, opts.capBytes ?? 128 * 1024 * 1024);
const rootFn = opts.root ?? defaultRoot;
async function dir(): Promise<FileSystemDirectoryHandle> {
const root = await rootFn();
return ensureDir(root, namespace);
}
async function readLru(): Promise<LruRecord[]> {
try {
const d = await dir();
const fh = await d.getFileHandle(LRU_FILENAME, { create: false });
const file = await fh.getFile();
const parsed = JSON.parse(await file.text()) as unknown;
return Array.isArray(parsed) ? (parsed as LruRecord[]) : [];
} catch {
return [];
}
}
async function writeLru(records: LruRecord[]): Promise<void> {
const d = await dir();
const fh = await d.getFileHandle(LRU_FILENAME, { create: true });
const w = await fh.createWritable({ keepExistingData: false });
await w.write(new Blob([JSON.stringify(records)]));
await w.close();
}
async function evictIfOverCap(): Promise<void> {
const records = await readLru();
let total = records.reduce((s, r) => s + r.size, 0);
if (total <= cap) return;
records.sort((a, b) => a.lastAt - b.lastAt);
const d = await dir();
while (total > cap && records.length > 0) {
const rec = records.shift()!;
try {
await d.removeEntry(rec.key);
} catch {
/* already gone */
}
total -= rec.size;
}
await writeLru(records);
}
return {
namespace,
async has(key) {
try {
const d = await dir();
await d.getFileHandle(safeKey(key), { create: false });
return true;
} catch {
return false;
}
},
async get(key) {
try {
const d = await dir();
const fh = await d.getFileHandle(safeKey(key), { create: false });
const file = await fh.getFile();
// touch LRU without blocking the read
void (async () => {
const records = await readLru();
const rec = records.find((r) => r.key === safeKey(key));
if (rec) {
rec.lastAt = Date.now();
await writeLru(records).catch(() => {
/* best-effort */
});
}
})();
return file;
} catch {
return null;
}
},
async put(key, blob) {
const sk = safeKey(key);
const d = await dir();
const fh = await d.getFileHandle(sk, { create: true });
const w = await fh.createWritable({ keepExistingData: false });
try {
await blob.stream().pipeTo(w as unknown as WritableStream);
} catch (err) {
try {
await (w as unknown as { abort?: (reason?: unknown) => Promise<void> }).abort?.(
String(err)
);
} catch {
/* best-effort */
}
throw err;
}
const records = (await readLru()).filter((r) => r.key !== sk);
records.push({ key: sk, size: blob.size, lastAt: Date.now() });
await writeLru(records);
await evictIfOverCap();
},
async delete(key) {
const sk = safeKey(key);
try {
const d = await dir();
await d.removeEntry(sk);
} catch {
/* already gone */
}
const records = (await readLru()).filter((r) => r.key !== sk);
await writeLru(records).catch(() => {
/* best-effort */
});
},
async clear() {
try {
const root = await rootFn();
await root.removeEntry(namespace, { recursive: true });
} catch {
/* already gone */
}
},
async usageBytes() {
const records = await readLru();
return records.reduce((s, r) => s + r.size, 0);
},
};
}