@agorapete/wllama 3.5.1-q2.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 (86) hide show
  1. package/.gitmodules +3 -0
  2. package/.prettierignore +38 -0
  3. package/AGENTS.md +1 -0
  4. package/CMakeLists.txt +131 -0
  5. package/LICENCE +21 -0
  6. package/README-dev.md +178 -0
  7. package/README.md +225 -0
  8. package/README_banner.png +0 -0
  9. package/assets/screenshot_0.png +0 -0
  10. package/cpp/generate_glue_prototype.js +115 -0
  11. package/cpp/glue.hpp +664 -0
  12. package/cpp/test_glue.cpp +80 -0
  13. package/cpp/wllama-context.h +1172 -0
  14. package/cpp/wllama-fs.h +148 -0
  15. package/cpp/wllama.cpp +187 -0
  16. package/cpp/wllama.h +6 -0
  17. package/esm/cache-manager.d.ts +130 -0
  18. package/esm/debug.d.ts +28 -0
  19. package/esm/glue/glue.d.ts +22 -0
  20. package/esm/glue/messages.d.ts +146 -0
  21. package/esm/huggingface.d.ts +31 -0
  22. package/esm/index.cjs +3406 -0
  23. package/esm/index.d.ts +8 -0
  24. package/esm/index.js +3387 -0
  25. package/esm/index.min.js +1 -0
  26. package/esm/index.min.js.map +1 -0
  27. package/esm/model-manager.d.ts +136 -0
  28. package/esm/storage/cos.d.ts +36 -0
  29. package/esm/storage/index.d.ts +33 -0
  30. package/esm/storage/opfs.d.ts +12 -0
  31. package/esm/types/oai-compat.d.ts +278 -0
  32. package/esm/types/types.d.ts +112 -0
  33. package/esm/utils.d.ts +119 -0
  34. package/esm/wasm/source-map.d.ts +1 -0
  35. package/esm/wasm/wllama.wasm +0 -0
  36. package/esm/wasm-from-cdn.d.ts +8 -0
  37. package/esm/wllama.d.ts +397 -0
  38. package/esm/worker.d.ts +92 -0
  39. package/esm/workers-code/generated.d.ts +4 -0
  40. package/guides/intro-v2.md +132 -0
  41. package/guides/intro-v3.1.md +40 -0
  42. package/guides/intro-v3.md +230 -0
  43. package/index.ts +1 -0
  44. package/package.json +71 -0
  45. package/scripts/bisect_test.sh +33 -0
  46. package/scripts/build_hf_space.sh +26 -0
  47. package/scripts/build_source_map.js +269 -0
  48. package/scripts/build_wasm.sh +19 -0
  49. package/scripts/build_worker.sh +38 -0
  50. package/scripts/check_debug_build.js +30 -0
  51. package/scripts/check_package_size.js +25 -0
  52. package/scripts/docker-compose.yml +76 -0
  53. package/scripts/generate_wasm_from_cdn.js +24 -0
  54. package/scripts/http_server.js +44 -0
  55. package/scripts/post_build.sh +32 -0
  56. package/src/cache-manager.ts +358 -0
  57. package/src/debug.ts +111 -0
  58. package/src/glue/glue.ts +291 -0
  59. package/src/glue/messages.ts +773 -0
  60. package/src/huggingface.ts +151 -0
  61. package/src/index.ts +8 -0
  62. package/src/mjs.test.ts +44 -0
  63. package/src/model-manager.test.ts +200 -0
  64. package/src/model-manager.ts +359 -0
  65. package/src/storage/cos.test.ts +83 -0
  66. package/src/storage/cos.ts +171 -0
  67. package/src/storage/index.ts +40 -0
  68. package/src/storage/opfs.ts +119 -0
  69. package/src/types/oai-compat.ts +342 -0
  70. package/src/types/types.ts +133 -0
  71. package/src/utils.test.ts +231 -0
  72. package/src/utils.ts +403 -0
  73. package/src/wasm/source-map.ts +7 -0
  74. package/src/wasm/wllama.js +1 -0
  75. package/src/wasm/wllama.wasm +0 -0
  76. package/src/wasm-from-cdn.ts +13 -0
  77. package/src/wllama.test.ts +392 -0
  78. package/src/wllama.ts +1138 -0
  79. package/src/wllama.wgpu.test.ts +62 -0
  80. package/src/worker.ts +443 -0
  81. package/src/workers-code/generated.ts +11 -0
  82. package/src/workers-code/llama-cpp.js +511 -0
  83. package/src/workers-code/opfs-utils.js +150 -0
  84. package/tsconfig.build.json +34 -0
  85. package/tsup.config.ts +23 -0
  86. package/vitest.config.ts +61 -0
