@velajs/storage 0.2.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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/base64.d.ts +8 -0
- package/dist/base64.js +25 -0
- package/dist/client/index.d.ts +50 -0
- package/dist/client/index.js +243 -0
- package/dist/decorators/inject-storage.decorator.d.ts +17 -0
- package/dist/decorators/inject-storage.decorator.js +21 -0
- package/dist/drivers/memory/index.d.ts +16 -0
- package/dist/drivers/memory/index.js +202 -0
- package/dist/drivers/r2/index.d.ts +11 -0
- package/dist/drivers/r2/index.js +166 -0
- package/dist/drivers/r2/r2.types.d.ts +67 -0
- package/dist/drivers/r2/r2.types.js +5 -0
- package/dist/drivers/r2-http/index.d.ts +33 -0
- package/dist/drivers/r2-http/index.js +52 -0
- package/dist/drivers/s3/index.d.ts +5 -0
- package/dist/drivers/s3/index.js +3 -0
- package/dist/drivers/s3/s3-client.d.ts +33 -0
- package/dist/drivers/s3/s3-client.js +154 -0
- package/dist/drivers/s3/s3.driver.d.ts +4 -0
- package/dist/drivers/s3/s3.driver.js +404 -0
- package/dist/drivers/s3/s3.types.d.ts +28 -0
- package/dist/drivers/s3/s3.types.js +1 -0
- package/dist/drivers/s3/xml.d.ts +6 -0
- package/dist/drivers/s3/xml.js +52 -0
- package/dist/http/authorizer.types.d.ts +59 -0
- package/dist/http/authorizer.types.js +1 -0
- package/dist/http/http-helpers.d.ts +22 -0
- package/dist/http/http-helpers.js +80 -0
- package/dist/http/protocol.types.d.ts +76 -0
- package/dist/http/protocol.types.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +16 -0
- package/dist/internal/body.d.ts +18 -0
- package/dist/internal/body.js +90 -0
- package/dist/internal/retry.d.ts +41 -0
- package/dist/internal/retry.js +127 -0
- package/dist/internal/stored-file.d.ts +27 -0
- package/dist/internal/stored-file.js +114 -0
- package/dist/middleware/cache.d.ts +50 -0
- package/dist/middleware/cache.js +101 -0
- package/dist/middleware/compose.d.ts +12 -0
- package/dist/middleware/compose.js +11 -0
- package/dist/middleware/compression.d.ts +16 -0
- package/dist/middleware/compression.js +101 -0
- package/dist/middleware/encryption.d.ts +14 -0
- package/dist/middleware/encryption.js +134 -0
- package/dist/middleware/failover.d.ts +18 -0
- package/dist/middleware/failover.js +52 -0
- package/dist/middleware/index.d.ts +15 -0
- package/dist/middleware/index.js +8 -0
- package/dist/middleware/retry.d.ts +14 -0
- package/dist/middleware/retry.js +47 -0
- package/dist/middleware/versioning.d.ts +36 -0
- package/dist/middleware/versioning.js +89 -0
- package/dist/middleware/wrap.d.ts +11 -0
- package/dist/middleware/wrap.js +46 -0
- package/dist/object-key.d.ts +17 -0
- package/dist/object-key.js +42 -0
- package/dist/storage.controller.d.ts +21 -0
- package/dist/storage.controller.js +380 -0
- package/dist/storage.error.d.ts +28 -0
- package/dist/storage.error.js +46 -0
- package/dist/storage.facade.d.ts +47 -0
- package/dist/storage.facade.js +443 -0
- package/dist/storage.module.d.ts +46 -0
- package/dist/storage.module.js +113 -0
- package/dist/storage.service.d.ts +42 -0
- package/dist/storage.service.js +86 -0
- package/dist/storage.tokens.d.ts +13 -0
- package/dist/storage.tokens.js +26 -0
- package/dist/storage.types.d.ts +245 -0
- package/dist/storage.types.js +1 -0
- package/dist/storagesdk/index.d.ts +85 -0
- package/dist/storagesdk/index.js +136 -0
- package/package.json +116 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single error taxonomy for `@velajs/storage`. Every driver maps its
|
|
3
|
+
* provider/transport failures onto a {@link StorageErrorCode}; the facade's
|
|
4
|
+
* capability gates throw `Unsupported`; the retry loop keys off
|
|
5
|
+
* {@link StorageError.retryable}; and the HTTP controller maps codes to
|
|
6
|
+
* status via its own `codeToHttp()` table.
|
|
7
|
+
*/ /** Codes that are safe to retry by default (transient failures). */ const RETRYABLE = new Set([
|
|
8
|
+
'Network',
|
|
9
|
+
'RateLimited',
|
|
10
|
+
'Timeout',
|
|
11
|
+
'Provider'
|
|
12
|
+
]);
|
|
13
|
+
export class StorageError extends Error {
|
|
14
|
+
name = 'StorageError';
|
|
15
|
+
code;
|
|
16
|
+
retryable;
|
|
17
|
+
status;
|
|
18
|
+
constructor(code, message, options){
|
|
19
|
+
super(message, options?.cause !== undefined ? {
|
|
20
|
+
cause: options.cause
|
|
21
|
+
} : undefined);
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.status = options?.status;
|
|
24
|
+
this.retryable = options?.retryable ?? RETRYABLE.has(code);
|
|
25
|
+
}
|
|
26
|
+
/** Wrap an arbitrary thrown value as a `StorageError` (idempotent). */ static wrap(e) {
|
|
27
|
+
if (e instanceof StorageError) return e;
|
|
28
|
+
if (isAbort(e)) return new StorageError('Aborted', 'operation aborted', {
|
|
29
|
+
cause: e
|
|
30
|
+
});
|
|
31
|
+
return new StorageError('Provider', e instanceof Error ? e.message : String(e), {
|
|
32
|
+
cause: e
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Map a transport status code to a `StorageError` (5xx/429 retryable). */ static fromStatus(status, message, cause) {
|
|
36
|
+
const code = status === 404 ? 'NotFound' : status === 403 || status === 401 ? 'AccessDenied' : status === 409 ? 'Conflict' : status === 429 ? 'RateLimited' : status >= 500 ? 'Provider' : 'InvalidRequest';
|
|
37
|
+
return new StorageError(code, message, {
|
|
38
|
+
status,
|
|
39
|
+
cause,
|
|
40
|
+
retryable: status === 429 || status >= 500
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** True for `AbortError`-shaped values (DOMException or `{ name: 'AbortError' }`). */ export function isAbort(e) {
|
|
45
|
+
return typeof e === 'object' && e !== null && 'name' in e && e.name === 'AbortError';
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Body, CreateMultipartOptions, DeleteManyOptions, DeleteManyResult, DownloadOptions, ListOptions, ListResult, MultipartUpload, OperationOptions, SignedMultipartCapability, SignedUpload, SignUploadOptions, StorageCapabilities, StorageDriver, StorageOptions, StoredFile, UploadOptions, UploadResult, UrlOptions } from './storage.types';
|
|
2
|
+
/**
|
|
3
|
+
* The consumer-facing storage handle. Wraps a {@link StorageDriver} with
|
|
4
|
+
* capability-gating (throws before any driver call), retry/timeout/abort,
|
|
5
|
+
* prefix scoping, generic `move`/`deleteMany` fallbacks, multipart
|
|
6
|
+
* orchestration, progress synthesis, and observability hooks.
|
|
7
|
+
*
|
|
8
|
+
* Framework-free — `StorageService` builds and caches one of these per bucket.
|
|
9
|
+
*/
|
|
10
|
+
export declare class Storage {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(opts: StorageOptions);
|
|
13
|
+
get raw(): unknown;
|
|
14
|
+
get driver(): StorageDriver;
|
|
15
|
+
get capabilities(): StorageCapabilities;
|
|
16
|
+
upload(key: string, body: Body, opts?: UploadOptions): Promise<UploadResult>;
|
|
17
|
+
download(key: string, opts?: DownloadOptions): Promise<StoredFile>;
|
|
18
|
+
head(key: string, opts?: OperationOptions): Promise<StoredFile>;
|
|
19
|
+
exists(key: string, opts?: OperationOptions): Promise<boolean>;
|
|
20
|
+
delete(key: string, opts?: OperationOptions): Promise<void>;
|
|
21
|
+
delete(keys: string[], opts?: DeleteManyOptions): Promise<DeleteManyResult>;
|
|
22
|
+
copy(from: string, to: string, opts?: OperationOptions): Promise<void>;
|
|
23
|
+
move(from: string, to: string, opts?: OperationOptions): Promise<void>;
|
|
24
|
+
list(opts?: ListOptions): Promise<ListResult>;
|
|
25
|
+
listAll(opts?: ListOptions): AsyncGenerator<StoredFile>;
|
|
26
|
+
url(key: string, opts?: UrlOptions): Promise<string>;
|
|
27
|
+
signedUploadUrl(key: string, opts: SignUploadOptions): Promise<SignedUpload>;
|
|
28
|
+
/** Prefix-bound view of the driver's presigned-multipart capability. */
|
|
29
|
+
get signedMultipart(): SignedMultipartCapability | undefined;
|
|
30
|
+
createMultipartUpload(key: string, opts?: CreateMultipartOptions): Promise<MultipartUpload>;
|
|
31
|
+
resumeMultipartUpload(key: string, uploadId: string): MultipartUpload;
|
|
32
|
+
/** A key-bound handle over the common single-object operations. */
|
|
33
|
+
file(key: string): FileHandle;
|
|
34
|
+
/** A read-only clone of this handle (mutations throw `ReadOnly`). */
|
|
35
|
+
readonly(): Storage;
|
|
36
|
+
}
|
|
37
|
+
export interface FileHandle {
|
|
38
|
+
readonly key: string;
|
|
39
|
+
upload(body: Body, opts?: UploadOptions): Promise<UploadResult>;
|
|
40
|
+
download(opts?: DownloadOptions): Promise<StoredFile>;
|
|
41
|
+
head(opts?: OperationOptions): Promise<StoredFile>;
|
|
42
|
+
exists(opts?: OperationOptions): Promise<boolean>;
|
|
43
|
+
delete(opts?: OperationOptions): Promise<void>;
|
|
44
|
+
url(opts?: UrlOptions): Promise<string>;
|
|
45
|
+
signedUploadUrl(opts: SignUploadOptions): Promise<SignedUpload>;
|
|
46
|
+
}
|
|
47
|
+
export declare function createStorage(opts: StorageOptions): Storage;
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { byteLengthOf, chunkStream, countingStream, isStream, toStream } from "./internal/body.js";
|
|
2
|
+
import { normalizeRetry, runWithRetry } from "./internal/retry.js";
|
|
3
|
+
import { joinKey, normalizePrefix, sanitizeKey, stripPrefix } from "./object-key.js";
|
|
4
|
+
import { StorageError } from "./storage.error.js";
|
|
5
|
+
const DEFAULT_PART_SIZE = 5 * 1024 * 1024; // 5 MiB (S3 minimum non-final part)
|
|
6
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
7
|
+
// Known-length bodies larger than this are routed through multipart so we never
|
|
8
|
+
// buffer a whole large object in memory (aws4fetch must hash the payload).
|
|
9
|
+
const MULTIPART_THRESHOLD = 16 * 1024 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* The consumer-facing storage handle. Wraps a {@link StorageDriver} with
|
|
12
|
+
* capability-gating (throws before any driver call), retry/timeout/abort,
|
|
13
|
+
* prefix scoping, generic `move`/`deleteMany` fallbacks, multipart
|
|
14
|
+
* orchestration, progress synthesis, and observability hooks.
|
|
15
|
+
*
|
|
16
|
+
* Framework-free — `StorageService` builds and caches one of these per bucket.
|
|
17
|
+
*/ export class Storage {
|
|
18
|
+
#driver;
|
|
19
|
+
#prefix;
|
|
20
|
+
#readonly;
|
|
21
|
+
#opts;
|
|
22
|
+
constructor(opts){
|
|
23
|
+
this.#opts = opts;
|
|
24
|
+
this.#driver = opts.driver;
|
|
25
|
+
this.#prefix = normalizePrefix(opts.prefix);
|
|
26
|
+
this.#readonly = opts.readonly ?? false;
|
|
27
|
+
}
|
|
28
|
+
get raw() {
|
|
29
|
+
return this.#driver.raw;
|
|
30
|
+
}
|
|
31
|
+
get driver() {
|
|
32
|
+
return this.#driver;
|
|
33
|
+
}
|
|
34
|
+
get capabilities() {
|
|
35
|
+
const d = this.#driver;
|
|
36
|
+
return {
|
|
37
|
+
rangeRead: !!d.supportsRange,
|
|
38
|
+
uploadProgress: !!d.reportsUploadProgress,
|
|
39
|
+
delimiter: !!d.supportsDelimiter,
|
|
40
|
+
metadata: !!d.supportsMetadata,
|
|
41
|
+
cacheControl: !!d.supportsCacheControl,
|
|
42
|
+
serverSideCopy: !!d.supportsServerSideCopy,
|
|
43
|
+
multipart: typeof d.createMultipartUpload === 'function',
|
|
44
|
+
signedMultipart: d.signedMultipart != null,
|
|
45
|
+
signedUrl: d.signedUrl ?? {
|
|
46
|
+
supported: false,
|
|
47
|
+
upload: false
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ---- upload ------------------------------------------------------------
|
|
52
|
+
async upload(key, body, opts) {
|
|
53
|
+
this.#assertWritable();
|
|
54
|
+
this.#assertUploadSupported(opts);
|
|
55
|
+
const d = this.#driver;
|
|
56
|
+
const path = this.#path(key);
|
|
57
|
+
const len = byteLengthOf(body);
|
|
58
|
+
const wantsMultipart = opts?.multipart != null && opts.multipart !== false;
|
|
59
|
+
const unknownStream = isStream(body) && len === undefined;
|
|
60
|
+
const bigKnown = len != null && len > MULTIPART_THRESHOLD;
|
|
61
|
+
const canMultipart = typeof d.createMultipartUpload === 'function';
|
|
62
|
+
if (wantsMultipart && !canMultipart) {
|
|
63
|
+
throw new StorageError('Unsupported', `${d.name}: multipart not supported`);
|
|
64
|
+
}
|
|
65
|
+
if ((wantsMultipart || unknownStream || bigKnown) && canMultipart) {
|
|
66
|
+
const result = await this.#exec('upload', key, false, opts, ()=>this.#putMultipart(path, body, opts));
|
|
67
|
+
return {
|
|
68
|
+
...result,
|
|
69
|
+
key
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const streaming = isStream(body);
|
|
73
|
+
let outBody = body;
|
|
74
|
+
if (opts?.onProgress && !d.reportsUploadProgress && streaming) {
|
|
75
|
+
outBody = countingStream(body, (loaded)=>opts.onProgress({
|
|
76
|
+
loaded,
|
|
77
|
+
total: len
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
const result = await this.#exec('upload', key, !streaming, opts, (signal)=>d.upload(path, outBody, {
|
|
81
|
+
...opts,
|
|
82
|
+
signal
|
|
83
|
+
}));
|
|
84
|
+
if (opts?.onProgress && !d.reportsUploadProgress && !streaming && len != null) {
|
|
85
|
+
opts.onProgress({
|
|
86
|
+
loaded: len,
|
|
87
|
+
total: len
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...result,
|
|
92
|
+
key
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async #putMultipart(path, body, opts) {
|
|
96
|
+
const mp = typeof opts?.multipart === 'object' ? opts.multipart : {};
|
|
97
|
+
const partSize = mp.partSize ?? DEFAULT_PART_SIZE;
|
|
98
|
+
const concurrency = Math.max(1, mp.concurrency ?? DEFAULT_CONCURRENCY);
|
|
99
|
+
const total = byteLengthOf(body);
|
|
100
|
+
const upload = await this.#driver.createMultipartUpload(path, {
|
|
101
|
+
contentType: opts?.contentType,
|
|
102
|
+
cacheControl: opts?.cacheControl,
|
|
103
|
+
metadata: opts?.metadata,
|
|
104
|
+
signal: opts?.signal
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
const parts = [];
|
|
108
|
+
let uploadedBytes = 0;
|
|
109
|
+
let partNumber = 0;
|
|
110
|
+
const inflight = new Set();
|
|
111
|
+
for await (const chunk of chunkStream(toStream(body), partSize)){
|
|
112
|
+
partNumber += 1;
|
|
113
|
+
const n = partNumber;
|
|
114
|
+
const size = chunk.byteLength;
|
|
115
|
+
const task = (async ()=>{
|
|
116
|
+
const up = await upload.uploadPart(n, chunk, {
|
|
117
|
+
signal: opts?.signal
|
|
118
|
+
});
|
|
119
|
+
parts[n - 1] = {
|
|
120
|
+
partNumber: n,
|
|
121
|
+
etag: up.etag,
|
|
122
|
+
size: up.size ?? size
|
|
123
|
+
};
|
|
124
|
+
uploadedBytes += size;
|
|
125
|
+
opts?.onProgress?.({
|
|
126
|
+
loaded: uploadedBytes,
|
|
127
|
+
total
|
|
128
|
+
});
|
|
129
|
+
})();
|
|
130
|
+
const tracked = task.finally(()=>inflight.delete(tracked));
|
|
131
|
+
inflight.add(tracked);
|
|
132
|
+
if (inflight.size >= concurrency) await Promise.race(inflight);
|
|
133
|
+
}
|
|
134
|
+
await Promise.all(inflight);
|
|
135
|
+
return await upload.complete(parts, {
|
|
136
|
+
signal: opts?.signal
|
|
137
|
+
});
|
|
138
|
+
} catch (e) {
|
|
139
|
+
await upload.abort().catch(()=>{});
|
|
140
|
+
throw StorageError.wrap(e);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ---- read / metadata ---------------------------------------------------
|
|
144
|
+
async download(key, opts) {
|
|
145
|
+
if (opts?.range) this.#assertRange();
|
|
146
|
+
const file = await this.#exec('download', key, true, opts, (signal)=>this.#driver.download(this.#path(key), {
|
|
147
|
+
...opts,
|
|
148
|
+
signal
|
|
149
|
+
}));
|
|
150
|
+
return this.#relabel(file, key);
|
|
151
|
+
}
|
|
152
|
+
async head(key, opts) {
|
|
153
|
+
const file = await this.#exec('head', key, true, opts, (signal)=>this.#driver.head(this.#path(key), {
|
|
154
|
+
...opts,
|
|
155
|
+
signal
|
|
156
|
+
}));
|
|
157
|
+
return this.#relabel(file, key);
|
|
158
|
+
}
|
|
159
|
+
exists(key, opts) {
|
|
160
|
+
return this.#exec('exists', key, true, opts, (signal)=>this.#driver.exists(this.#path(key), {
|
|
161
|
+
...opts,
|
|
162
|
+
signal
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
async delete(keyOrKeys, opts) {
|
|
166
|
+
this.#assertWritable();
|
|
167
|
+
if (Array.isArray(keyOrKeys)) return this.#deleteMany(keyOrKeys, opts);
|
|
168
|
+
const key = keyOrKeys;
|
|
169
|
+
return this.#exec('delete', key, true, opts, (signal)=>this.#driver.delete(this.#path(key), {
|
|
170
|
+
...opts,
|
|
171
|
+
signal
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
async #deleteMany(keys, opts) {
|
|
175
|
+
const d = this.#driver;
|
|
176
|
+
if (d.deleteMany) {
|
|
177
|
+
const res = await this.#exec('deleteMany', undefined, true, opts, (signal)=>d.deleteMany(keys.map((k)=>this.#path(k)), {
|
|
178
|
+
...opts,
|
|
179
|
+
signal
|
|
180
|
+
}));
|
|
181
|
+
return {
|
|
182
|
+
deleted: res.deleted.map((k)=>this.#strip(k)),
|
|
183
|
+
errors: res.errors?.map((e)=>({
|
|
184
|
+
key: this.#strip(e.key),
|
|
185
|
+
error: e.error
|
|
186
|
+
}))
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// Generic bounded fan-out fallback.
|
|
190
|
+
const concurrency = Math.max(1, opts?.concurrency ?? 8);
|
|
191
|
+
const stopOnError = opts?.stopOnError ?? false;
|
|
192
|
+
const deleted = [];
|
|
193
|
+
const errors = [];
|
|
194
|
+
let index = 0;
|
|
195
|
+
let stopped = false;
|
|
196
|
+
const worker = async ()=>{
|
|
197
|
+
while(index < keys.length && !stopped){
|
|
198
|
+
const key = keys[index++];
|
|
199
|
+
try {
|
|
200
|
+
await d.delete(this.#path(key), {
|
|
201
|
+
signal: opts?.signal
|
|
202
|
+
});
|
|
203
|
+
deleted.push(key);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
const error = StorageError.wrap(e);
|
|
206
|
+
errors.push({
|
|
207
|
+
key,
|
|
208
|
+
error
|
|
209
|
+
});
|
|
210
|
+
if (stopOnError) {
|
|
211
|
+
stopped = true;
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const runners = Array.from({
|
|
218
|
+
length: Math.min(concurrency, keys.length)
|
|
219
|
+
}, worker);
|
|
220
|
+
if (stopOnError) await Promise.all(runners);
|
|
221
|
+
else await Promise.allSettled(runners);
|
|
222
|
+
return {
|
|
223
|
+
deleted,
|
|
224
|
+
errors: errors.length ? errors : undefined
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async copy(from, to, opts) {
|
|
228
|
+
this.#assertWritable();
|
|
229
|
+
return this.#exec('copy', to, true, opts, (signal)=>this.#driver.copy(this.#path(from), this.#path(to), {
|
|
230
|
+
...opts,
|
|
231
|
+
signal
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
async move(from, to, opts) {
|
|
235
|
+
this.#assertWritable();
|
|
236
|
+
const f = this.#path(from);
|
|
237
|
+
const t = this.#path(to);
|
|
238
|
+
if (f === t) return;
|
|
239
|
+
if (this.#driver.move) {
|
|
240
|
+
await this.#exec('move', to, true, opts, (signal)=>this.#driver.move(f, t, {
|
|
241
|
+
...opts,
|
|
242
|
+
signal
|
|
243
|
+
}));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
await this.#exec('move', to, true, opts, async (signal)=>{
|
|
247
|
+
await this.#driver.copy(f, t, {
|
|
248
|
+
...opts,
|
|
249
|
+
signal
|
|
250
|
+
});
|
|
251
|
+
await this.#driver.delete(f, {
|
|
252
|
+
...opts,
|
|
253
|
+
signal
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
// ---- list --------------------------------------------------------------
|
|
258
|
+
async list(opts) {
|
|
259
|
+
if (opts?.delimiter) this.#assertDelimiter();
|
|
260
|
+
const scopedPrefix = this.#listPrefix(opts?.prefix);
|
|
261
|
+
const res = await this.#exec('list', undefined, true, opts, (signal)=>this.#driver.list({
|
|
262
|
+
...opts,
|
|
263
|
+
prefix: scopedPrefix,
|
|
264
|
+
signal
|
|
265
|
+
}));
|
|
266
|
+
return {
|
|
267
|
+
items: res.items.map((f)=>this.#relabel(f, this.#strip(f.key))),
|
|
268
|
+
prefixes: res.prefixes?.map((p)=>this.#strip(p)),
|
|
269
|
+
cursor: res.cursor
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async *listAll(opts) {
|
|
273
|
+
let cursor = opts?.cursor;
|
|
274
|
+
do {
|
|
275
|
+
const page = await this.list({
|
|
276
|
+
...opts,
|
|
277
|
+
cursor
|
|
278
|
+
});
|
|
279
|
+
for (const item of page.items)yield item;
|
|
280
|
+
cursor = page.cursor;
|
|
281
|
+
}while (cursor)
|
|
282
|
+
}
|
|
283
|
+
// ---- URLs / presign ----------------------------------------------------
|
|
284
|
+
async url(key, opts) {
|
|
285
|
+
if (!this.#driver.signedUrl?.supported) {
|
|
286
|
+
throw new StorageError('Unsupported', `${this.#driver.name}: url() not supported`);
|
|
287
|
+
}
|
|
288
|
+
return this.#exec('url', key, true, opts, (signal)=>this.#driver.url(this.#path(key), {
|
|
289
|
+
...opts,
|
|
290
|
+
signal
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
async signedUploadUrl(key, opts) {
|
|
294
|
+
this.#assertWritable();
|
|
295
|
+
if (!this.#driver.signedUrl?.upload) {
|
|
296
|
+
throw new StorageError('Unsupported', `${this.#driver.name}: signed uploads not supported`);
|
|
297
|
+
}
|
|
298
|
+
return this.#exec('signedUploadUrl', key, true, opts, (signal)=>this.#driver.signedUploadUrl(this.#path(key), {
|
|
299
|
+
...opts,
|
|
300
|
+
signal
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
/** Prefix-bound view of the driver's presigned-multipart capability. */ get signedMultipart() {
|
|
304
|
+
const cap = this.#driver.signedMultipart;
|
|
305
|
+
if (!cap) return undefined;
|
|
306
|
+
const path = (k)=>this.#path(k);
|
|
307
|
+
return {
|
|
308
|
+
create: (key, o)=>cap.create(path(key), o),
|
|
309
|
+
signPart: (key, uploadId, partNumber, o)=>cap.signPart(path(key), uploadId, partNumber, o),
|
|
310
|
+
complete: (key, uploadId, parts)=>cap.complete(path(key), uploadId, parts),
|
|
311
|
+
abort: (key, uploadId)=>cap.abort(path(key), uploadId)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// ---- low-level multipart ----------------------------------------------
|
|
315
|
+
async createMultipartUpload(key, opts) {
|
|
316
|
+
this.#assertWritable();
|
|
317
|
+
if (typeof this.#driver.createMultipartUpload !== 'function') {
|
|
318
|
+
throw new StorageError('Unsupported', `${this.#driver.name}: multipart not supported`);
|
|
319
|
+
}
|
|
320
|
+
return this.#driver.createMultipartUpload(this.#path(key), opts);
|
|
321
|
+
}
|
|
322
|
+
resumeMultipartUpload(key, uploadId) {
|
|
323
|
+
this.#assertWritable();
|
|
324
|
+
if (typeof this.#driver.resumeMultipartUpload !== 'function') {
|
|
325
|
+
throw new StorageError('Unsupported', `${this.#driver.name}: multipart resume not supported`);
|
|
326
|
+
}
|
|
327
|
+
return this.#driver.resumeMultipartUpload(this.#path(key), uploadId);
|
|
328
|
+
}
|
|
329
|
+
// ---- ergonomics --------------------------------------------------------
|
|
330
|
+
/** A key-bound handle over the common single-object operations. */ file(key) {
|
|
331
|
+
return {
|
|
332
|
+
key,
|
|
333
|
+
upload: (body, o)=>this.upload(key, body, o),
|
|
334
|
+
download: (o)=>this.download(key, o),
|
|
335
|
+
head: (o)=>this.head(key, o),
|
|
336
|
+
exists: (o)=>this.exists(key, o),
|
|
337
|
+
delete: (o)=>this.delete(key, o),
|
|
338
|
+
url: (o)=>this.url(key, o),
|
|
339
|
+
signedUploadUrl: (o)=>this.signedUploadUrl(key, o)
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/** A read-only clone of this handle (mutations throw `ReadOnly`). */ readonly() {
|
|
343
|
+
return new Storage({
|
|
344
|
+
...this.#opts,
|
|
345
|
+
readonly: true
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// ---- internals ---------------------------------------------------------
|
|
349
|
+
#path(key) {
|
|
350
|
+
return joinKey(this.#prefix, sanitizeKey(key));
|
|
351
|
+
}
|
|
352
|
+
#strip(storedKey) {
|
|
353
|
+
return stripPrefix(this.#prefix, storedKey);
|
|
354
|
+
}
|
|
355
|
+
#listPrefix(prefix) {
|
|
356
|
+
if (!this.#prefix) return prefix;
|
|
357
|
+
return prefix ? `${this.#prefix}/${prefix}` : `${this.#prefix}/`;
|
|
358
|
+
}
|
|
359
|
+
#relabel(file, logicalKey) {
|
|
360
|
+
if (file.key === logicalKey) return file;
|
|
361
|
+
const name = logicalKey.slice(logicalKey.lastIndexOf('/') + 1);
|
|
362
|
+
return {
|
|
363
|
+
key: logicalKey,
|
|
364
|
+
name,
|
|
365
|
+
size: file.size,
|
|
366
|
+
type: file.type,
|
|
367
|
+
lastModified: file.lastModified,
|
|
368
|
+
etag: file.etag,
|
|
369
|
+
metadata: file.metadata,
|
|
370
|
+
arrayBuffer: ()=>file.arrayBuffer(),
|
|
371
|
+
text: ()=>file.text(),
|
|
372
|
+
blob: ()=>file.blob(),
|
|
373
|
+
stream: ()=>file.stream()
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
#assertWritable() {
|
|
377
|
+
if (this.#readonly) throw new StorageError('ReadOnly', 'storage handle is read-only');
|
|
378
|
+
}
|
|
379
|
+
#assertUploadSupported(opts) {
|
|
380
|
+
const d = this.#driver;
|
|
381
|
+
if (opts?.metadata && Object.keys(opts.metadata).length > 0 && !d.supportsMetadata) {
|
|
382
|
+
throw new StorageError('Unsupported', `${d.name}: metadata not supported`);
|
|
383
|
+
}
|
|
384
|
+
if (opts?.cacheControl && !d.supportsCacheControl) {
|
|
385
|
+
throw new StorageError('Unsupported', `${d.name}: cacheControl not supported`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
#assertRange() {
|
|
389
|
+
if (!this.#driver.supportsRange) {
|
|
390
|
+
throw new StorageError('Unsupported', `${this.#driver.name}: range reads not supported`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
#assertDelimiter() {
|
|
394
|
+
if (!this.#driver.supportsDelimiter) {
|
|
395
|
+
throw new StorageError('Unsupported', `${this.#driver.name}: delimiter not supported`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async #exec(type, key, replayable, opts, fn) {
|
|
399
|
+
const started = Date.now();
|
|
400
|
+
const retries = replayable ? opts?.retries ?? this.#opts.retries : 0;
|
|
401
|
+
const maxRetries = normalizeRetry(retries).max;
|
|
402
|
+
const hooks = this.#opts.hooks;
|
|
403
|
+
try {
|
|
404
|
+
const result = await runWithRetry(fn, {
|
|
405
|
+
retries,
|
|
406
|
+
timeout: opts?.timeout ?? this.#opts.timeout,
|
|
407
|
+
signal: opts?.signal ?? this.#opts.signal
|
|
408
|
+
}, (info)=>hooks?.onRetry?.({
|
|
409
|
+
type,
|
|
410
|
+
key,
|
|
411
|
+
attempt: info.attempt,
|
|
412
|
+
maxRetries,
|
|
413
|
+
delayMs: info.delayMs,
|
|
414
|
+
error: info.error
|
|
415
|
+
}));
|
|
416
|
+
hooks?.onOperation?.({
|
|
417
|
+
type,
|
|
418
|
+
key,
|
|
419
|
+
status: 'success',
|
|
420
|
+
durationMs: Date.now() - started
|
|
421
|
+
});
|
|
422
|
+
return result;
|
|
423
|
+
} catch (e) {
|
|
424
|
+
const error = StorageError.wrap(e);
|
|
425
|
+
hooks?.onError?.({
|
|
426
|
+
type,
|
|
427
|
+
key,
|
|
428
|
+
error
|
|
429
|
+
});
|
|
430
|
+
hooks?.onOperation?.({
|
|
431
|
+
type,
|
|
432
|
+
key,
|
|
433
|
+
status: 'error',
|
|
434
|
+
durationMs: Date.now() - started,
|
|
435
|
+
error
|
|
436
|
+
});
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
export function createStorage(opts) {
|
|
442
|
+
return new Storage(opts);
|
|
443
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type DynamicModule, type InferTokens, type Token } from '@velajs/vela';
|
|
2
|
+
import type { StorageDriver, StorageHooks } from './storage.types';
|
|
3
|
+
import type { StorageAuthorizer } from './http/authorizer.types';
|
|
4
|
+
/** Options for mounting the HTTP upload/download controller for a bucket. */
|
|
5
|
+
export interface StorageHttpOptions {
|
|
6
|
+
/** Mount path (relative to vela's globalPrefix). Default `/api/storage`. */
|
|
7
|
+
basePath?: string;
|
|
8
|
+
/** Fine-grained per-action authorizer (deny / allow / allow-with-overrides). */
|
|
9
|
+
authorize?: StorageAuthorizer;
|
|
10
|
+
/** Behavior when no `authorize` is set. Default `'deny'`. */
|
|
11
|
+
defaultPolicy?: 'deny' | 'allow';
|
|
12
|
+
/** `redirect` to a signed URL (default) or `proxy` bytes through the Worker. */
|
|
13
|
+
download?: 'redirect' | 'proxy';
|
|
14
|
+
/** Set `false` to configure options without mounting the controller. Default mounts. */
|
|
15
|
+
mountController?: boolean;
|
|
16
|
+
defaultExpiresIn?: number;
|
|
17
|
+
maxExpiresIn?: number;
|
|
18
|
+
maxUploadSize?: number;
|
|
19
|
+
maxListLimit?: number;
|
|
20
|
+
deleteConcurrency?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface StorageModuleOptions {
|
|
23
|
+
driver: StorageDriver;
|
|
24
|
+
name?: string;
|
|
25
|
+
prefix?: string;
|
|
26
|
+
readonly?: boolean;
|
|
27
|
+
hooks?: StorageHooks;
|
|
28
|
+
http?: StorageHttpOptions;
|
|
29
|
+
}
|
|
30
|
+
export interface StorageModuleAsyncOptions<Inject extends readonly Token<unknown>[] = readonly Token<unknown>[]> {
|
|
31
|
+
inject?: Inject;
|
|
32
|
+
imports?: DynamicModule['imports'];
|
|
33
|
+
useFactory: (...deps: InferTokens<Inject>) => StorageDriver;
|
|
34
|
+
name?: string;
|
|
35
|
+
prefix?: string;
|
|
36
|
+
readonly?: boolean;
|
|
37
|
+
hooks?: StorageHooks;
|
|
38
|
+
http?: StorageHttpOptions;
|
|
39
|
+
key?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare class StorageModule {
|
|
42
|
+
/** Synchronous registration — the driver is provided directly. */
|
|
43
|
+
static forRoot(options: StorageModuleOptions): DynamicModule;
|
|
44
|
+
/** Deferred / DI-driven registration — `useFactory` runs lazily on first use. */
|
|
45
|
+
static forRootAsync<const Inject extends readonly Token<unknown>[] = readonly Token<unknown>[]>(options: StorageModuleAsyncOptions<Inject>): DynamicModule;
|
|
46
|
+
}
|