@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.
- package/.gitmodules +3 -0
- package/.prettierignore +38 -0
- package/AGENTS.md +1 -0
- package/CMakeLists.txt +131 -0
- package/LICENCE +21 -0
- package/README-dev.md +178 -0
- package/README.md +225 -0
- package/README_banner.png +0 -0
- package/assets/screenshot_0.png +0 -0
- package/cpp/generate_glue_prototype.js +115 -0
- package/cpp/glue.hpp +664 -0
- package/cpp/test_glue.cpp +80 -0
- package/cpp/wllama-context.h +1172 -0
- package/cpp/wllama-fs.h +148 -0
- package/cpp/wllama.cpp +187 -0
- package/cpp/wllama.h +6 -0
- package/esm/cache-manager.d.ts +130 -0
- package/esm/debug.d.ts +28 -0
- package/esm/glue/glue.d.ts +22 -0
- package/esm/glue/messages.d.ts +146 -0
- package/esm/huggingface.d.ts +31 -0
- package/esm/index.cjs +3406 -0
- package/esm/index.d.ts +8 -0
- package/esm/index.js +3387 -0
- package/esm/index.min.js +1 -0
- package/esm/index.min.js.map +1 -0
- package/esm/model-manager.d.ts +136 -0
- package/esm/storage/cos.d.ts +36 -0
- package/esm/storage/index.d.ts +33 -0
- package/esm/storage/opfs.d.ts +12 -0
- package/esm/types/oai-compat.d.ts +278 -0
- package/esm/types/types.d.ts +112 -0
- package/esm/utils.d.ts +119 -0
- package/esm/wasm/source-map.d.ts +1 -0
- package/esm/wasm/wllama.wasm +0 -0
- package/esm/wasm-from-cdn.d.ts +8 -0
- package/esm/wllama.d.ts +397 -0
- package/esm/worker.d.ts +92 -0
- package/esm/workers-code/generated.d.ts +4 -0
- package/guides/intro-v2.md +132 -0
- package/guides/intro-v3.1.md +40 -0
- package/guides/intro-v3.md +230 -0
- package/index.ts +1 -0
- package/package.json +71 -0
- package/scripts/bisect_test.sh +33 -0
- package/scripts/build_hf_space.sh +26 -0
- package/scripts/build_source_map.js +269 -0
- package/scripts/build_wasm.sh +19 -0
- package/scripts/build_worker.sh +38 -0
- package/scripts/check_debug_build.js +30 -0
- package/scripts/check_package_size.js +25 -0
- package/scripts/docker-compose.yml +76 -0
- package/scripts/generate_wasm_from_cdn.js +24 -0
- package/scripts/http_server.js +44 -0
- package/scripts/post_build.sh +32 -0
- package/src/cache-manager.ts +358 -0
- package/src/debug.ts +111 -0
- package/src/glue/glue.ts +291 -0
- package/src/glue/messages.ts +773 -0
- package/src/huggingface.ts +151 -0
- package/src/index.ts +8 -0
- package/src/mjs.test.ts +44 -0
- package/src/model-manager.test.ts +200 -0
- package/src/model-manager.ts +359 -0
- package/src/storage/cos.test.ts +83 -0
- package/src/storage/cos.ts +171 -0
- package/src/storage/index.ts +40 -0
- package/src/storage/opfs.ts +119 -0
- package/src/types/oai-compat.ts +342 -0
- package/src/types/types.ts +133 -0
- package/src/utils.test.ts +231 -0
- package/src/utils.ts +403 -0
- package/src/wasm/source-map.ts +7 -0
- package/src/wasm/wllama.js +1 -0
- package/src/wasm/wllama.wasm +0 -0
- package/src/wasm-from-cdn.ts +13 -0
- package/src/wllama.test.ts +392 -0
- package/src/wllama.ts +1138 -0
- package/src/wllama.wgpu.test.ts +62 -0
- package/src/worker.ts +443 -0
- package/src/workers-code/generated.ts +11 -0
- package/src/workers-code/llama-cpp.js +511 -0
- package/src/workers-code/opfs-utils.js +150 -0
- package/tsconfig.build.json +34 -0
- package/tsup.config.ts +23 -0
- 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
|
+
}
|