@@ -0,0 +1,359 @@
1
+ import CacheManager, {
2
+ type CacheEntryMetadata,
3
+ type CacheEntry,
4
+ type DownloadOptions,
5
+ } from './cache-manager';
6
+ import { isString, isValidGgufFile, sumArr } from './utils';
7
+ import { WllamaError, type WllamaLogger } from './wllama';
8
+
9
+ const DEFAULT_PARALLEL_DOWNLOADS = 3;
10
+
11
+ export interface ModelSource {
12
+ /**
13
+ * URL to the GGUF file. If the model is splitted, pass the URL to the first shard. Mmproj file should also be provided via this array if you want to use multimodal.
14
+ */
15
+ url: string;
16
+ mmprojUrl?: string;
17
+ }
18
+
19
+ /**
20
+ * Callback function to track download progress
21
+ */
22
+ export type DownloadProgressCallback = (opts: {
23
+ /**
24
+ * Number of bytes loaded (sum of all shards)
25
+ */
26
+ loaded: number;
27
+ /**
28
+ * Total number of bytes (sum of all shards)
29
+ */
30
+ total: number;
31
+ }) => any;
32
+
33
+ /**
34
+ * Status of the model validation
35
+ */
36
+ export enum ModelValidationStatus {
37
+ VALID = 'valid',
38
+ INVALID = 'invalid',
39
+ DELETED = 'deleted',
40
+ }
41
+
42
+ /**
43
+ * Parameters for ModelManager constructor
44
+ */
45
+ export interface ModelManagerParams {
46
+ cacheManager?: CacheManager;
47
+ logger?: WllamaLogger;
48
+ /**
49
+ * Number of parallel downloads
50
+ *
51
+ * Default: 3
52
+ */
53
+ parallelDownloads?: number | undefined;
54
+ /**
55
+ * Allow offline mode
56
+ *
57
+ * Default: false
58
+ */
59
+ allowOffline?: boolean | undefined;
60
+ }
61
+
62
+ /**
63
+ * Model class
64
+ *
65
+ * One model can have multiple shards, each shard is a GGUF file.
66
+ */
67
+ export class Model {
68
+ private modelManager: ModelManager;
69
+ constructor(
70
+ modelManager: ModelManager,
71
+ url: string,
72
+ mmprojUrl?: string,
73
+ savedFiles?: CacheEntry[]
74
+ ) {
75
+ this.modelManager = modelManager;
76
+ this.url = url;
77
+ this.mmprojUrl = mmprojUrl;
78
+ if (savedFiles) {
79
+ // this file is already in cache
80
+ this.files = this.getAllFiles(savedFiles);
81
+ this.size = sumArr(this.files.map((f) => f.metadata.originalSize));
82
+ } else {
83
+ // this file is not in cache, we are about to download it
84
+ this.files = [];
85
+ this.size = 0;
86
+ }
87
+ }
88
+ /**
89
+ * URL to the GGUF file (in case it contains multiple shards, the URL should point to the first shard)
90
+ *
91
+ * This URL will be used to identify the model in the cache. There can't be 2 models with the same URL.
92
+ */
93
+ url: string;
94
+ /**
95
+ * URL to mmproj file, if exists
96
+ */
97
+ mmprojUrl?: string | undefined;
98
+ /**
99
+ * Size in bytes (total size of all shards).
100
+ *
101
+ * A value of -1 means the model is deleted from the cache. You must call `ModelManager.downloadModel` to re-download the model.
102
+ */
103
+ size: number;
104
+ /**
105
+ * List of all shards in the cache, sorted by original URL (ascending order)
106
+ */
107
+ files: CacheEntry[];
108
+ /**
109
+ * Open and get a list of all shards as Blobs
110
+ */
111
+ async open(): Promise<Blob[]> {
112
+ if (this.size === -1) {
113
+ throw new WllamaError(
114
+ `Model is deleted from the cache; Call ModelManager.downloadModel to re-download the model`,
115
+ 'load_error'
116
+ );
117
+ }
118
+ const blobs: Blob[] = [];
119
+ for (const file of this.files) {
120
+ const blob = await this.modelManager.cacheManager.open(file.name);
121
+ if (!blob) {
122
+ throw new Error(
123
+ `Failed to open file ${file.name}; Hint: the model may be invalid, please refresh it`
124
+ );
125
+ }
126
+ blobs.push(blob);
127
+ }
128
+ return blobs;
129
+ }
130
+ /**
131
+ * Validate the model files.
132
+ *
133
+ * If the model is invalid, the model manager will not be able to use it. You must call `refresh` to re-download the model.
134
+ *
135
+ * Cases that model is invalid:
136
+ * - The model is deleted from the cache
137
+ * - The model files are missing (or the download is interrupted)
138
+ */
139
+ validate(): ModelValidationStatus {
140
+ let nbShards = ModelManager.parseModelUrl(this.url).length;
141
+ if (this.mmprojUrl) {
142
+ nbShards += 1;
143
+ }
144
+ if (this.size === -1) {
145
+ return ModelValidationStatus.DELETED;
146
+ }
147
+ if (this.size < 16 || this.files.length !== nbShards) {
148
+ return ModelValidationStatus.INVALID;
149
+ }
150
+ for (const file of this.files) {
151
+ if (!file.metadata || file.metadata.originalSize !== file.size) {
152
+ return ModelValidationStatus.INVALID;
153
+ }
154
+ }
155
+ return ModelValidationStatus.VALID;
156
+ }
157
+ /**
158
+ * In case the model is invalid, call this function to re-download the model
159
+ */
160
+ async refresh(options: DownloadOptions = {}): Promise<void> {
161
+ const urls = ModelManager.parseModelUrl(this.url);
162
+ if (this.mmprojUrl) {
163
+ urls.push(this.mmprojUrl);
164
+ }
165
+ const works = urls.map((url, index) => ({
166
+ url,
167
+ index,
168
+ }));
169
+ this.modelManager.logger.debug('Downloading model files:', urls);
170
+ const nParallel =
171
+ this.modelManager.params.parallelDownloads ?? DEFAULT_PARALLEL_DOWNLOADS;
172
+ const totalSize = await this.getTotalDownloadSize(urls);
173
+ const loadedSize: number[] = [];
174
+ const worker = async () => {
175
+ while (works.length > 0) {
176
+ const w = works.shift();
177
+ if (!w) break;
178
+ await this.modelManager.cacheManager.download(w.url, {
179
+ ...options,
180
+ metadataAdditional: {
181
+ originalURL: w.url,
182
+ mmprojURL: this.mmprojUrl,
183
+ } satisfies Partial<CacheEntryMetadata>,
184
+ progressCallback: ({ loaded }) => {
185
+ loadedSize[w.index] = loaded;
186
+ options.progressCallback?.({
187
+ loaded: sumArr(loadedSize),
188
+ total: totalSize,
189
+ });
190
+ },
191
+ });
192
+ }
193
+ };
194
+ const promises: Promise<void>[] = [];
195
+ for (let i = 0; i < nParallel; i++) {
196
+ promises.push(worker());
197
+ loadedSize.push(0);
198
+ }
199
+ await Promise.all(promises);
200
+ this.files = this.getAllFiles(await this.modelManager.cacheManager.list());
201
+ this.size = this.files.reduce((acc, f) => acc + f.metadata.originalSize, 0);
202
+ }
203
+ /**
204
+ * Remove the model from the cache
205
+ */
206
+ async remove(): Promise<void> {
207
+ this.files = this.getAllFiles(await this.modelManager.cacheManager.list());
208
+ await this.modelManager.cacheManager.deleteMany(
209
+ (f) => !!this.files.find((file) => file.name === f.name)
210
+ );
211
+ this.size = -1;
212
+ }
213
+
214
+ private getAllFiles(savedFiles: CacheEntry[]): CacheEntry[] {
215
+ const allUrls = new Set(ModelManager.parseModelUrl(this.url));
216
+ if (this.mmprojUrl) {
217
+ allUrls.add(this.mmprojUrl);
218
+ }
219
+ // console.log({allUrls, savedFiles});
220
+ const allFiles: CacheEntry[] = [];
221
+ for (const url of allUrls) {
222
+ const file = savedFiles.find((f) => f.metadata.originalURL === url);
223
+ if (!file) {
224
+ throw new Error(`Model file not found: ${url}`);
225
+ }
226
+ allFiles.push(file);
227
+ }
228
+ allFiles.sort((a, b) =>
229
+ a.metadata.originalURL.localeCompare(b.metadata.originalURL)
230
+ );
231
+ return allFiles;
232
+ }
233
+
234
+ private async getTotalDownloadSize(urls: string[]): Promise<number> {
235
+ const responses = await Promise.all(
236
+ urls.map((url) => fetch(url, { method: 'HEAD' }))
237
+ );
238
+ const sizes = responses.map((res) =>
239
+ Number(res.headers.get('content-length') || '0')
240
+ );
241
+ return sumArr(sizes);
242
+ }
243
+ }
244
+
245
+ export class ModelManager {
246
+ // The CacheManager singleton, can be accessed by user
247
+ public cacheManager: CacheManager;
248
+
249
+ public params: ModelManagerParams;
250
+ public logger: WllamaLogger;
251
+
252
+ constructor(params: ModelManagerParams = {}) {
253
+ this.cacheManager = params.cacheManager || new CacheManager();
254
+ this.params = params;
255
+ this.logger = params.logger || console;
256
+ }
257
+
258
+ /**
259
+ * Parses a model URL and returns an array of URLs based on the following patterns:
260
+ * - If the input URL is an array, it returns the array itself.
261
+ * - If the input URL is a string in the `gguf-split` format, it returns an array containing the URL of each shard in ascending order.
262
+ * - Otherwise, it returns an array containing the input URL as a single element array.
263
+ * @param modelUrl URL or list of URLs
264
+ */
265
+ static parseModelUrl(modelUrl: string | string[]): string[] {
266
+ if (Array.isArray(modelUrl)) {
267
+ return modelUrl;
268
+ }
269
+ const urlPartsRegex = /-(\d{5})-of-(\d{5})\.gguf(?:\?.*)?$/;
270
+ const queryMatch = modelUrl.match(/\.gguf(\?.*)?$/);
271
+ const queryParams = queryMatch?.[1] ?? '';
272
+ const matches = modelUrl.match(urlPartsRegex);
273
+ if (!matches) {
274
+ return [modelUrl];
275
+ }
276
+ const baseURL = modelUrl.replace(urlPartsRegex, '');
277
+ const total = matches[2];
278
+ const paddedShardIds = Array.from({ length: Number(total) }, (_, index) =>
279
+ (index + 1).toString().padStart(5, '0')
280
+ );
281
+ return paddedShardIds.map(
282
+ (current) => `${baseURL}-${current}-of-${total}.gguf${queryParams}`
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Get all models in the cache
288
+ */
289
+ async getModels(opts: { includeInvalid?: boolean } = {}): Promise<Model[]> {
290
+ const cachedFiles = await this.cacheManager.list();
291
+ let models: Model[] = [];
292
+ for (const file of cachedFiles) {
293
+ const shards = ModelManager.parseModelUrl(file.metadata.originalURL);
294
+ const mmprojUrl = file.metadata.mmprojURL;
295
+ const isFirstShard =
296
+ shards.length === 1 || shards[0] === file.metadata.originalURL;
297
+ if (isFirstShard) {
298
+ models.push(
299
+ new Model(this, file.metadata.originalURL, mmprojUrl, cachedFiles)
300
+ );
301
+ }
302
+ }
303
+ if (!opts.includeInvalid) {
304
+ models = models.filter(
305
+ (m) => m.validate() === ModelValidationStatus.VALID
306
+ );
307
+ }
308
+ return models;
309
+ }
310
+
311
+ /**
312
+ * Download a model from the given URL.
313
+ *
314
+ * The URL must end with `.gguf`
315
+ */
316
+ async downloadModel(
317
+ sourceOrURL: ModelSource | string,
318
+ options: DownloadOptions = {}
319
+ ): Promise<Model> {
320
+ const source: ModelSource = isString(sourceOrURL)
321
+ ? { url: sourceOrURL as string }
322
+ : (sourceOrURL as ModelSource);
323
+ if (!isValidGgufFile(source.url)) {
324
+ throw new WllamaError(
325
+ `Invalid model URL: ${source.url}; URL must ends with ".gguf"`,
326
+ 'download_error'
327
+ );
328
+ }
329
+ const model = new Model(this, source.url, source.mmprojUrl);
330
+ const validity = model.validate();
331
+ if (validity !== ModelValidationStatus.VALID) {
332
+ await model.refresh(options);
333
+ }
334
+ return model;
335
+ }
336
+
337
+ /**
338
+ * Get a model from the cache or download it if it's not available.
339
+ */
340
+ async getModelOrDownload(
341
+ source: ModelSource,
342
+ options: DownloadOptions = {}
343
+ ): Promise<Model> {
344
+ const models = await this.getModels();
345
+ const model = models.find((m) => m.url === source.url);
346
+ if (model) {
347
+ options.progressCallback?.({ loaded: model.size, total: model.size });
348
+ return model;
349
+ }
350
+ return this.downloadModel(source, options);
351
+ }
352
+
353
+ /**
354
+ * Remove all models from the cache
355
+ */
356
+ async clear(): Promise<void> {
357
+ await this.cacheManager.clear();
358
+ }
359
+ }
@@ -0,0 +1,83 @@
1
+ import { test, expect, beforeEach } from 'vitest';
2
+ import { COSBackend, mockCOS } from './cos';
3
+
4
+ async function randomBufAndHash(): Promise<{
5
+ buf: Uint8Array;
6
+ sha256: string;
7
+ }> {
8
+ const buf = crypto.getRandomValues(new Uint8Array(256));
9
+ const hashBuf = await crypto.subtle.digest('SHA-256', buf);
10
+ const sha256 = Array.from(new Uint8Array(hashBuf))
11
+ .map((b) => b.toString(16).padStart(2, '0'))
12
+ .join('');
13
+ return { buf, sha256 };
14
+ }
15
+
16
+ function bufStream(buf: Uint8Array): ReadableStream<Uint8Array> {
17
+ return new ReadableStream({
18
+ start(controller) {
19
+ controller.enqueue(buf.slice());
20
+ controller.close();
21
+ },
22
+ });
23
+ }
24
+
25
+ test.sequential('write then read without hint falls back to OPFS', async () => {
26
+ const backend = new COSBackend();
27
+ const { buf } = await randomBufAndHash();
28
+ const key = 'test-no-hint';
29
+
30
+ await backend.write(key, bufStream(buf));
31
+ const blob = await backend.read(key);
32
+ expect(blob).not.toBeNull();
33
+ expect(new Uint8Array(await blob!.arrayBuffer())).toEqual(buf);
34
+
35
+ await backend.delete(key);
36
+ });
37
+
38
+ beforeEach(() => {
39
+ mockCOS();
40
+ });
41
+
42
+ test.sequential('write with hint stores in COS only', async () => {
43
+ const backend = new COSBackend();
44
+ expect(backend.isSupported()).toBe(true);
45
+
46
+ const { buf, sha256 } = await randomBufAndHash();
47
+ const hint = { sha256 };
48
+ const key = 'test-with-hint';
49
+
50
+ await backend.write(key, bufStream(buf), hint);
51
+
52
+ // read back via hint → should hit COS
53
+ const blob = await backend.read(key, hint);
54
+ expect(blob).not.toBeNull();
55
+ expect(new Uint8Array(await blob!.arrayBuffer())).toEqual(buf);
56
+
57
+ // without hint → OPFS fallback → not found (was written to COS only)
58
+ const blobOpfs = await backend.read(key);
59
+ expect(blobOpfs).toBeNull();
60
+
61
+ await backend.delete(key);
62
+ });
63
+
64
+ test.sequential('getSize with hint reflects COS size', async () => {
65
+ const backend = new COSBackend();
66
+ const { buf, sha256 } = await randomBufAndHash();
67
+ const hint = { sha256 };
68
+ const key = 'test-size-hint';
69
+
70
+ await backend.write(key, bufStream(buf), hint);
71
+
72
+ const size = await backend.getSize(key, hint);
73
+ expect(size).toBe(buf.byteLength);
74
+
75
+ await backend.delete(key);
76
+ });
77
+
78
+ test.sequential('read missing key returns null', async () => {
79
+ const backend = new COSBackend();
80
+ const { sha256 } = await randomBufAndHash();
81
+ const blob = await backend.read('non-existent-key', { sha256 });
82
+ expect(blob).toBeNull();
83
+ });
@@ -0,0 +1,171 @@
1
+ import type { StorageBackend, StorageFileHint } from './index';
2
+ import { OPFSBackend } from './opfs';
3
+
4
+ interface CrossOriginStorageRequestFileHandleHash {
5
+ value: string;
6
+ algorithm: string;
7
+ }
8
+
9
+ interface CrossOriginStorageManager {
10
+ requestFileHandle(
11
+ hash: CrossOriginStorageRequestFileHandleHash,
12
+ options?: { create?: boolean; origins?: string[] | string }
13
+ ): Promise<FileSystemFileHandle>;
14
+ }
15
+
16
+ declare global {
17
+ interface Navigator {
18
+ readonly crossOriginStorage?: CrossOriginStorageManager;
19
+ }
20
+ }
21
+
22
+ function makeHash(key: string): CrossOriginStorageRequestFileHandleHash {
23
+ return { algorithm: 'SHA-256', value: key };
24
+ }
25
+
26
+ // internal, non-standard implementation
27
+ class COSInternalBackend implements StorageBackend {
28
+ isSupported(): boolean {
29
+ return (
30
+ typeof navigator !== 'undefined' && 'crossOriginStorage' in navigator
31
+ );
32
+ }
33
+
34
+ // IMPORTANT: key must be SHA-256 hash of the data
35
+ async read(key: string): Promise<Blob | null> {
36
+ try {
37
+ const handle = await navigator.crossOriginStorage!.requestFileHandle(
38
+ makeHash(key)
39
+ );
40
+ return handle.getFile();
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ // IMPORTANT: key must be SHA-256 hash of the data
47
+ async write(key: string, stream: ReadableStream): Promise<void> {
48
+ const handle = await navigator.crossOriginStorage!.requestFileHandle(
49
+ makeHash(key),
50
+ { create: true }
51
+ );
52
+ const writable = await (handle as any).createWritable();
53
+ const reader = stream.getReader();
54
+ try {
55
+ while (true) {
56
+ const { done, value } = await reader.read();
57
+ if (done) break;
58
+ await writable.write(value);
59
+ }
60
+ } finally {
61
+ await writable.close();
62
+ }
63
+ }
64
+
65
+ // IMPORTANT: key must be SHA-256 hash of the data
66
+ async getSize(key: string): Promise<number> {
67
+ try {
68
+ const handle = await navigator.crossOriginStorage!.requestFileHandle(
69
+ makeHash(key)
70
+ );
71
+ const file = await handle.getFile();
72
+ return file.size;
73
+ } catch {
74
+ return -1;
75
+ }
76
+ }
77
+
78
+ async list(): Promise<Array<{ key: string; size: number }>> {
79
+ throw new Error('not implemented');
80
+ }
81
+
82
+ async delete(_key: string): Promise<void> {
83
+ throw new Error('not implemented');
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Storage backend that uses the Cross-Origin Storage API
89
+ * Metadata is stored in OPFS, while the actual data is stored in COS
90
+ * If hint.sha256 is provided, it will be used as the key for COS, otherwise fallback to OPFS
91
+ */
92
+ export class COSBackend implements StorageBackend {
93
+ private cos = new COSInternalBackend();
94
+ private priv = new OPFSBackend();
95
+
96
+ isSupported(): boolean {
97
+ return this.priv.isSupported();
98
+ }
99
+
100
+ async read(key: string, hint?: StorageFileHint): Promise<Blob | null> {
101
+ if (hint?.sha256 && this.cos.isSupported()) {
102
+ const blob = await this.cos.read(hint.sha256);
103
+ if (blob) return blob;
104
+ }
105
+ return this.priv.read(key);
106
+ }
107
+
108
+ async write(
109
+ key: string,
110
+ stream: ReadableStream,
111
+ hint?: StorageFileHint
112
+ ): Promise<void> {
113
+ if (hint?.sha256 && this.cos.isSupported()) {
114
+ await this.cos.write(hint.sha256, stream);
115
+ } else {
116
+ await this.priv.write(key, stream);
117
+ }
118
+ }
119
+
120
+ async getSize(key: string, hint?: StorageFileHint): Promise<number> {
121
+ if (hint?.sha256 && this.cos.isSupported()) {
122
+ const size = await this.cos.getSize(hint.sha256);
123
+ if (size !== -1) return size;
124
+ }
125
+ return this.priv.getSize(key);
126
+ }
127
+
128
+ async list(): Promise<Array<{ key: string; size: number }>> {
129
+ return this.priv.list();
130
+ }
131
+
132
+ async delete(key: string): Promise<void> {
133
+ return this.priv.delete(key);
134
+ }
135
+ }
136
+
137
+ // used for testing only
138
+ export function mockCOS(): void {
139
+ const store = new Map<string, Blob>();
140
+
141
+ (navigator as any).crossOriginStorage = {
142
+ async requestFileHandle(
143
+ { value }: CrossOriginStorageRequestFileHandleHash,
144
+ options?: { create?: boolean }
145
+ ): Promise<FileSystemFileHandle> {
146
+ if (!options?.create && !store.has(value)) {
147
+ throw new DOMException('File not found', 'NotFoundError');
148
+ }
149
+ return {
150
+ getFile() {
151
+ const blob = store.get(value);
152
+ if (!blob) throw new DOMException('File not found', 'NotFoundError');
153
+ return Promise.resolve(new File([blob], value));
154
+ },
155
+ createWritable() {
156
+ const chunks: BlobPart[] = [];
157
+ return Promise.resolve({
158
+ write(chunk: BlobPart) {
159
+ chunks.push(chunk);
160
+ return Promise.resolve();
161
+ },
162
+ close() {
163
+ store.set(value, new Blob(chunks));
164
+ return Promise.resolve();
165
+ },
166
+ });
167
+ },
168
+ } as unknown as FileSystemFileHandle;
169
+ },
170
+ } satisfies CrossOriginStorageManager;
171
+ }
@@ -0,0 +1,40 @@
1
+ export interface StorageFileHint {
2
+ sha256: string;
3
+ }
4
+
5
+ export interface StorageBackend {
6
+ isSupported(): boolean;
7
+
8
+ /**
9
+ * Read a file from storage by key.
10
+ * @returns Blob, or null if the key does not exist
11
+ */
12
+ read(key: string, hint?: StorageFileHint): Promise<Blob | null>;
13
+
14
+ /**
15
+ * Write a ReadableStream to storage under the given key.
16
+ * Overwrites any existing content for that key.
17
+ */
18
+ write(
19
+ key: string,
20
+ stream: ReadableStream,
21
+ hint?: StorageFileHint
22
+ ): Promise<void>;
23
+
24
+ /**
25
+ * Get the stored size of a file in bytes.
26
+ * @returns number of bytes, or -1 if the key does not exist
27
+ */
28
+ getSize(key: string, hint?: StorageFileHint): Promise<number>;
29
+
30
+ /**
31
+ * List all keys currently in storage.
32
+ * Includes metadata keys - callers are responsible for filtering.
33
+ */
34
+ list(): Promise<Array<{ key: string; size: number }>>;
35
+
36
+ /**
37
+ * Delete a single entry by key. No-op if the key does not exist.
38
+ */
39
+ delete(key: string): Promise<void>;
40
+ }