@cubis/vfsclient 0.0.1
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 +315 -0
- package/dist/index.cjs +1577 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +649 -0
- package/dist/index.d.ts +649 -0
- package/dist/index.js +1560 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1577 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/types/errors.ts
|
|
4
|
+
var VFSError = class _VFSError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
code;
|
|
7
|
+
problem;
|
|
8
|
+
headers;
|
|
9
|
+
constructor(message, options) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "VFSError";
|
|
12
|
+
this.status = options?.status ?? 500;
|
|
13
|
+
this.code = options?.code;
|
|
14
|
+
this.problem = options?.problem;
|
|
15
|
+
this.headers = options?.headers;
|
|
16
|
+
if (options?.cause) {
|
|
17
|
+
this.cause = options.cause;
|
|
18
|
+
}
|
|
19
|
+
Object.setPrototypeOf(this, _VFSError.prototype);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Helper to check if error is a specific HTTP status code.
|
|
23
|
+
*/
|
|
24
|
+
is(status) {
|
|
25
|
+
return this.status === status;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// src/http/error.ts
|
|
30
|
+
async function parseHttpError(res) {
|
|
31
|
+
const status = res.status;
|
|
32
|
+
const contentType = res.headers.get("content-type") || "";
|
|
33
|
+
let errorMessage = res.statusText || `Request failed with status ${status}`;
|
|
34
|
+
let problemDetails;
|
|
35
|
+
let code;
|
|
36
|
+
try {
|
|
37
|
+
if (contentType.includes("application/json") || contentType.includes("application/problem+json")) {
|
|
38
|
+
const json = await res.json();
|
|
39
|
+
if (json && typeof json === "object") {
|
|
40
|
+
problemDetails = json;
|
|
41
|
+
if (problemDetails.detail) {
|
|
42
|
+
errorMessage = problemDetails.detail;
|
|
43
|
+
} else if (problemDetails.title) {
|
|
44
|
+
errorMessage = problemDetails.title;
|
|
45
|
+
} else if (json.error) {
|
|
46
|
+
errorMessage = json.error;
|
|
47
|
+
} else if (json.message) {
|
|
48
|
+
errorMessage = json.message;
|
|
49
|
+
}
|
|
50
|
+
if (problemDetails.extensions?.code && typeof problemDetails.extensions.code === "string") {
|
|
51
|
+
code = problemDetails.extensions.code;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
if (text) {
|
|
57
|
+
errorMessage = text;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
return new VFSError(errorMessage, {
|
|
63
|
+
status,
|
|
64
|
+
code,
|
|
65
|
+
problem: problemDetails,
|
|
66
|
+
headers: res.headers
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/http/client.ts
|
|
71
|
+
var HttpClient = class {
|
|
72
|
+
endpoint;
|
|
73
|
+
apiKey;
|
|
74
|
+
apiHash;
|
|
75
|
+
defaultTimeout;
|
|
76
|
+
fetchImpl;
|
|
77
|
+
debug;
|
|
78
|
+
maxRetries;
|
|
79
|
+
retryDelay;
|
|
80
|
+
retryOnStatus;
|
|
81
|
+
constructor(options) {
|
|
82
|
+
if (!options.endpoint) {
|
|
83
|
+
throw new VFSError("Endpoint URL is required", { status: 400 });
|
|
84
|
+
}
|
|
85
|
+
this.endpoint = options.endpoint.replace(/\/+$/, "");
|
|
86
|
+
this.apiKey = options.apiKey;
|
|
87
|
+
this.apiHash = options.apiHash;
|
|
88
|
+
this.defaultTimeout = options.timeout ?? 3e4;
|
|
89
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
90
|
+
if (typeof options.debug === "function") {
|
|
91
|
+
this.debug = options.debug;
|
|
92
|
+
} else if (options.debug === true) {
|
|
93
|
+
this.debug = (msg, ...args) => console.debug(`[VFSClient] ${msg}`, ...args);
|
|
94
|
+
}
|
|
95
|
+
this.maxRetries = options.retry?.maxRetries ?? 2;
|
|
96
|
+
this.retryDelay = options.retry?.retryDelay ?? 500;
|
|
97
|
+
this.retryOnStatus = options.retry?.retryOnStatus ?? [502, 503, 504];
|
|
98
|
+
}
|
|
99
|
+
getEndpoint() {
|
|
100
|
+
return this.endpoint;
|
|
101
|
+
}
|
|
102
|
+
getApiKey() {
|
|
103
|
+
return this.apiKey;
|
|
104
|
+
}
|
|
105
|
+
getApiHash() {
|
|
106
|
+
return this.apiHash;
|
|
107
|
+
}
|
|
108
|
+
buildUrl(path, params) {
|
|
109
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
110
|
+
const url = new URL(`${this.endpoint}${cleanPath}`);
|
|
111
|
+
if (params) {
|
|
112
|
+
for (const [key, val] of Object.entries(params)) {
|
|
113
|
+
if (val !== void 0 && val !== null) {
|
|
114
|
+
url.searchParams.append(key, String(val));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return url.toString();
|
|
119
|
+
}
|
|
120
|
+
buildHeaders(customHeaders, skipAuth) {
|
|
121
|
+
const headers = new Headers();
|
|
122
|
+
if (!skipAuth) {
|
|
123
|
+
if (this.apiKey) {
|
|
124
|
+
headers.set("x-api-key", this.apiKey);
|
|
125
|
+
}
|
|
126
|
+
if (this.apiHash) {
|
|
127
|
+
headers.set("x-api-hash", this.apiHash);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (customHeaders) {
|
|
131
|
+
for (const [key, value] of Object.entries(customHeaders)) {
|
|
132
|
+
headers.set(key, value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return headers;
|
|
136
|
+
}
|
|
137
|
+
async request(method, path, options = {}) {
|
|
138
|
+
const url = this.buildUrl(path, options.params);
|
|
139
|
+
const headers = this.buildHeaders(options.headers, options.skipAuth);
|
|
140
|
+
const timeoutMs = options.timeout ?? this.defaultTimeout;
|
|
141
|
+
let attempt = 0;
|
|
142
|
+
let lastError;
|
|
143
|
+
while (attempt <= this.maxRetries) {
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const timer = setTimeout(() => controller.abort(new Error("Request timed out")), timeoutMs);
|
|
146
|
+
if (options.signal) {
|
|
147
|
+
options.signal.addEventListener("abort", () => controller.abort(options.signal?.reason), { once: true });
|
|
148
|
+
}
|
|
149
|
+
this.debug?.(`${method} ${url} (Attempt ${attempt + 1}/${this.maxRetries + 1})`);
|
|
150
|
+
try {
|
|
151
|
+
const res = await this.fetchImpl(url, {
|
|
152
|
+
method,
|
|
153
|
+
headers,
|
|
154
|
+
body: options.body,
|
|
155
|
+
signal: controller.signal
|
|
156
|
+
});
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
if (attempt < this.maxRetries && this.retryOnStatus.includes(res.status)) {
|
|
160
|
+
attempt++;
|
|
161
|
+
const backoff = this.retryDelay * Math.pow(2, attempt - 1);
|
|
162
|
+
this.debug?.(`HTTP ${res.status} on ${method} ${url}. Retrying in ${backoff}ms...`);
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
throw await parseHttpError(res);
|
|
167
|
+
}
|
|
168
|
+
let data;
|
|
169
|
+
const contentType = res.headers.get("content-type") || "";
|
|
170
|
+
if (contentType.includes("application/json")) {
|
|
171
|
+
data = await res.json();
|
|
172
|
+
} else if (contentType.includes("text/")) {
|
|
173
|
+
data = await res.text();
|
|
174
|
+
} else {
|
|
175
|
+
data = res;
|
|
176
|
+
}
|
|
177
|
+
let totalCount;
|
|
178
|
+
const totalCountHeader = res.headers.get("x-total-count");
|
|
179
|
+
if (totalCountHeader) {
|
|
180
|
+
const parsed = parseInt(totalCountHeader, 10);
|
|
181
|
+
if (!isNaN(parsed)) {
|
|
182
|
+
totalCount = parsed;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
data,
|
|
187
|
+
status: res.status,
|
|
188
|
+
statusText: res.statusText,
|
|
189
|
+
headers: res.headers,
|
|
190
|
+
totalCount
|
|
191
|
+
};
|
|
192
|
+
} catch (err) {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
if (err instanceof VFSError) {
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
lastError = err;
|
|
198
|
+
const isAbort = options.signal?.aborted;
|
|
199
|
+
if (isAbort) {
|
|
200
|
+
throw new VFSError("Request was aborted by caller", { status: 499, cause: err });
|
|
201
|
+
}
|
|
202
|
+
if (attempt < this.maxRetries) {
|
|
203
|
+
attempt++;
|
|
204
|
+
const backoff = this.retryDelay * Math.pow(2, attempt - 1);
|
|
205
|
+
this.debug?.(`Network error on ${method} ${url}. Retrying in ${backoff}ms...`, err);
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
throw new VFSError(err instanceof Error ? err.message : "Network request failed", {
|
|
210
|
+
status: 0,
|
|
211
|
+
cause: err
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw lastError instanceof VFSError ? lastError : new VFSError("Request failed after retries", { status: 500, cause: lastError });
|
|
216
|
+
}
|
|
217
|
+
get(path, options) {
|
|
218
|
+
return this.request("GET", path, options);
|
|
219
|
+
}
|
|
220
|
+
post(path, body, options) {
|
|
221
|
+
return this.request("POST", path, { ...options, body });
|
|
222
|
+
}
|
|
223
|
+
put(path, body, options) {
|
|
224
|
+
return this.request("PUT", path, { ...options, body });
|
|
225
|
+
}
|
|
226
|
+
delete(path, options) {
|
|
227
|
+
return this.request("DELETE", path, options);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Fetches raw Response object directly (useful for streaming file downloads).
|
|
231
|
+
*/
|
|
232
|
+
async fetchRaw(path, options = {}) {
|
|
233
|
+
const url = this.buildUrl(path, options.params);
|
|
234
|
+
const headers = this.buildHeaders(options.headers, options.skipAuth);
|
|
235
|
+
const timeoutMs = options.timeout ?? this.defaultTimeout;
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
const timer = setTimeout(() => controller.abort(new Error("Request timed out")), timeoutMs);
|
|
238
|
+
if (options.signal) {
|
|
239
|
+
options.signal.addEventListener("abort", () => controller.abort(options.signal?.reason), { once: true });
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const res = await this.fetchImpl(url, {
|
|
243
|
+
method: "GET",
|
|
244
|
+
headers,
|
|
245
|
+
signal: controller.signal
|
|
246
|
+
});
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
if (!res.ok) {
|
|
249
|
+
throw await parseHttpError(res);
|
|
250
|
+
}
|
|
251
|
+
return res;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
if (err instanceof VFSError) {
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
throw new VFSError(err instanceof Error ? err.message : "Download failed", {
|
|
258
|
+
status: 0,
|
|
259
|
+
cause: err
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// src/cache/memory.ts
|
|
266
|
+
var MemoryCacheAdapter = class {
|
|
267
|
+
store = /* @__PURE__ */ new Map();
|
|
268
|
+
maxItems;
|
|
269
|
+
defaultTTL;
|
|
270
|
+
constructor(options = {}) {
|
|
271
|
+
this.maxItems = options.maxItems ?? 200;
|
|
272
|
+
this.defaultTTL = options.defaultTTL ?? 3600;
|
|
273
|
+
}
|
|
274
|
+
isExpired(entry) {
|
|
275
|
+
if (!entry.expiresAt) return false;
|
|
276
|
+
return Date.now() > entry.expiresAt;
|
|
277
|
+
}
|
|
278
|
+
get(key) {
|
|
279
|
+
const entry = this.store.get(key);
|
|
280
|
+
if (!entry) return null;
|
|
281
|
+
if (this.isExpired(entry)) {
|
|
282
|
+
this.store.delete(key);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
this.store.delete(key);
|
|
286
|
+
this.store.set(key, entry);
|
|
287
|
+
return entry;
|
|
288
|
+
}
|
|
289
|
+
set(key, entry, ttlSeconds) {
|
|
290
|
+
const ttl = ttlSeconds ?? this.defaultTTL;
|
|
291
|
+
const expiresAt = ttl > 0 ? Date.now() + ttl * 1e3 : void 0;
|
|
292
|
+
const clonedEntry = {
|
|
293
|
+
...entry,
|
|
294
|
+
expiresAt
|
|
295
|
+
};
|
|
296
|
+
if (this.store.has(key)) {
|
|
297
|
+
this.store.delete(key);
|
|
298
|
+
} else if (this.store.size >= this.maxItems) {
|
|
299
|
+
const oldestKey = this.store.keys().next().value;
|
|
300
|
+
if (oldestKey !== void 0) {
|
|
301
|
+
this.store.delete(oldestKey);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
this.store.set(key, clonedEntry);
|
|
305
|
+
}
|
|
306
|
+
delete(key) {
|
|
307
|
+
return this.store.delete(key);
|
|
308
|
+
}
|
|
309
|
+
clear() {
|
|
310
|
+
this.store.clear();
|
|
311
|
+
}
|
|
312
|
+
has(key) {
|
|
313
|
+
const entry = this.store.get(key);
|
|
314
|
+
if (!entry) return false;
|
|
315
|
+
if (this.isExpired(entry)) {
|
|
316
|
+
this.store.delete(key);
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
size() {
|
|
322
|
+
return this.store.size;
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// src/cache/browser.ts
|
|
327
|
+
var BrowserCacheAdapter = class {
|
|
328
|
+
cacheName;
|
|
329
|
+
defaultTTL;
|
|
330
|
+
fallback;
|
|
331
|
+
constructor(options = {}) {
|
|
332
|
+
this.cacheName = options.cacheName ?? "vfs-client-cache-v1";
|
|
333
|
+
this.defaultTTL = options.defaultTTL ?? 3600;
|
|
334
|
+
this.fallback = new MemoryCacheAdapter({ defaultTTL: this.defaultTTL });
|
|
335
|
+
}
|
|
336
|
+
isAvailable() {
|
|
337
|
+
return typeof window !== "undefined" && typeof window.caches !== "undefined";
|
|
338
|
+
}
|
|
339
|
+
urlForKey(key) {
|
|
340
|
+
return `https://vfs-cache.internal/${encodeURIComponent(key)}`;
|
|
341
|
+
}
|
|
342
|
+
async get(key) {
|
|
343
|
+
if (!this.isAvailable()) {
|
|
344
|
+
return this.fallback.get(key);
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const cache = await window.caches.open(this.cacheName);
|
|
348
|
+
const res = await cache.match(this.urlForKey(key));
|
|
349
|
+
if (!res) return null;
|
|
350
|
+
const expiresHeader = res.headers.get("x-vfs-expires-at");
|
|
351
|
+
if (expiresHeader) {
|
|
352
|
+
const expiresAt2 = parseInt(expiresHeader, 10);
|
|
353
|
+
if (!isNaN(expiresAt2) && Date.now() > expiresAt2) {
|
|
354
|
+
await cache.delete(this.urlForKey(key));
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const buffer = await res.arrayBuffer();
|
|
359
|
+
const contentType = res.headers.get("content-type") || void 0;
|
|
360
|
+
const name = res.headers.get("x-vfs-name") || void 0;
|
|
361
|
+
const hash = res.headers.get("x-vfs-hash") || void 0;
|
|
362
|
+
const createdAtHeader = res.headers.get("x-vfs-created-at");
|
|
363
|
+
const createdAt = createdAtHeader ? parseInt(createdAtHeader, 10) : Date.now();
|
|
364
|
+
const expiresAt = expiresHeader ? parseInt(expiresHeader, 10) : void 0;
|
|
365
|
+
return {
|
|
366
|
+
data: new Uint8Array(buffer),
|
|
367
|
+
contentType,
|
|
368
|
+
name,
|
|
369
|
+
size: buffer.byteLength,
|
|
370
|
+
hash,
|
|
371
|
+
createdAt,
|
|
372
|
+
expiresAt
|
|
373
|
+
};
|
|
374
|
+
} catch {
|
|
375
|
+
return this.fallback.get(key);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async set(key, entry, ttlSeconds) {
|
|
379
|
+
if (!this.isAvailable()) {
|
|
380
|
+
this.fallback.set(key, entry, ttlSeconds);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const ttl = ttlSeconds ?? this.defaultTTL;
|
|
385
|
+
const expiresAt = ttl > 0 ? Date.now() + ttl * 1e3 : void 0;
|
|
386
|
+
const headers = new Headers();
|
|
387
|
+
if (entry.contentType) headers.set("content-type", entry.contentType);
|
|
388
|
+
if (entry.name) headers.set("x-vfs-name", entry.name);
|
|
389
|
+
if (entry.hash) headers.set("x-vfs-hash", entry.hash);
|
|
390
|
+
headers.set("x-vfs-created-at", String(entry.createdAt));
|
|
391
|
+
if (expiresAt) headers.set("x-vfs-expires-at", String(expiresAt));
|
|
392
|
+
const response = new Response(entry.data, { headers });
|
|
393
|
+
const cache = await window.caches.open(this.cacheName);
|
|
394
|
+
await cache.put(this.urlForKey(key), response);
|
|
395
|
+
} catch {
|
|
396
|
+
this.fallback.set(key, entry, ttlSeconds);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async delete(key) {
|
|
400
|
+
if (!this.isAvailable()) {
|
|
401
|
+
return this.fallback.delete(key);
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const cache = await window.caches.open(this.cacheName);
|
|
405
|
+
return await cache.delete(this.urlForKey(key));
|
|
406
|
+
} catch {
|
|
407
|
+
return this.fallback.delete(key);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async clear() {
|
|
411
|
+
if (!this.isAvailable()) {
|
|
412
|
+
this.fallback.clear();
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
await window.caches.delete(this.cacheName);
|
|
417
|
+
} catch {
|
|
418
|
+
this.fallback.clear();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async has(key) {
|
|
422
|
+
const entry = await this.get(key);
|
|
423
|
+
return entry !== null;
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// src/cache/filesystem.ts
|
|
428
|
+
var FileSystemCacheAdapter = class {
|
|
429
|
+
cacheDir;
|
|
430
|
+
defaultTTL;
|
|
431
|
+
fallback;
|
|
432
|
+
isNodeEnv;
|
|
433
|
+
constructor(options = {}) {
|
|
434
|
+
this.cacheDir = options.cacheDir ?? "./.vfs-cache";
|
|
435
|
+
this.defaultTTL = options.defaultTTL ?? 3600;
|
|
436
|
+
this.fallback = new MemoryCacheAdapter({ defaultTTL: this.defaultTTL });
|
|
437
|
+
this.isNodeEnv = typeof process !== "undefined" && Boolean(process.versions?.node || process.versions?.bun);
|
|
438
|
+
}
|
|
439
|
+
sanitizeKey(key) {
|
|
440
|
+
return encodeURIComponent(key).replace(/[*?:<>|"/\\%]/g, "_");
|
|
441
|
+
}
|
|
442
|
+
async getFs() {
|
|
443
|
+
if (!this.isNodeEnv) return null;
|
|
444
|
+
try {
|
|
445
|
+
const fs = await import('fs/promises');
|
|
446
|
+
const path = await import('path');
|
|
447
|
+
return { fs, path };
|
|
448
|
+
} catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async ensureDir() {
|
|
453
|
+
const mods = await this.getFs();
|
|
454
|
+
if (!mods) return false;
|
|
455
|
+
try {
|
|
456
|
+
await mods.fs.mkdir(this.cacheDir, { recursive: true });
|
|
457
|
+
return true;
|
|
458
|
+
} catch {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async get(key) {
|
|
463
|
+
const mods = await this.getFs();
|
|
464
|
+
if (!mods) return this.fallback.get(key);
|
|
465
|
+
const safeKey = this.sanitizeKey(key);
|
|
466
|
+
const dataPath = mods.path.join(this.cacheDir, `${safeKey}.bin`);
|
|
467
|
+
const metaPath = mods.path.join(this.cacheDir, `${safeKey}.json`);
|
|
468
|
+
try {
|
|
469
|
+
const metaRaw = await mods.fs.readFile(metaPath, "utf8");
|
|
470
|
+
const meta = JSON.parse(metaRaw);
|
|
471
|
+
if (meta.expiresAt && Date.now() > meta.expiresAt) {
|
|
472
|
+
await Promise.allSettled([mods.fs.unlink(dataPath), mods.fs.unlink(metaPath)]);
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
const buffer = await mods.fs.readFile(dataPath);
|
|
476
|
+
return {
|
|
477
|
+
data: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength),
|
|
478
|
+
contentType: meta.contentType,
|
|
479
|
+
name: meta.name,
|
|
480
|
+
size: meta.size,
|
|
481
|
+
hash: meta.hash,
|
|
482
|
+
createdAt: meta.createdAt,
|
|
483
|
+
expiresAt: meta.expiresAt
|
|
484
|
+
};
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async set(key, entry, ttlSeconds) {
|
|
490
|
+
const mods = await this.getFs();
|
|
491
|
+
if (!mods) {
|
|
492
|
+
this.fallback.set(key, entry, ttlSeconds);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (!await this.ensureDir()) {
|
|
496
|
+
this.fallback.set(key, entry, ttlSeconds);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const ttl = ttlSeconds ?? this.defaultTTL;
|
|
500
|
+
const expiresAt = ttl > 0 ? Date.now() + ttl * 1e3 : void 0;
|
|
501
|
+
const safeKey = this.sanitizeKey(key);
|
|
502
|
+
const dataPath = mods.path.join(this.cacheDir, `${safeKey}.bin`);
|
|
503
|
+
const metaPath = mods.path.join(this.cacheDir, `${safeKey}.json`);
|
|
504
|
+
const meta = {
|
|
505
|
+
contentType: entry.contentType,
|
|
506
|
+
name: entry.name,
|
|
507
|
+
size: entry.size,
|
|
508
|
+
hash: entry.hash,
|
|
509
|
+
createdAt: entry.createdAt,
|
|
510
|
+
expiresAt
|
|
511
|
+
};
|
|
512
|
+
try {
|
|
513
|
+
await Promise.all([
|
|
514
|
+
mods.fs.writeFile(dataPath, entry.data),
|
|
515
|
+
mods.fs.writeFile(metaPath, JSON.stringify(meta), "utf8")
|
|
516
|
+
]);
|
|
517
|
+
} catch {
|
|
518
|
+
this.fallback.set(key, entry, ttlSeconds);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async delete(key) {
|
|
522
|
+
const mods = await this.getFs();
|
|
523
|
+
if (!mods) return this.fallback.delete(key);
|
|
524
|
+
const safeKey = this.sanitizeKey(key);
|
|
525
|
+
const dataPath = mods.path.join(this.cacheDir, `${safeKey}.bin`);
|
|
526
|
+
const metaPath = mods.path.join(this.cacheDir, `${safeKey}.json`);
|
|
527
|
+
try {
|
|
528
|
+
await Promise.allSettled([mods.fs.unlink(dataPath), mods.fs.unlink(metaPath)]);
|
|
529
|
+
return true;
|
|
530
|
+
} catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async clear() {
|
|
535
|
+
const mods = await this.getFs();
|
|
536
|
+
if (!mods) {
|
|
537
|
+
this.fallback.clear();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
const files = await mods.fs.readdir(this.cacheDir);
|
|
542
|
+
await Promise.allSettled(files.map((file) => mods.fs.unlink(mods.path.join(this.cacheDir, file))));
|
|
543
|
+
} catch {
|
|
544
|
+
this.fallback.clear();
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async has(key) {
|
|
548
|
+
const entry = await this.get(key);
|
|
549
|
+
return entry !== null;
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
// src/cache/factory.ts
|
|
554
|
+
function createDefaultCache() {
|
|
555
|
+
if (typeof window !== "undefined" && typeof window.caches !== "undefined") {
|
|
556
|
+
return new BrowserCacheAdapter();
|
|
557
|
+
}
|
|
558
|
+
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
|
|
559
|
+
return new FileSystemCacheAdapter();
|
|
560
|
+
}
|
|
561
|
+
return new MemoryCacheAdapter();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/crypto/hash.ts
|
|
565
|
+
function bufferToHex(buffer) {
|
|
566
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
567
|
+
let hex = "";
|
|
568
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
569
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
570
|
+
}
|
|
571
|
+
return hex;
|
|
572
|
+
}
|
|
573
|
+
async function calculateSHA256(data) {
|
|
574
|
+
let bytes;
|
|
575
|
+
if (typeof data === "string") {
|
|
576
|
+
bytes = new TextEncoder().encode(data);
|
|
577
|
+
} else if (data instanceof Uint8Array) {
|
|
578
|
+
bytes = data;
|
|
579
|
+
} else {
|
|
580
|
+
bytes = new Uint8Array(data);
|
|
581
|
+
}
|
|
582
|
+
if (typeof globalThis !== "undefined" && globalThis.crypto?.subtle?.digest) {
|
|
583
|
+
const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
584
|
+
return bufferToHex(hashBuffer);
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
const nodeCrypto = await import('crypto');
|
|
588
|
+
return nodeCrypto.createHash("sha256").update(bytes).digest("hex");
|
|
589
|
+
} catch {
|
|
590
|
+
throw new Error("No crypto implementation found in current environment for SHA-256");
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async function calculateBlobSHA256(blob) {
|
|
594
|
+
const arrayBuffer = await blob.arrayBuffer();
|
|
595
|
+
return calculateSHA256(arrayBuffer);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/upload/normalizer.ts
|
|
599
|
+
function guessContentType(filename) {
|
|
600
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
601
|
+
switch (ext) {
|
|
602
|
+
case "jpg":
|
|
603
|
+
case "jpeg":
|
|
604
|
+
return "image/jpeg";
|
|
605
|
+
case "png":
|
|
606
|
+
return "image/png";
|
|
607
|
+
case "gif":
|
|
608
|
+
return "image/gif";
|
|
609
|
+
case "webp":
|
|
610
|
+
return "image/webp";
|
|
611
|
+
case "svg":
|
|
612
|
+
return "image/svg+xml";
|
|
613
|
+
case "pdf":
|
|
614
|
+
return "application/pdf";
|
|
615
|
+
case "json":
|
|
616
|
+
return "application/json";
|
|
617
|
+
case "txt":
|
|
618
|
+
return "text/plain";
|
|
619
|
+
case "html":
|
|
620
|
+
return "text/html";
|
|
621
|
+
case "csv":
|
|
622
|
+
return "text/csv";
|
|
623
|
+
case "zip":
|
|
624
|
+
return "application/zip";
|
|
625
|
+
case "tar":
|
|
626
|
+
return "application/x-tar";
|
|
627
|
+
case "gz":
|
|
628
|
+
return "application/gzip";
|
|
629
|
+
case "mp4":
|
|
630
|
+
return "video/mp4";
|
|
631
|
+
case "mp3":
|
|
632
|
+
return "audio/mpeg";
|
|
633
|
+
default:
|
|
634
|
+
return "application/octet-stream";
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
async function normalizeUploadInput(input) {
|
|
638
|
+
const file = input.file;
|
|
639
|
+
let bytes = new Uint8Array(0);
|
|
640
|
+
let name = input.name || "unnamed_file";
|
|
641
|
+
let contentType = input.contentType;
|
|
642
|
+
if (typeof File !== "undefined" && file instanceof File) {
|
|
643
|
+
name = input.name || file.name;
|
|
644
|
+
contentType = contentType || file.type || guessContentType(name);
|
|
645
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
646
|
+
bytes = new Uint8Array(arrayBuffer);
|
|
647
|
+
} else if (typeof Blob !== "undefined" && file instanceof Blob) {
|
|
648
|
+
contentType = contentType || file.type || guessContentType(name);
|
|
649
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
650
|
+
bytes = new Uint8Array(arrayBuffer);
|
|
651
|
+
} else if (file instanceof Uint8Array) {
|
|
652
|
+
bytes = file;
|
|
653
|
+
contentType = contentType || guessContentType(name);
|
|
654
|
+
} else if (file instanceof ArrayBuffer) {
|
|
655
|
+
bytes = new Uint8Array(file);
|
|
656
|
+
contentType = contentType || guessContentType(name);
|
|
657
|
+
} else if (typeof ReadableStream !== "undefined" && file instanceof ReadableStream) {
|
|
658
|
+
const reader = file.getReader();
|
|
659
|
+
const chunks = [];
|
|
660
|
+
while (true) {
|
|
661
|
+
const { done, value } = await reader.read();
|
|
662
|
+
if (done) break;
|
|
663
|
+
if (value) chunks.push(value);
|
|
664
|
+
}
|
|
665
|
+
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0);
|
|
666
|
+
bytes = new Uint8Array(totalLength);
|
|
667
|
+
let offset = 0;
|
|
668
|
+
for (const chunk of chunks) {
|
|
669
|
+
bytes.set(chunk, offset);
|
|
670
|
+
offset += chunk.length;
|
|
671
|
+
}
|
|
672
|
+
contentType = contentType || guessContentType(name);
|
|
673
|
+
} else if (typeof file === "object" && file !== null && "on" in file && typeof file.pipe === "function") {
|
|
674
|
+
const chunks = [];
|
|
675
|
+
await new Promise((resolve, reject) => {
|
|
676
|
+
file.on("data", (chunk) => {
|
|
677
|
+
if (typeof chunk === "string") {
|
|
678
|
+
chunks.push(new TextEncoder().encode(chunk));
|
|
679
|
+
} else if (chunk instanceof Uint8Array) {
|
|
680
|
+
chunks.push(chunk);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
file.on("end", () => resolve());
|
|
684
|
+
file.on("error", (err) => reject(err));
|
|
685
|
+
});
|
|
686
|
+
const totalLength = chunks.reduce((acc, c) => acc + c.length, 0);
|
|
687
|
+
bytes = new Uint8Array(totalLength);
|
|
688
|
+
let offset = 0;
|
|
689
|
+
for (const chunk of chunks) {
|
|
690
|
+
bytes.set(chunk, offset);
|
|
691
|
+
offset += chunk.length;
|
|
692
|
+
}
|
|
693
|
+
contentType = contentType || guessContentType(name);
|
|
694
|
+
} else if (typeof file === "string") {
|
|
695
|
+
const isNode = typeof process !== "undefined" && Boolean(process.versions?.node || process.versions?.bun);
|
|
696
|
+
let readFromFile = false;
|
|
697
|
+
if (isNode) {
|
|
698
|
+
try {
|
|
699
|
+
const fs = await import('fs/promises');
|
|
700
|
+
const path = await import('path');
|
|
701
|
+
const stat = await fs.stat(file);
|
|
702
|
+
if (stat.isFile()) {
|
|
703
|
+
const buf = await fs.readFile(file);
|
|
704
|
+
bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
705
|
+
name = input.name || path.basename(file);
|
|
706
|
+
contentType = contentType || guessContentType(name);
|
|
707
|
+
readFromFile = true;
|
|
708
|
+
}
|
|
709
|
+
} catch {
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!readFromFile) {
|
|
713
|
+
if (file.startsWith("data:")) {
|
|
714
|
+
const commaIdx = file.indexOf(",");
|
|
715
|
+
if (commaIdx !== -1) {
|
|
716
|
+
const mimeMatch = file.slice(0, commaIdx).match(/data:(.*?);/);
|
|
717
|
+
if (mimeMatch && !contentType) {
|
|
718
|
+
contentType = mimeMatch[1];
|
|
719
|
+
}
|
|
720
|
+
const base64Data = file.slice(commaIdx + 1);
|
|
721
|
+
const binaryStr = atob(base64Data);
|
|
722
|
+
bytes = new Uint8Array(binaryStr.length);
|
|
723
|
+
for (let i = 0; i < binaryStr.length; i++) {
|
|
724
|
+
bytes[i] = binaryStr.charCodeAt(i);
|
|
725
|
+
}
|
|
726
|
+
} else {
|
|
727
|
+
bytes = new TextEncoder().encode(file);
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
bytes = new TextEncoder().encode(file);
|
|
731
|
+
}
|
|
732
|
+
contentType = contentType || guessContentType(name);
|
|
733
|
+
}
|
|
734
|
+
} else {
|
|
735
|
+
throw new VFSError("Unsupported file input type for upload", { status: 400 });
|
|
736
|
+
}
|
|
737
|
+
const hash = input.fileHash || await calculateSHA256(bytes);
|
|
738
|
+
return {
|
|
739
|
+
bytes,
|
|
740
|
+
name,
|
|
741
|
+
size: bytes.byteLength,
|
|
742
|
+
contentType: contentType || "application/octet-stream",
|
|
743
|
+
hash
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/upload/preflight.ts
|
|
748
|
+
async function executePreflight(http, req, signal) {
|
|
749
|
+
try {
|
|
750
|
+
const res = await http.post("/api/upload/preflight", JSON.stringify(req), {
|
|
751
|
+
headers: { "Content-Type": "application/json" },
|
|
752
|
+
signal
|
|
753
|
+
});
|
|
754
|
+
return res.data;
|
|
755
|
+
} catch (err) {
|
|
756
|
+
if (err instanceof VFSError && err.status === 404) {
|
|
757
|
+
return {
|
|
758
|
+
allowed: true,
|
|
759
|
+
bucket_id: req.bucket_id,
|
|
760
|
+
size: req.size ?? 0,
|
|
761
|
+
max_upload_bytes: 0
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
throw err;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/upload/single.ts
|
|
769
|
+
async function uploadSingleFile(http, input, defaultBucket = "default") {
|
|
770
|
+
const bucketId = input.bucketId || defaultBucket;
|
|
771
|
+
input.onProgress?.({
|
|
772
|
+
loaded: 0,
|
|
773
|
+
total: 100,
|
|
774
|
+
percent: 5,
|
|
775
|
+
phase: "hashing"
|
|
776
|
+
});
|
|
777
|
+
const normalized = await normalizeUploadInput(input);
|
|
778
|
+
let uploadToken;
|
|
779
|
+
if (input.preflight !== false) {
|
|
780
|
+
input.onProgress?.({
|
|
781
|
+
loaded: 0,
|
|
782
|
+
total: normalized.size,
|
|
783
|
+
percent: 15,
|
|
784
|
+
phase: "preflight"
|
|
785
|
+
});
|
|
786
|
+
const preflight = await executePreflight(
|
|
787
|
+
http,
|
|
788
|
+
{
|
|
789
|
+
bucket_id: bucketId,
|
|
790
|
+
name: normalized.name,
|
|
791
|
+
size: normalized.size,
|
|
792
|
+
content_type: normalized.contentType,
|
|
793
|
+
file_hash: normalized.hash
|
|
794
|
+
},
|
|
795
|
+
input.signal
|
|
796
|
+
);
|
|
797
|
+
if (preflight.duplicate) {
|
|
798
|
+
input.onProgress?.({
|
|
799
|
+
loaded: normalized.size,
|
|
800
|
+
total: normalized.size,
|
|
801
|
+
percent: 100,
|
|
802
|
+
phase: "complete"
|
|
803
|
+
});
|
|
804
|
+
return preflight.duplicate;
|
|
805
|
+
}
|
|
806
|
+
if (!preflight.allowed) {
|
|
807
|
+
throw new VFSError(preflight.reason || "Upload rejected by server preflight check", {
|
|
808
|
+
status: 400
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
uploadToken = preflight.upload_token;
|
|
812
|
+
}
|
|
813
|
+
input.onProgress?.({
|
|
814
|
+
loaded: 0,
|
|
815
|
+
total: normalized.size,
|
|
816
|
+
percent: 30,
|
|
817
|
+
phase: "uploading"
|
|
818
|
+
});
|
|
819
|
+
const formData = new FormData();
|
|
820
|
+
const blob = new Blob([normalized.bytes], { type: normalized.contentType });
|
|
821
|
+
formData.append("file", blob, normalized.name);
|
|
822
|
+
formData.append("bucket_id", bucketId);
|
|
823
|
+
if (normalized.hash) {
|
|
824
|
+
formData.append("file_hash", normalized.hash);
|
|
825
|
+
}
|
|
826
|
+
if (uploadToken) {
|
|
827
|
+
formData.append("upload_token", uploadToken);
|
|
828
|
+
}
|
|
829
|
+
if (input.metadata && Object.keys(input.metadata).length > 0) {
|
|
830
|
+
formData.append("metadata", JSON.stringify(input.metadata));
|
|
831
|
+
}
|
|
832
|
+
const res = await http.post("/api/upload", formData, {
|
|
833
|
+
signal: input.signal
|
|
834
|
+
});
|
|
835
|
+
input.onProgress?.({
|
|
836
|
+
loaded: normalized.size,
|
|
837
|
+
total: normalized.size,
|
|
838
|
+
percent: 100,
|
|
839
|
+
phase: "complete"
|
|
840
|
+
});
|
|
841
|
+
return res.data;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// src/upload/session.ts
|
|
845
|
+
var DEFAULT_CHUNK_SIZE = 1048576;
|
|
846
|
+
var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
847
|
+
function generateSessionId() {
|
|
848
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
849
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
850
|
+
}
|
|
851
|
+
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
|
|
852
|
+
}
|
|
853
|
+
async function uploadSessionFile(http, input, defaultBucket = "default") {
|
|
854
|
+
const bucketId = input.bucketId || defaultBucket;
|
|
855
|
+
input.onProgress?.({
|
|
856
|
+
loaded: 0,
|
|
857
|
+
total: 100,
|
|
858
|
+
percent: 5,
|
|
859
|
+
phase: "hashing"
|
|
860
|
+
});
|
|
861
|
+
const normalized = await normalizeUploadInput(input);
|
|
862
|
+
const sessionId = input.resumeId || generateSessionId();
|
|
863
|
+
const chunkSize = input.chunkSize || DEFAULT_CHUNK_SIZE;
|
|
864
|
+
input.onProgress?.({
|
|
865
|
+
loaded: 0,
|
|
866
|
+
total: normalized.size,
|
|
867
|
+
percent: 10,
|
|
868
|
+
phase: "preflight"
|
|
869
|
+
});
|
|
870
|
+
let session;
|
|
871
|
+
let failures = 0;
|
|
872
|
+
while (!session) {
|
|
873
|
+
if (input.signal?.aborted) {
|
|
874
|
+
throw new VFSError("Upload aborted", { status: 499 });
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
const res = await http.post(
|
|
878
|
+
"/api/upload/sessions",
|
|
879
|
+
JSON.stringify({
|
|
880
|
+
id: sessionId,
|
|
881
|
+
bucket_id: bucketId,
|
|
882
|
+
name: normalized.name,
|
|
883
|
+
size: normalized.size,
|
|
884
|
+
file_hash: normalized.hash,
|
|
885
|
+
content_type: normalized.contentType
|
|
886
|
+
}),
|
|
887
|
+
{
|
|
888
|
+
headers: { "Content-Type": "application/json" },
|
|
889
|
+
signal: input.signal,
|
|
890
|
+
timeout: 15e3
|
|
891
|
+
}
|
|
892
|
+
);
|
|
893
|
+
session = res.data;
|
|
894
|
+
} catch (err) {
|
|
895
|
+
if (err instanceof VFSError) {
|
|
896
|
+
if (err.status === 410) {
|
|
897
|
+
throw new VFSError("The upload session has expired. Start a new session.", { status: 410, cause: err });
|
|
898
|
+
}
|
|
899
|
+
if ([400, 401, 403, 409, 413, 422].includes(err.status)) {
|
|
900
|
+
throw err;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
failures++;
|
|
904
|
+
if (failures >= 5) {
|
|
905
|
+
throw new VFSError("Failed to initialize upload session after retries", { cause: err });
|
|
906
|
+
}
|
|
907
|
+
await delay(500 * Math.pow(2, failures - 1));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (!session) {
|
|
911
|
+
throw new VFSError("Failed to initialize upload session");
|
|
912
|
+
}
|
|
913
|
+
let currentSession = session;
|
|
914
|
+
failures = 0;
|
|
915
|
+
let offset = currentSession.offset;
|
|
916
|
+
while (["receiving", "paused"].includes(currentSession.state) && offset < normalized.size) {
|
|
917
|
+
if (input.signal?.aborted) {
|
|
918
|
+
throw new VFSError("Upload aborted", { status: 499 });
|
|
919
|
+
}
|
|
920
|
+
const currentChunkSize = Math.min(currentSession.chunk_size || chunkSize, normalized.size - offset);
|
|
921
|
+
const chunkBytes = normalized.bytes.subarray(offset, offset + currentChunkSize);
|
|
922
|
+
const chunkHash = await calculateSHA256(chunkBytes);
|
|
923
|
+
const percent = Math.min(95, 15 + Math.floor(offset / normalized.size * 80));
|
|
924
|
+
input.onProgress?.({
|
|
925
|
+
loaded: offset,
|
|
926
|
+
total: normalized.size,
|
|
927
|
+
percent,
|
|
928
|
+
phase: "chunks"
|
|
929
|
+
});
|
|
930
|
+
try {
|
|
931
|
+
const chunkRes = await http.put(
|
|
932
|
+
`/api/upload/sessions/${currentSession.id}/chunks/${offset}`,
|
|
933
|
+
// Pass Uint8Array slice directly as binary body
|
|
934
|
+
chunkBytes,
|
|
935
|
+
{
|
|
936
|
+
headers: {
|
|
937
|
+
"Content-Type": "application/octet-stream",
|
|
938
|
+
"X-Upload-SHA256": chunkHash
|
|
939
|
+
},
|
|
940
|
+
signal: input.signal,
|
|
941
|
+
timeout: 3e4
|
|
942
|
+
}
|
|
943
|
+
);
|
|
944
|
+
currentSession = chunkRes.data;
|
|
945
|
+
offset = currentSession.offset;
|
|
946
|
+
failures = 0;
|
|
947
|
+
} catch (err) {
|
|
948
|
+
failures++;
|
|
949
|
+
if (err instanceof VFSError && [400, 401, 403, 410, 413, 422].includes(err.status)) {
|
|
950
|
+
throw err;
|
|
951
|
+
}
|
|
952
|
+
if (failures >= 6) {
|
|
953
|
+
throw new VFSError(`Chunk upload failed at offset ${offset}: ${err instanceof Error ? err.message : "Unknown error"}`, {
|
|
954
|
+
cause: err
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
await delay(Math.min(500 * Math.pow(2, failures - 1), 8e3));
|
|
958
|
+
try {
|
|
959
|
+
const statusRes = await http.get(`/api/upload/sessions/${currentSession.id}`, {
|
|
960
|
+
timeout: 1e4,
|
|
961
|
+
signal: input.signal
|
|
962
|
+
});
|
|
963
|
+
currentSession = statusRes.data;
|
|
964
|
+
offset = currentSession.offset;
|
|
965
|
+
} catch {
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
input.onProgress?.({
|
|
970
|
+
loaded: normalized.size,
|
|
971
|
+
total: normalized.size,
|
|
972
|
+
percent: 96,
|
|
973
|
+
phase: "finalizing"
|
|
974
|
+
});
|
|
975
|
+
if (currentSession.state !== "complete") {
|
|
976
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
977
|
+
if (input.signal?.aborted) {
|
|
978
|
+
throw new VFSError("Upload aborted", { status: 499 });
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
const finalRes = await http.post(
|
|
982
|
+
`/api/upload/sessions/${currentSession.id}/finalize`,
|
|
983
|
+
"{}",
|
|
984
|
+
{
|
|
985
|
+
headers: { "Content-Type": "application/json" },
|
|
986
|
+
timeout: 15e3,
|
|
987
|
+
signal: input.signal
|
|
988
|
+
}
|
|
989
|
+
);
|
|
990
|
+
currentSession = finalRes.data;
|
|
991
|
+
break;
|
|
992
|
+
} catch (err) {
|
|
993
|
+
if (attempt >= 4) {
|
|
994
|
+
throw new VFSError("Failed to finalize session upload", { cause: err });
|
|
995
|
+
}
|
|
996
|
+
await delay(1e3);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
let pollAttempts = 0;
|
|
1001
|
+
while (currentSession.state !== "complete" && pollAttempts < 10) {
|
|
1002
|
+
await delay(1e3);
|
|
1003
|
+
try {
|
|
1004
|
+
const statusRes = await http.get(`/api/upload/sessions/${currentSession.id}`, {
|
|
1005
|
+
timeout: 5e3,
|
|
1006
|
+
signal: input.signal
|
|
1007
|
+
});
|
|
1008
|
+
currentSession = statusRes.data;
|
|
1009
|
+
if (currentSession.state === "complete") break;
|
|
1010
|
+
} catch {
|
|
1011
|
+
}
|
|
1012
|
+
pollAttempts++;
|
|
1013
|
+
}
|
|
1014
|
+
input.onProgress?.({
|
|
1015
|
+
loaded: normalized.size,
|
|
1016
|
+
total: normalized.size,
|
|
1017
|
+
percent: 100,
|
|
1018
|
+
phase: "complete"
|
|
1019
|
+
});
|
|
1020
|
+
const ext = normalized.name.split(".").pop() || "";
|
|
1021
|
+
const fileUrl = `${http.getEndpoint()}/v/${bucketId}/${currentSession.id}${ext ? `.${ext}` : ""}`;
|
|
1022
|
+
return {
|
|
1023
|
+
id: currentSession.id,
|
|
1024
|
+
bucket_id: bucketId,
|
|
1025
|
+
file_id: currentSession.id,
|
|
1026
|
+
file_hash: normalized.hash,
|
|
1027
|
+
extension: ext,
|
|
1028
|
+
name: normalized.name,
|
|
1029
|
+
size: normalized.size,
|
|
1030
|
+
type: normalized.contentType,
|
|
1031
|
+
metadata: input.metadata,
|
|
1032
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1033
|
+
url: fileUrl,
|
|
1034
|
+
key: `edge:${currentSession.id}`,
|
|
1035
|
+
store: "edge",
|
|
1036
|
+
is_stat_synced: true
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/upload/queue.ts
|
|
1041
|
+
var BackgroundUploadQueue = class {
|
|
1042
|
+
http;
|
|
1043
|
+
defaultBucket;
|
|
1044
|
+
concurrency;
|
|
1045
|
+
items = [];
|
|
1046
|
+
activeCount = 0;
|
|
1047
|
+
isPaused = false;
|
|
1048
|
+
abortController = new AbortController();
|
|
1049
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1050
|
+
onFileSuccessCb;
|
|
1051
|
+
onFileErrorCb;
|
|
1052
|
+
onCompletePromiseResolve;
|
|
1053
|
+
onCompletePromiseReject;
|
|
1054
|
+
constructor(http, options = {}) {
|
|
1055
|
+
this.http = http;
|
|
1056
|
+
this.defaultBucket = options.defaultBucket || "default";
|
|
1057
|
+
this.concurrency = Math.max(1, options.concurrency || 3);
|
|
1058
|
+
}
|
|
1059
|
+
onProgress(listener) {
|
|
1060
|
+
this.listeners.add(listener);
|
|
1061
|
+
return () => this.listeners.delete(listener);
|
|
1062
|
+
}
|
|
1063
|
+
onFileSuccess(cb) {
|
|
1064
|
+
this.onFileSuccessCb = cb;
|
|
1065
|
+
return this;
|
|
1066
|
+
}
|
|
1067
|
+
onFileError(cb) {
|
|
1068
|
+
this.onFileErrorCb = cb;
|
|
1069
|
+
return this;
|
|
1070
|
+
}
|
|
1071
|
+
add(input) {
|
|
1072
|
+
const id = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
|
|
1073
|
+
const item = {
|
|
1074
|
+
id,
|
|
1075
|
+
input,
|
|
1076
|
+
status: "pending",
|
|
1077
|
+
progress: 0
|
|
1078
|
+
};
|
|
1079
|
+
this.items.push(item);
|
|
1080
|
+
this.processQueue();
|
|
1081
|
+
return id;
|
|
1082
|
+
}
|
|
1083
|
+
addAll(inputs) {
|
|
1084
|
+
return inputs.map((input) => this.add(input));
|
|
1085
|
+
}
|
|
1086
|
+
pause() {
|
|
1087
|
+
this.isPaused = true;
|
|
1088
|
+
}
|
|
1089
|
+
resume() {
|
|
1090
|
+
if (this.isPaused) {
|
|
1091
|
+
this.isPaused = false;
|
|
1092
|
+
this.processQueue();
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
cancel() {
|
|
1096
|
+
this.abortController.abort();
|
|
1097
|
+
this.abortController = new AbortController();
|
|
1098
|
+
for (const item of this.items) {
|
|
1099
|
+
if (item.status === "pending" || item.status === "uploading") {
|
|
1100
|
+
item.status = "cancelled";
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
this.activeCount = 0;
|
|
1104
|
+
this.notifyProgress();
|
|
1105
|
+
this.onCompletePromiseReject?.(new Error("Upload queue cancelled"));
|
|
1106
|
+
}
|
|
1107
|
+
clear() {
|
|
1108
|
+
this.cancel();
|
|
1109
|
+
this.items = [];
|
|
1110
|
+
}
|
|
1111
|
+
getItems() {
|
|
1112
|
+
return [...this.items];
|
|
1113
|
+
}
|
|
1114
|
+
getProgress() {
|
|
1115
|
+
const totalFiles = this.items.length;
|
|
1116
|
+
if (totalFiles === 0) {
|
|
1117
|
+
return { totalFiles: 0, completedFiles: 0, failedFiles: 0, overallPercent: 100 };
|
|
1118
|
+
}
|
|
1119
|
+
let completedFiles = 0;
|
|
1120
|
+
let failedFiles = 0;
|
|
1121
|
+
let totalPercentSum = 0;
|
|
1122
|
+
let currentFile;
|
|
1123
|
+
for (const item of this.items) {
|
|
1124
|
+
if (item.status === "completed") {
|
|
1125
|
+
completedFiles++;
|
|
1126
|
+
totalPercentSum += 100;
|
|
1127
|
+
} else if (item.status === "failed") {
|
|
1128
|
+
failedFiles++;
|
|
1129
|
+
totalPercentSum += 100;
|
|
1130
|
+
} else if (item.status === "uploading") {
|
|
1131
|
+
currentFile = item.input.name;
|
|
1132
|
+
totalPercentSum += item.progress;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
const overallPercent = Math.round(totalPercentSum / totalFiles);
|
|
1136
|
+
return {
|
|
1137
|
+
totalFiles,
|
|
1138
|
+
completedFiles,
|
|
1139
|
+
failedFiles,
|
|
1140
|
+
overallPercent,
|
|
1141
|
+
currentFile
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
notifyProgress(item) {
|
|
1145
|
+
const progress = this.getProgress();
|
|
1146
|
+
for (const listener of this.listeners) {
|
|
1147
|
+
try {
|
|
1148
|
+
listener(progress, item);
|
|
1149
|
+
} catch {
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
async processQueue() {
|
|
1154
|
+
if (this.isPaused) return;
|
|
1155
|
+
while (this.activeCount < this.concurrency) {
|
|
1156
|
+
const nextItem = this.items.find((i) => i.status === "pending");
|
|
1157
|
+
if (!nextItem) break;
|
|
1158
|
+
this.activeCount++;
|
|
1159
|
+
nextItem.status = "uploading";
|
|
1160
|
+
this.notifyProgress(nextItem);
|
|
1161
|
+
this.uploadItem(nextItem).catch(() => {
|
|
1162
|
+
}).finally(() => {
|
|
1163
|
+
this.activeCount--;
|
|
1164
|
+
this.notifyProgress(nextItem);
|
|
1165
|
+
this.processQueue();
|
|
1166
|
+
this.checkCompletion();
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
async uploadItem(item) {
|
|
1171
|
+
const inputWithProgress = {
|
|
1172
|
+
...item.input,
|
|
1173
|
+
signal: item.input.signal || this.abortController.signal,
|
|
1174
|
+
onProgress: (p) => {
|
|
1175
|
+
item.progress = p.percent;
|
|
1176
|
+
item.input.onProgress?.(p);
|
|
1177
|
+
this.notifyProgress(item);
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
try {
|
|
1181
|
+
let result;
|
|
1182
|
+
if (item.input.resumable) {
|
|
1183
|
+
result = await uploadSessionFile(this.http, inputWithProgress, this.defaultBucket);
|
|
1184
|
+
} else if (item.input.preflight !== false) {
|
|
1185
|
+
const normalized = await normalizeUploadInput(item.input);
|
|
1186
|
+
const bucketId = item.input.bucketId || this.defaultBucket;
|
|
1187
|
+
const preflight = await executePreflight(
|
|
1188
|
+
this.http,
|
|
1189
|
+
{
|
|
1190
|
+
bucket_id: bucketId,
|
|
1191
|
+
name: normalized.name,
|
|
1192
|
+
size: normalized.size,
|
|
1193
|
+
content_type: normalized.contentType,
|
|
1194
|
+
file_hash: normalized.hash
|
|
1195
|
+
},
|
|
1196
|
+
inputWithProgress.signal
|
|
1197
|
+
);
|
|
1198
|
+
if (preflight.duplicate) {
|
|
1199
|
+
result = preflight.duplicate;
|
|
1200
|
+
} else if (preflight.upload_protocol === "edge-chunks-v1") {
|
|
1201
|
+
result = await uploadSessionFile(this.http, inputWithProgress, this.defaultBucket);
|
|
1202
|
+
} else {
|
|
1203
|
+
result = await uploadSingleFile(this.http, inputWithProgress, this.defaultBucket);
|
|
1204
|
+
}
|
|
1205
|
+
} else {
|
|
1206
|
+
result = await uploadSingleFile(this.http, inputWithProgress, this.defaultBucket);
|
|
1207
|
+
}
|
|
1208
|
+
item.status = "completed";
|
|
1209
|
+
item.progress = 100;
|
|
1210
|
+
item.response = result;
|
|
1211
|
+
this.onFileSuccessCb?.(item.input, result);
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
item.status = "failed";
|
|
1214
|
+
item.error = err instanceof Error ? err : new Error(String(err));
|
|
1215
|
+
this.onFileErrorCb?.(item.input, item.error);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
checkCompletion() {
|
|
1219
|
+
const hasPending = this.items.some((i) => i.status === "pending" || i.status === "uploading");
|
|
1220
|
+
if (!hasPending && this.activeCount === 0) {
|
|
1221
|
+
const successful = this.items.filter((i) => i.status === "completed" && i.response).map((i) => i.response);
|
|
1222
|
+
this.onCompletePromiseResolve?.(successful);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Returns a promise that resolves when all items currently in the queue have completed.
|
|
1227
|
+
*/
|
|
1228
|
+
async wait() {
|
|
1229
|
+
const hasPending = this.items.some((i) => i.status === "pending" || i.status === "uploading");
|
|
1230
|
+
if (!hasPending && this.activeCount === 0) {
|
|
1231
|
+
return this.items.filter((i) => i.status === "completed" && i.response).map((i) => i.response);
|
|
1232
|
+
}
|
|
1233
|
+
return new Promise((resolve, reject) => {
|
|
1234
|
+
this.onCompletePromiseResolve = resolve;
|
|
1235
|
+
this.onCompletePromiseReject = reject;
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
async function uploadMultipleFiles(http, input, defaultBucket = "default") {
|
|
1240
|
+
const queue = new BackgroundUploadQueue(http, {
|
|
1241
|
+
defaultBucket: input.bucketId || defaultBucket,
|
|
1242
|
+
concurrency: input.concurrency || 3
|
|
1243
|
+
});
|
|
1244
|
+
if (input.onProgress) {
|
|
1245
|
+
queue.onProgress((p) => input.onProgress?.(p));
|
|
1246
|
+
}
|
|
1247
|
+
if (input.onFileSuccess) {
|
|
1248
|
+
queue.onFileSuccess(input.onFileSuccess);
|
|
1249
|
+
}
|
|
1250
|
+
if (input.onFileError) {
|
|
1251
|
+
queue.onFileError(input.onFileError);
|
|
1252
|
+
}
|
|
1253
|
+
if (input.signal) {
|
|
1254
|
+
input.signal.addEventListener("abort", () => queue.cancel(), { once: true });
|
|
1255
|
+
}
|
|
1256
|
+
queue.addAll(input.files);
|
|
1257
|
+
return queue.wait();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// src/client.ts
|
|
1261
|
+
var VFSClient = class {
|
|
1262
|
+
http;
|
|
1263
|
+
defaultBucket;
|
|
1264
|
+
cache;
|
|
1265
|
+
constructor(options) {
|
|
1266
|
+
this.http = new HttpClient(options);
|
|
1267
|
+
this.defaultBucket = options.defaultBucket || "default";
|
|
1268
|
+
if (options.cache === true) {
|
|
1269
|
+
this.cache = createDefaultCache();
|
|
1270
|
+
} else if (options.cache && typeof options.cache === "object") {
|
|
1271
|
+
this.cache = options.cache;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Upload a single file to VFS.
|
|
1276
|
+
* Proactively checks preflight quota and deduplication: if identical file content exists,
|
|
1277
|
+
* skips transfer and returns the existing file immediately.
|
|
1278
|
+
*/
|
|
1279
|
+
async upload(input) {
|
|
1280
|
+
if (input.resumable) {
|
|
1281
|
+
return uploadSessionFile(this.http, input, this.defaultBucket);
|
|
1282
|
+
}
|
|
1283
|
+
return uploadSingleFile(this.http, input, this.defaultBucket);
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Upload multiple files with concurrency control, progress reporting, and stream/chunk support.
|
|
1287
|
+
*/
|
|
1288
|
+
async uploadMultiple(input) {
|
|
1289
|
+
return uploadMultipleFiles(this.http, input, this.defaultBucket);
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Creates a background upload queue manager for continuous uploads with pause/resume and events.
|
|
1293
|
+
*/
|
|
1294
|
+
createBackgroundQueue(options) {
|
|
1295
|
+
return new BackgroundUploadQueue(this.http, {
|
|
1296
|
+
defaultBucket: options?.bucketId || this.defaultBucket,
|
|
1297
|
+
concurrency: options?.concurrency || 3
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Retrieves a file from VFS with multi-tier caching (Browser CacheStorage / Server Filesystem).
|
|
1302
|
+
*/
|
|
1303
|
+
async getFile(bucketId, fileId, options = {}) {
|
|
1304
|
+
const cacheKey = `vfs:${bucketId}:${fileId}`;
|
|
1305
|
+
const shouldUseCache = options.useCache !== false && Boolean(this.cache);
|
|
1306
|
+
if (shouldUseCache && this.cache) {
|
|
1307
|
+
const cached = await this.cache.get(cacheKey);
|
|
1308
|
+
if (cached) {
|
|
1309
|
+
if (options.responseType === "arrayBuffer") {
|
|
1310
|
+
return cached.data.buffer.slice(cached.data.byteOffset, cached.data.byteOffset + cached.data.byteLength);
|
|
1311
|
+
}
|
|
1312
|
+
if (options.responseType === "text") {
|
|
1313
|
+
return new TextDecoder().decode(cached.data);
|
|
1314
|
+
}
|
|
1315
|
+
if (options.responseType === "json") {
|
|
1316
|
+
return JSON.parse(new TextDecoder().decode(cached.data));
|
|
1317
|
+
}
|
|
1318
|
+
if (options.responseType === "stream") {
|
|
1319
|
+
return new ReadableStream({
|
|
1320
|
+
start(controller) {
|
|
1321
|
+
controller.enqueue(cached.data);
|
|
1322
|
+
controller.close();
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
return new Blob([cached.data], { type: cached.contentType });
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
const params = {};
|
|
1330
|
+
if (options.download) {
|
|
1331
|
+
params.download = true;
|
|
1332
|
+
}
|
|
1333
|
+
if (options.imgproxy) {
|
|
1334
|
+
if (options.imgproxy.width) params.w = options.imgproxy.width;
|
|
1335
|
+
if (options.imgproxy.height) params.h = options.imgproxy.height;
|
|
1336
|
+
if (options.imgproxy.resizingType) params.rs = options.imgproxy.resizingType;
|
|
1337
|
+
if (options.imgproxy.format) params.fmt = options.imgproxy.format;
|
|
1338
|
+
if (options.imgproxy.quality) params.q = options.imgproxy.quality;
|
|
1339
|
+
}
|
|
1340
|
+
const response = await this.http.fetchRaw(`/v/${encodeURIComponent(bucketId)}/${encodeURIComponent(fileId)}`, {
|
|
1341
|
+
params,
|
|
1342
|
+
signal: options.signal
|
|
1343
|
+
});
|
|
1344
|
+
const contentType = response.headers.get("content-type") || "application/octet-stream";
|
|
1345
|
+
const contentDisposition = response.headers.get("content-disposition") || "";
|
|
1346
|
+
let filename;
|
|
1347
|
+
const match = contentDisposition.match(/filename="?([^";]+)"?/);
|
|
1348
|
+
if (match) filename = match[1];
|
|
1349
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
1350
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
1351
|
+
if (shouldUseCache && this.cache) {
|
|
1352
|
+
await this.cache.set(cacheKey, {
|
|
1353
|
+
data: bytes,
|
|
1354
|
+
contentType,
|
|
1355
|
+
name: filename,
|
|
1356
|
+
size: bytes.byteLength,
|
|
1357
|
+
createdAt: Date.now()
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
if (options.responseType === "arrayBuffer") {
|
|
1361
|
+
return arrayBuffer;
|
|
1362
|
+
}
|
|
1363
|
+
if (options.responseType === "text") {
|
|
1364
|
+
return new TextDecoder().decode(bytes);
|
|
1365
|
+
}
|
|
1366
|
+
if (options.responseType === "json") {
|
|
1367
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
1368
|
+
}
|
|
1369
|
+
if (options.responseType === "stream") {
|
|
1370
|
+
return new ReadableStream({
|
|
1371
|
+
start(controller) {
|
|
1372
|
+
controller.enqueue(bytes);
|
|
1373
|
+
controller.close();
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
return new Blob([bytes], { type: contentType });
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Retrieves a file directly by its SHA-256 hash (/h/:hash).
|
|
1381
|
+
* Hash-addressed content is immutable and cached indefinitely.
|
|
1382
|
+
*/
|
|
1383
|
+
async getFileByHash(hash, options = {}) {
|
|
1384
|
+
const cacheKey = `vfs:hash:${hash}`;
|
|
1385
|
+
const shouldUseCache = options.useCache !== false && Boolean(this.cache);
|
|
1386
|
+
if (shouldUseCache && this.cache) {
|
|
1387
|
+
const cached = await this.cache.get(cacheKey);
|
|
1388
|
+
if (cached) {
|
|
1389
|
+
if (options.responseType === "arrayBuffer") {
|
|
1390
|
+
return cached.data.buffer.slice(cached.data.byteOffset, cached.data.byteOffset + cached.data.byteLength);
|
|
1391
|
+
}
|
|
1392
|
+
if (options.responseType === "text") {
|
|
1393
|
+
return new TextDecoder().decode(cached.data);
|
|
1394
|
+
}
|
|
1395
|
+
if (options.responseType === "json") {
|
|
1396
|
+
return JSON.parse(new TextDecoder().decode(cached.data));
|
|
1397
|
+
}
|
|
1398
|
+
return new Blob([cached.data], { type: cached.contentType });
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
const response = await this.http.fetchRaw(`/h/${encodeURIComponent(hash)}`, {
|
|
1402
|
+
signal: options.signal
|
|
1403
|
+
});
|
|
1404
|
+
const contentType = response.headers.get("content-type") || "application/octet-stream";
|
|
1405
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
1406
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
1407
|
+
if (shouldUseCache && this.cache) {
|
|
1408
|
+
await this.cache.set(
|
|
1409
|
+
cacheKey,
|
|
1410
|
+
{
|
|
1411
|
+
data: bytes,
|
|
1412
|
+
contentType,
|
|
1413
|
+
size: bytes.byteLength,
|
|
1414
|
+
hash,
|
|
1415
|
+
createdAt: Date.now()
|
|
1416
|
+
},
|
|
1417
|
+
31536e3
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
if (options.responseType === "arrayBuffer") {
|
|
1421
|
+
return arrayBuffer;
|
|
1422
|
+
}
|
|
1423
|
+
if (options.responseType === "text") {
|
|
1424
|
+
return new TextDecoder().decode(bytes);
|
|
1425
|
+
}
|
|
1426
|
+
if (options.responseType === "json") {
|
|
1427
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
1428
|
+
}
|
|
1429
|
+
return new Blob([bytes], { type: contentType });
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Gets metadata manifest of an uploaded file as a JSON object without downloading the file data.
|
|
1433
|
+
*/
|
|
1434
|
+
async getFileMetadata(bucketId, fileId, signal) {
|
|
1435
|
+
const res = await this.http.get(
|
|
1436
|
+
`/v/${encodeURIComponent(bucketId)}/${encodeURIComponent(fileId)}`,
|
|
1437
|
+
{
|
|
1438
|
+
params: { output: "json" },
|
|
1439
|
+
headers: { Accept: "application/json" },
|
|
1440
|
+
signal
|
|
1441
|
+
}
|
|
1442
|
+
);
|
|
1443
|
+
return res.data;
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Gets metadata manifest of a file by its SHA-256 hash.
|
|
1447
|
+
*/
|
|
1448
|
+
async getFileMetadataByHash(hash, signal) {
|
|
1449
|
+
const res = await this.http.get(`/h/${encodeURIComponent(hash)}`, {
|
|
1450
|
+
params: { output: "json" },
|
|
1451
|
+
headers: { Accept: "application/json" },
|
|
1452
|
+
signal
|
|
1453
|
+
});
|
|
1454
|
+
return res.data;
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Generates public/access URL for a file, supporting imgproxy transformations and download triggers.
|
|
1458
|
+
*/
|
|
1459
|
+
getFileUrl(bucketId, fileId, options = {}) {
|
|
1460
|
+
const url = new URL(`${this.http.getEndpoint()}/v/${encodeURIComponent(bucketId)}/${encodeURIComponent(fileId)}`);
|
|
1461
|
+
if (options.download) {
|
|
1462
|
+
url.searchParams.set("download", "true");
|
|
1463
|
+
}
|
|
1464
|
+
if (options.imgproxy) {
|
|
1465
|
+
if (options.imgproxy.width) url.searchParams.set("w", String(options.imgproxy.width));
|
|
1466
|
+
if (options.imgproxy.height) url.searchParams.set("h", String(options.imgproxy.height));
|
|
1467
|
+
if (options.imgproxy.resizingType) url.searchParams.set("rs", options.imgproxy.resizingType);
|
|
1468
|
+
if (options.imgproxy.format) url.searchParams.set("fmt", options.imgproxy.format);
|
|
1469
|
+
if (options.imgproxy.quality) url.searchParams.set("q", String(options.imgproxy.quality));
|
|
1470
|
+
}
|
|
1471
|
+
return url.toString();
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Generates public URL for a file addressed by its SHA-256 hash.
|
|
1475
|
+
*/
|
|
1476
|
+
getFileUrlByHash(hash) {
|
|
1477
|
+
return `${this.http.getEndpoint()}/h/${encodeURIComponent(hash)}`;
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Deletes a file by bucket ID and file ID, invalidating any cached copies.
|
|
1481
|
+
*/
|
|
1482
|
+
async deleteFile(bucketId, fileId, signal) {
|
|
1483
|
+
try {
|
|
1484
|
+
await this.http.delete(`/api/upload/unlink/${encodeURIComponent(bucketId)}/${encodeURIComponent(fileId)}`, {
|
|
1485
|
+
signal
|
|
1486
|
+
});
|
|
1487
|
+
} catch (err) {
|
|
1488
|
+
if (err instanceof VFSError && err.status === 404) ; else {
|
|
1489
|
+
throw err;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
if (this.cache) {
|
|
1493
|
+
await this.cache.delete(`vfs:${bucketId}:${fileId}`);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Deletes a file record by catalog ID.
|
|
1498
|
+
*/
|
|
1499
|
+
async deleteFileById(id, signal) {
|
|
1500
|
+
await this.http.delete(`/api/upload/${encodeURIComponent(id)}`, { signal });
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Deletes all files within a specified bucket.
|
|
1504
|
+
*/
|
|
1505
|
+
async deleteAllInBucket(bucketId, signal) {
|
|
1506
|
+
await this.http.delete(`/api/upload/bucket/${encodeURIComponent(bucketId)}`, { signal });
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Lists stored objects in a bucket.
|
|
1510
|
+
*/
|
|
1511
|
+
async listFiles(options = {}) {
|
|
1512
|
+
const bucketId = options.bucketId || this.defaultBucket;
|
|
1513
|
+
const res = await this.http.get("/api/upload", {
|
|
1514
|
+
params: { bucket_id: bucketId },
|
|
1515
|
+
signal: options.signal
|
|
1516
|
+
});
|
|
1517
|
+
return res.data;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Gets real-time statistics for a bucket (total files, storage usage, read/write counts, quota limits).
|
|
1521
|
+
*/
|
|
1522
|
+
async getBucketStats(bucketId, signal) {
|
|
1523
|
+
const targetBucket = bucketId || this.defaultBucket;
|
|
1524
|
+
const res = await this.http.get(`/api/bucket/stats/${encodeURIComponent(targetBucket)}`, { signal });
|
|
1525
|
+
return res.data;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Gets a list of all bucket statistics.
|
|
1529
|
+
*/
|
|
1530
|
+
async getAllBucketStats(params, signal) {
|
|
1531
|
+
const res = await this.http.get("/api/bucket/stats", {
|
|
1532
|
+
params,
|
|
1533
|
+
signal
|
|
1534
|
+
});
|
|
1535
|
+
return {
|
|
1536
|
+
items: res.data,
|
|
1537
|
+
total: res.totalCount
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Gets overall system summary of bucket statistics (total buckets, total files, total storage size).
|
|
1542
|
+
*/
|
|
1543
|
+
async getBucketSummary(signal) {
|
|
1544
|
+
const res = await this.http.get("/api/bucket/stats/summary", { signal });
|
|
1545
|
+
return res.data;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Gets date-based usage growth metrics for a bucket.
|
|
1549
|
+
*/
|
|
1550
|
+
async getBucketMetrics(bucketId, signal) {
|
|
1551
|
+
const targetBucket = bucketId || this.defaultBucket;
|
|
1552
|
+
const res = await this.http.get(
|
|
1553
|
+
`/api/bucket/stats/${encodeURIComponent(targetBucket)}/metrics`,
|
|
1554
|
+
{ signal }
|
|
1555
|
+
);
|
|
1556
|
+
return res.data;
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
exports.BackgroundUploadQueue = BackgroundUploadQueue;
|
|
1561
|
+
exports.BrowserCacheAdapter = BrowserCacheAdapter;
|
|
1562
|
+
exports.FileSystemCacheAdapter = FileSystemCacheAdapter;
|
|
1563
|
+
exports.HttpClient = HttpClient;
|
|
1564
|
+
exports.MemoryCacheAdapter = MemoryCacheAdapter;
|
|
1565
|
+
exports.VFSClient = VFSClient;
|
|
1566
|
+
exports.VFSError = VFSError;
|
|
1567
|
+
exports.calculateBlobSHA256 = calculateBlobSHA256;
|
|
1568
|
+
exports.calculateSHA256 = calculateSHA256;
|
|
1569
|
+
exports.createDefaultCache = createDefaultCache;
|
|
1570
|
+
exports.executePreflight = executePreflight;
|
|
1571
|
+
exports.guessContentType = guessContentType;
|
|
1572
|
+
exports.normalizeUploadInput = normalizeUploadInput;
|
|
1573
|
+
exports.uploadMultipleFiles = uploadMultipleFiles;
|
|
1574
|
+
exports.uploadSessionFile = uploadSessionFile;
|
|
1575
|
+
exports.uploadSingleFile = uploadSingleFile;
|
|
1576
|
+
//# sourceMappingURL=index.cjs.map
|
|
1577
|
+
//# sourceMappingURL=index.cjs.map
|