@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,101 @@
|
|
|
1
|
+
import { createStoredFile } from "../internal/stored-file.js";
|
|
2
|
+
import { passthrough } from "./wrap.js";
|
|
3
|
+
/** In-memory, per-isolate cache with lazy TTL + insertion-order eviction (no timers). */ export class MapCacheStore {
|
|
4
|
+
maxEntries;
|
|
5
|
+
now;
|
|
6
|
+
#map = new Map();
|
|
7
|
+
constructor(maxEntries = 1000, now = Date.now){
|
|
8
|
+
this.maxEntries = maxEntries;
|
|
9
|
+
this.now = now;
|
|
10
|
+
}
|
|
11
|
+
get(key) {
|
|
12
|
+
const e = this.#map.get(key);
|
|
13
|
+
if (!e) return undefined;
|
|
14
|
+
if (e.expiresAt <= this.now()) {
|
|
15
|
+
this.#map.delete(key);
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
return e;
|
|
19
|
+
}
|
|
20
|
+
set(key, value) {
|
|
21
|
+
this.#map.delete(key);
|
|
22
|
+
this.#map.set(key, value);
|
|
23
|
+
if (this.#map.size > this.maxEntries) {
|
|
24
|
+
const oldest = this.#map.keys().next().value;
|
|
25
|
+
if (oldest !== undefined) this.#map.delete(oldest);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
delete(key) {
|
|
29
|
+
this.#map.delete(key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function toMeta(f) {
|
|
33
|
+
return {
|
|
34
|
+
key: f.key,
|
|
35
|
+
name: f.name,
|
|
36
|
+
size: f.size,
|
|
37
|
+
type: f.type,
|
|
38
|
+
lastModified: f.lastModified,
|
|
39
|
+
etag: f.etag,
|
|
40
|
+
metadata: f.metadata
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read-through cache for `head` / `exists`. Bodies are never cached (a `head`
|
|
45
|
+
* hit still lazily fetches on body access), and `list` is not cached
|
|
46
|
+
* (cross-prefix invalidation is intractable). Writes invalidate the key.
|
|
47
|
+
*/ export function cache(opts = {}) {
|
|
48
|
+
const now = opts.now ?? Date.now;
|
|
49
|
+
const store = opts.store ?? new MapCacheStore(1000, now);
|
|
50
|
+
const ttl = opts.ttlMs ?? 60_000;
|
|
51
|
+
const doHead = opts.cache?.head ?? true;
|
|
52
|
+
const doExists = opts.cache?.exists ?? true;
|
|
53
|
+
const ns = (k)=>`${opts.keyPrefix ?? ''}${k}`;
|
|
54
|
+
return (inner)=>{
|
|
55
|
+
const invalidate = async (...keys)=>{
|
|
56
|
+
await Promise.all(keys.map((k)=>store.delete(ns(k))));
|
|
57
|
+
};
|
|
58
|
+
return passthrough(inner, {
|
|
59
|
+
head: doHead ? async (k, o)=>{
|
|
60
|
+
const hit = await store.get(ns(k));
|
|
61
|
+
if (hit?.kind === 'meta' && hit.meta) {
|
|
62
|
+
return createStoredFile(hit.meta, {
|
|
63
|
+
kind: 'lazy',
|
|
64
|
+
fetch: async ()=>new Response((await inner.download(k)).stream())
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const file = await inner.head(k, o);
|
|
68
|
+
await store.set(ns(k), {
|
|
69
|
+
kind: 'meta',
|
|
70
|
+
meta: toMeta(file),
|
|
71
|
+
expiresAt: now() + ttl
|
|
72
|
+
}, ttl);
|
|
73
|
+
return file;
|
|
74
|
+
} : undefined,
|
|
75
|
+
exists: doExists ? async (k, o)=>{
|
|
76
|
+
const hit = await store.get(ns(k));
|
|
77
|
+
if (hit?.kind === 'exists' && typeof hit.exists === 'boolean') return hit.exists;
|
|
78
|
+
const exists = await inner.exists(k, o);
|
|
79
|
+
await store.set(ns(k), {
|
|
80
|
+
kind: 'exists',
|
|
81
|
+
exists,
|
|
82
|
+
expiresAt: now() + ttl
|
|
83
|
+
}, ttl);
|
|
84
|
+
return exists;
|
|
85
|
+
} : undefined,
|
|
86
|
+
upload: async (k, b, o)=>{
|
|
87
|
+
const r = await inner.upload(k, b, o);
|
|
88
|
+
await invalidate(k);
|
|
89
|
+
return r;
|
|
90
|
+
},
|
|
91
|
+
delete: async (k, o)=>{
|
|
92
|
+
await inner.delete(k, o);
|
|
93
|
+
await invalidate(k);
|
|
94
|
+
},
|
|
95
|
+
copy: async (f, t, o)=>{
|
|
96
|
+
await inner.copy(f, t, o);
|
|
97
|
+
await invalidate(t);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StorageDriver } from '../storage.types';
|
|
2
|
+
import type { Middleware } from './wrap';
|
|
3
|
+
/**
|
|
4
|
+
* Compose middlewares over a base driver. Listed innermost → outermost:
|
|
5
|
+
* `compose(base, a, b, c)` yields `c(b(a(base)))`, so on any call `c` runs
|
|
6
|
+
* first (outermost) and `base` last. The same wrapper handles read and write,
|
|
7
|
+
* so the reverse order on download comes for free.
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately NO default preset — each middleware is opt-in, so you
|
|
10
|
+
* never accidentally ship, e.g., compress-then-encrypt (a CRIME/BREACH vector).
|
|
11
|
+
*/
|
|
12
|
+
export declare function compose(base: StorageDriver, ...middlewares: Middleware[]): StorageDriver;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose middlewares over a base driver. Listed innermost → outermost:
|
|
3
|
+
* `compose(base, a, b, c)` yields `c(b(a(base)))`, so on any call `c` runs
|
|
4
|
+
* first (outermost) and `base` last. The same wrapper handles read and write,
|
|
5
|
+
* so the reverse order on download comes for free.
|
|
6
|
+
*
|
|
7
|
+
* There is deliberately NO default preset — each middleware is opt-in, so you
|
|
8
|
+
* never accidentally ship, e.g., compress-then-encrypt (a CRIME/BREACH vector).
|
|
9
|
+
*/ export function compose(base, ...middlewares) {
|
|
10
|
+
return middlewares.reduce((driver, wrap)=>wrap(driver), base);
|
|
11
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Middleware } from './wrap';
|
|
2
|
+
export type CompressionFormat = 'gzip' | 'deflate' | 'deflate-raw';
|
|
3
|
+
export interface CompressionOptions {
|
|
4
|
+
/** Stream format. Default `gzip`. */
|
|
5
|
+
format?: CompressionFormat;
|
|
6
|
+
/** What to do when `CompressionStream` is unavailable. Default `throw`. */
|
|
7
|
+
onUnavailable?: 'throw' | 'passthrough';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Transparent gzip/deflate via Web Streams `CompressionStream` — fully
|
|
11
|
+
* streaming, opaque mode (v1). The stored object is compressed and tagged
|
|
12
|
+
* `vela-zip`; reads detect the tag and decompress, so pre-existing (untagged)
|
|
13
|
+
* objects pass through unharmed. Range reads / presigned URLs are disabled
|
|
14
|
+
* (the byte stream is not seekable and a direct URL would serve gzip).
|
|
15
|
+
*/
|
|
16
|
+
export declare function compression(opts?: CompressionOptions): Middleware;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { byteLengthOf, toStream } from "../internal/body.js";
|
|
2
|
+
import { createStoredFile } from "../internal/stored-file.js";
|
|
3
|
+
import { StorageError } from "../storage.error.js";
|
|
4
|
+
import { passthrough } from "./wrap.js";
|
|
5
|
+
function stripVela(meta) {
|
|
6
|
+
if (!meta) return undefined;
|
|
7
|
+
const out = {};
|
|
8
|
+
for (const [k, v] of Object.entries(meta))if (!k.startsWith('vela-')) out[k] = v;
|
|
9
|
+
return Object.keys(out).length ? out : undefined;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Transparent gzip/deflate via Web Streams `CompressionStream` — fully
|
|
13
|
+
* streaming, opaque mode (v1). The stored object is compressed and tagged
|
|
14
|
+
* `vela-zip`; reads detect the tag and decompress, so pre-existing (untagged)
|
|
15
|
+
* objects pass through unharmed. Range reads / presigned URLs are disabled
|
|
16
|
+
* (the byte stream is not seekable and a direct URL would serve gzip).
|
|
17
|
+
*/ export function compression(opts = {}) {
|
|
18
|
+
const format = opts.format ?? 'gzip';
|
|
19
|
+
const available = typeof CompressionStream !== 'undefined' && typeof DecompressionStream !== 'undefined';
|
|
20
|
+
if (!available && (opts.onUnavailable ?? 'throw') === 'throw') {
|
|
21
|
+
throw new StorageError('Unsupported', 'CompressionStream is not available in this runtime');
|
|
22
|
+
}
|
|
23
|
+
return (inner)=>{
|
|
24
|
+
if (!available) return inner; // passthrough mode
|
|
25
|
+
const decompressDownload = async (k, _o)=>{
|
|
26
|
+
const file = await inner.download(k);
|
|
27
|
+
const zip = file.metadata?.['vela-zip'];
|
|
28
|
+
if (!zip) return file; // untagged / legacy object
|
|
29
|
+
const plain = file.stream().pipeThrough(new DecompressionStream(zip));
|
|
30
|
+
const size = file.metadata?.['vela-size'] ? Number(file.metadata['vela-size']) : file.size;
|
|
31
|
+
return createStoredFile({
|
|
32
|
+
key: k,
|
|
33
|
+
name: file.name,
|
|
34
|
+
size,
|
|
35
|
+
type: file.metadata?.['vela-ct'] ?? file.type,
|
|
36
|
+
lastModified: file.lastModified,
|
|
37
|
+
etag: file.etag,
|
|
38
|
+
metadata: stripVela(file.metadata)
|
|
39
|
+
}, {
|
|
40
|
+
kind: 'stream',
|
|
41
|
+
stream: plain
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
return passthrough(inner, {
|
|
45
|
+
reportsUploadProgress: false,
|
|
46
|
+
supportsRange: false,
|
|
47
|
+
signedUrl: {
|
|
48
|
+
supported: false,
|
|
49
|
+
upload: false
|
|
50
|
+
},
|
|
51
|
+
async upload (k, body, o) {
|
|
52
|
+
const zipped = toStream(body).pipeThrough(new CompressionStream(format));
|
|
53
|
+
const metadata = {
|
|
54
|
+
...o?.metadata,
|
|
55
|
+
'vela-zip': format
|
|
56
|
+
};
|
|
57
|
+
if (o?.contentType) metadata['vela-ct'] = o.contentType;
|
|
58
|
+
const known = byteLengthOf(body);
|
|
59
|
+
if (known != null) metadata['vela-size'] = String(known);
|
|
60
|
+
const r = await inner.upload(k, zipped, {
|
|
61
|
+
...o,
|
|
62
|
+
metadata: inner.supportsMetadata ? metadata : undefined,
|
|
63
|
+
contentType: 'application/octet-stream'
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
...r,
|
|
67
|
+
size: known ?? r.size,
|
|
68
|
+
contentType: o?.contentType ?? 'application/octet-stream'
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
download: decompressDownload,
|
|
72
|
+
async head (k, o) {
|
|
73
|
+
const file = await inner.head(k, o);
|
|
74
|
+
if (!file.metadata?.['vela-zip']) return file;
|
|
75
|
+
const size = file.metadata['vela-size'] ? Number(file.metadata['vela-size']) : file.size;
|
|
76
|
+
return createStoredFile({
|
|
77
|
+
key: k,
|
|
78
|
+
name: file.name,
|
|
79
|
+
size,
|
|
80
|
+
type: file.metadata['vela-ct'] ?? file.type,
|
|
81
|
+
lastModified: file.lastModified,
|
|
82
|
+
etag: file.etag,
|
|
83
|
+
metadata: stripVela(file.metadata)
|
|
84
|
+
}, {
|
|
85
|
+
kind: 'lazy',
|
|
86
|
+
fetch: async ()=>new Response((await decompressDownload(k)).stream())
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
url () {
|
|
90
|
+
throw new StorageError('Unsupported', 'compression: url() would serve compressed bytes');
|
|
91
|
+
},
|
|
92
|
+
signedUploadUrl () {
|
|
93
|
+
throw new StorageError('Unsupported', 'compression: signed uploads would store uncompressed');
|
|
94
|
+
}
|
|
95
|
+
}, [
|
|
96
|
+
'createMultipartUpload',
|
|
97
|
+
'resumeMultipartUpload',
|
|
98
|
+
'signedMultipart'
|
|
99
|
+
]);
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Middleware } from './wrap';
|
|
2
|
+
export interface EncryptionOptions {
|
|
3
|
+
/** A 256-bit AES-GCM key: a `CryptoKey` or 32 raw bytes. */
|
|
4
|
+
key: CryptoKey | Uint8Array;
|
|
5
|
+
/** How to treat objects that aren't valid ciphertext (legacy data). Default `throw`. */
|
|
6
|
+
onInvalid?: 'throw' | 'passthrough';
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Transparent client-side encryption via Web Crypto AES-GCM. v1 encrypts the
|
|
10
|
+
* WHOLE object (buffered); the stored object is `IV(12) || ciphertext+tag`.
|
|
11
|
+
* Range reads, presigned URLs, and multipart are disabled (a signed URL would
|
|
12
|
+
* hand out ciphertext / accept plaintext, defeating the invariant).
|
|
13
|
+
*/
|
|
14
|
+
export declare function encryption(opts: EncryptionOptions): Middleware;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { toBytes } from "../internal/body.js";
|
|
2
|
+
import { createStoredFile } from "../internal/stored-file.js";
|
|
3
|
+
import { StorageError } from "../storage.error.js";
|
|
4
|
+
import { passthrough } from "./wrap.js";
|
|
5
|
+
const IV_BYTES = 12;
|
|
6
|
+
// The Web Crypto typings pin BufferSource to ArrayBuffer-backed views; our byte
|
|
7
|
+
// views are ArrayBufferLike-typed. They are ArrayBuffer-backed at runtime, so a
|
|
8
|
+
// narrowing cast is safe and confined to this helper.
|
|
9
|
+
const asBuf = (u)=>u;
|
|
10
|
+
function stripVela(meta) {
|
|
11
|
+
if (!meta) return undefined;
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const [k, v] of Object.entries(meta))if (!k.startsWith('vela-')) out[k] = v;
|
|
14
|
+
return Object.keys(out).length ? out : undefined;
|
|
15
|
+
}
|
|
16
|
+
async function importKey(key) {
|
|
17
|
+
if (key instanceof Uint8Array) {
|
|
18
|
+
return crypto.subtle.importKey('raw', key, {
|
|
19
|
+
name: 'AES-GCM'
|
|
20
|
+
}, false, [
|
|
21
|
+
'encrypt',
|
|
22
|
+
'decrypt'
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
return key;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Transparent client-side encryption via Web Crypto AES-GCM. v1 encrypts the
|
|
29
|
+
* WHOLE object (buffered); the stored object is `IV(12) || ciphertext+tag`.
|
|
30
|
+
* Range reads, presigned URLs, and multipart are disabled (a signed URL would
|
|
31
|
+
* hand out ciphertext / accept plaintext, defeating the invariant).
|
|
32
|
+
*/ export function encryption(opts) {
|
|
33
|
+
const keyPromise = importKey(opts.key);
|
|
34
|
+
return (inner)=>{
|
|
35
|
+
const decryptDownload = async (k, _o)=>{
|
|
36
|
+
const file = await inner.download(k);
|
|
37
|
+
const framed = new Uint8Array(await file.arrayBuffer());
|
|
38
|
+
if (framed.byteLength < IV_BYTES) {
|
|
39
|
+
if (opts.onInvalid === 'passthrough') return file;
|
|
40
|
+
throw new StorageError('Parse', `object too small to be encrypted: ${k}`);
|
|
41
|
+
}
|
|
42
|
+
const iv = framed.subarray(0, IV_BYTES);
|
|
43
|
+
const ciphertext = framed.subarray(IV_BYTES);
|
|
44
|
+
const key = await keyPromise;
|
|
45
|
+
let plain;
|
|
46
|
+
try {
|
|
47
|
+
plain = new Uint8Array(await crypto.subtle.decrypt({
|
|
48
|
+
name: 'AES-GCM',
|
|
49
|
+
iv: asBuf(iv)
|
|
50
|
+
}, key, asBuf(ciphertext)));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (opts.onInvalid === 'passthrough') return file;
|
|
53
|
+
throw new StorageError('Provider', `decryption failed: ${k}`, {
|
|
54
|
+
cause: e
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return createStoredFile({
|
|
58
|
+
key: k,
|
|
59
|
+
name: file.name,
|
|
60
|
+
size: plain.byteLength,
|
|
61
|
+
type: file.metadata?.['vela-ct'] ?? file.type,
|
|
62
|
+
lastModified: file.lastModified,
|
|
63
|
+
etag: file.etag,
|
|
64
|
+
metadata: stripVela(file.metadata)
|
|
65
|
+
}, {
|
|
66
|
+
kind: 'bytes',
|
|
67
|
+
bytes: plain
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
return passthrough(inner, {
|
|
71
|
+
reportsUploadProgress: false,
|
|
72
|
+
supportsRange: false,
|
|
73
|
+
signedUrl: {
|
|
74
|
+
supported: false,
|
|
75
|
+
upload: false
|
|
76
|
+
},
|
|
77
|
+
async upload (k, body, o) {
|
|
78
|
+
const plaintext = await toBytes(body);
|
|
79
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
80
|
+
const key = await keyPromise;
|
|
81
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({
|
|
82
|
+
name: 'AES-GCM',
|
|
83
|
+
iv
|
|
84
|
+
}, key, asBuf(plaintext)));
|
|
85
|
+
const framed = new Uint8Array(IV_BYTES + ct.byteLength);
|
|
86
|
+
framed.set(iv, 0);
|
|
87
|
+
framed.set(ct, IV_BYTES);
|
|
88
|
+
const metadata = {
|
|
89
|
+
...o?.metadata,
|
|
90
|
+
'vela-enc': 'AES-GCM'
|
|
91
|
+
};
|
|
92
|
+
if (o?.contentType) metadata['vela-ct'] = o.contentType;
|
|
93
|
+
metadata['vela-size'] = String(plaintext.byteLength);
|
|
94
|
+
const r = await inner.upload(k, framed, {
|
|
95
|
+
...o,
|
|
96
|
+
metadata: inner.supportsMetadata ? metadata : undefined,
|
|
97
|
+
contentType: 'application/octet-stream'
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
...r,
|
|
101
|
+
size: plaintext.byteLength,
|
|
102
|
+
contentType: o?.contentType ?? 'application/octet-stream'
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
download: decryptDownload,
|
|
106
|
+
async head (k, o) {
|
|
107
|
+
const file = await inner.head(k, o);
|
|
108
|
+
const size = file.metadata?.['vela-size'] ? Number(file.metadata['vela-size']) : file.size;
|
|
109
|
+
return createStoredFile({
|
|
110
|
+
key: k,
|
|
111
|
+
name: file.name,
|
|
112
|
+
size,
|
|
113
|
+
type: file.metadata?.['vela-ct'] ?? file.type,
|
|
114
|
+
lastModified: file.lastModified,
|
|
115
|
+
etag: file.etag,
|
|
116
|
+
metadata: stripVela(file.metadata)
|
|
117
|
+
}, {
|
|
118
|
+
kind: 'lazy',
|
|
119
|
+
fetch: async ()=>new Response((await decryptDownload(k)).stream())
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
url () {
|
|
123
|
+
throw new StorageError('Unsupported', 'encryption: url() would expose ciphertext');
|
|
124
|
+
},
|
|
125
|
+
signedUploadUrl () {
|
|
126
|
+
throw new StorageError('Unsupported', 'encryption: signed uploads would store plaintext');
|
|
127
|
+
}
|
|
128
|
+
}, [
|
|
129
|
+
'createMultipartUpload',
|
|
130
|
+
'resumeMultipartUpload',
|
|
131
|
+
'signedMultipart'
|
|
132
|
+
]);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StorageDriver } from '../storage.types';
|
|
2
|
+
import { type Middleware } from './wrap';
|
|
3
|
+
export interface FailoverOptions {
|
|
4
|
+
/** Decide whether an error should trigger failover. Default: transient errors. */
|
|
5
|
+
shouldFailover?: (error: unknown) => boolean;
|
|
6
|
+
onFailover?: (info: {
|
|
7
|
+
from: number;
|
|
8
|
+
to: number;
|
|
9
|
+
error: unknown;
|
|
10
|
+
}) => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Read-failover across a primary + fallbacks. Reads try each driver in order
|
|
14
|
+
* until one succeeds; writes and presigning go to the primary only (avoids
|
|
15
|
+
* partial/divergent writes). Capabilities are the intersection so the facade
|
|
16
|
+
* never routes a feature to a driver that can't serve it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function failover(fallbacks: StorageDriver[], opts?: FailoverOptions): Middleware;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { StorageError } from "../storage.error.js";
|
|
2
|
+
import { passthrough } from "./wrap.js";
|
|
3
|
+
function isTransient(error) {
|
|
4
|
+
return error instanceof StorageError ? error.retryable : true;
|
|
5
|
+
}
|
|
6
|
+
function intersectCapabilities(chain) {
|
|
7
|
+
return {
|
|
8
|
+
supportsRange: chain.every((d)=>!!d.supportsRange),
|
|
9
|
+
supportsDelimiter: chain.every((d)=>!!d.supportsDelimiter),
|
|
10
|
+
supportsMetadata: chain.every((d)=>!!d.supportsMetadata),
|
|
11
|
+
supportsCacheControl: chain.every((d)=>!!d.supportsCacheControl),
|
|
12
|
+
supportsServerSideCopy: chain.every((d)=>!!d.supportsServerSideCopy)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read-failover across a primary + fallbacks. Reads try each driver in order
|
|
17
|
+
* until one succeeds; writes and presigning go to the primary only (avoids
|
|
18
|
+
* partial/divergent writes). Capabilities are the intersection so the facade
|
|
19
|
+
* never routes a feature to a driver that can't serve it.
|
|
20
|
+
*/ export function failover(fallbacks, opts = {}) {
|
|
21
|
+
const should = opts.shouldFailover ?? isTransient;
|
|
22
|
+
return (primary)=>{
|
|
23
|
+
const chain = [
|
|
24
|
+
primary,
|
|
25
|
+
...fallbacks
|
|
26
|
+
];
|
|
27
|
+
const read = async (fn)=>{
|
|
28
|
+
let lastError;
|
|
29
|
+
for(let i = 0; i < chain.length; i++){
|
|
30
|
+
try {
|
|
31
|
+
return await fn(chain[i]);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
lastError = e;
|
|
34
|
+
if (!should(e) || i === chain.length - 1) throw e;
|
|
35
|
+
opts.onFailover?.({
|
|
36
|
+
from: i,
|
|
37
|
+
to: i + 1,
|
|
38
|
+
error: e
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw lastError;
|
|
43
|
+
};
|
|
44
|
+
return passthrough(primary, {
|
|
45
|
+
...intersectCapabilities(chain),
|
|
46
|
+
download: (k, o)=>read((d)=>d.download(k, o)),
|
|
47
|
+
head: (k, o)=>read((d)=>d.head(k, o)),
|
|
48
|
+
exists: (k, o)=>read((d)=>d.exists(k, o)),
|
|
49
|
+
list: (o)=>read((d)=>d.list(o))
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { compose } from './compose';
|
|
2
|
+
export { passthrough } from './wrap';
|
|
3
|
+
export type { Middleware } from './wrap';
|
|
4
|
+
export { retry } from './retry';
|
|
5
|
+
export type { RetryMiddlewareOptions } from './retry';
|
|
6
|
+
export { failover } from './failover';
|
|
7
|
+
export type { FailoverOptions } from './failover';
|
|
8
|
+
export { cache, MapCacheStore } from './cache';
|
|
9
|
+
export type { CacheOptions, CacheStore, CacheEntry, CachedMeta } from './cache';
|
|
10
|
+
export { encryption } from './encryption';
|
|
11
|
+
export type { EncryptionOptions } from './encryption';
|
|
12
|
+
export { compression } from './compression';
|
|
13
|
+
export type { CompressionOptions, CompressionFormat } from './compression';
|
|
14
|
+
export { versioning, unwrapVersioning, VERSIONED } from './versioning';
|
|
15
|
+
export type { VersioningOptions, Versioned, VersionInfo } from './versioning';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { compose } from "./compose.js";
|
|
2
|
+
export { passthrough } from "./wrap.js";
|
|
3
|
+
export { retry } from "./retry.js";
|
|
4
|
+
export { failover } from "./failover.js";
|
|
5
|
+
export { cache, MapCacheStore } from "./cache.js";
|
|
6
|
+
export { encryption } from "./encryption.js";
|
|
7
|
+
export { compression } from "./compression.js";
|
|
8
|
+
export { versioning, unwrapVersioning, VERSIONED } from "./versioning.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RetryOptions } from '../storage.types';
|
|
2
|
+
import { type Middleware } from './wrap';
|
|
3
|
+
export interface RetryMiddlewareOptions {
|
|
4
|
+
/** Default retry policy for driver operations. Per-call `opts.retries` wins. */
|
|
5
|
+
retries?: RetryOptions;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Retry transient failures at the driver level. Reads and idempotent ops
|
|
9
|
+
* always retry; an upload retries only when its body is replayable (not a
|
|
10
|
+
* one-shot stream). Note: the `Storage` facade also retries per
|
|
11
|
+
* `OperationOptions.retries` — use this middleware when driving a driver
|
|
12
|
+
* directly, or set the facade's retries to 0 to avoid compounding.
|
|
13
|
+
*/
|
|
14
|
+
export declare function retry(opts?: RetryMiddlewareOptions): Middleware;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { isStream } from "../internal/body.js";
|
|
2
|
+
import { runWithRetry } from "../internal/retry.js";
|
|
3
|
+
import { passthrough } from "./wrap.js";
|
|
4
|
+
/**
|
|
5
|
+
* Retry transient failures at the driver level. Reads and idempotent ops
|
|
6
|
+
* always retry; an upload retries only when its body is replayable (not a
|
|
7
|
+
* one-shot stream). Note: the `Storage` facade also retries per
|
|
8
|
+
* `OperationOptions.retries` — use this middleware when driving a driver
|
|
9
|
+
* directly, or set the facade's retries to 0 to avoid compounding.
|
|
10
|
+
*/ export function retry(opts = {}) {
|
|
11
|
+
const base = opts.retries ?? 3;
|
|
12
|
+
const run = (o, fn)=>runWithRetry(fn, {
|
|
13
|
+
retries: o?.retries ?? base,
|
|
14
|
+
timeout: o?.timeout,
|
|
15
|
+
signal: o?.signal
|
|
16
|
+
});
|
|
17
|
+
return (inner)=>passthrough(inner, {
|
|
18
|
+
download: (k, o)=>run(o, (s)=>inner.download(k, {
|
|
19
|
+
...o,
|
|
20
|
+
signal: s
|
|
21
|
+
})),
|
|
22
|
+
head: (k, o)=>run(o, (s)=>inner.head(k, {
|
|
23
|
+
...o,
|
|
24
|
+
signal: s
|
|
25
|
+
})),
|
|
26
|
+
exists: (k, o)=>run(o, (s)=>inner.exists(k, {
|
|
27
|
+
...o,
|
|
28
|
+
signal: s
|
|
29
|
+
})),
|
|
30
|
+
list: (o)=>run(o, (s)=>inner.list({
|
|
31
|
+
...o,
|
|
32
|
+
signal: s
|
|
33
|
+
})),
|
|
34
|
+
delete: (k, o)=>run(o, (s)=>inner.delete(k, {
|
|
35
|
+
...o,
|
|
36
|
+
signal: s
|
|
37
|
+
})),
|
|
38
|
+
copy: (f, t, o)=>run(o, (s)=>inner.copy(f, t, {
|
|
39
|
+
...o,
|
|
40
|
+
signal: s
|
|
41
|
+
})),
|
|
42
|
+
upload: (k, b, o)=>isStream(b) ? inner.upload(k, b, o) : run(o, (s)=>inner.upload(k, b, {
|
|
43
|
+
...o,
|
|
44
|
+
signal: s
|
|
45
|
+
}))
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { StorageDriver } from '../storage.types';
|
|
2
|
+
import { type Middleware } from './wrap';
|
|
3
|
+
export interface VersionInfo {
|
|
4
|
+
versionId: string;
|
|
5
|
+
key: string;
|
|
6
|
+
size: number;
|
|
7
|
+
lastModified?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Extra surface a versioning-wrapped driver exposes (reached via {@link unwrapVersioning}). */
|
|
10
|
+
export interface Versioned {
|
|
11
|
+
listVersions(key: string): Promise<VersionInfo[]>;
|
|
12
|
+
restore(key: string, versionId: string): Promise<void>;
|
|
13
|
+
deleteVersion(key: string, versionId: string): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export declare const VERSIONED: unique symbol;
|
|
16
|
+
export interface VersioningOptions {
|
|
17
|
+
/** Prefix under which prior versions are stored. Default `.vela-versions/`. */
|
|
18
|
+
prefix?: string;
|
|
19
|
+
versionId?: () => string;
|
|
20
|
+
/** Keep at most this many versions per key (prunes oldest). */
|
|
21
|
+
maxVersions?: number;
|
|
22
|
+
/** Hide the version prefix from `list()`. Default true. */
|
|
23
|
+
hideFromList?: boolean;
|
|
24
|
+
/** On delete: snapshot first (default) or purge all versions. */
|
|
25
|
+
onDelete?: 'snapshot' | 'purge';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Keeps prior versions on overwrite via key-suffixing (no external manifest →
|
|
29
|
+
* no read-modify-write race). Snapshots are cheap when the base driver
|
|
30
|
+
* `supportsServerSideCopy`. Direct presigned uploads bypass snapshotting, so
|
|
31
|
+
* `signedUploadUrl` throws. Reach `listVersions`/`restore`/`deleteVersion` via
|
|
32
|
+
* {@link unwrapVersioning}.
|
|
33
|
+
*/
|
|
34
|
+
export declare function versioning(opts?: VersioningOptions): Middleware;
|
|
35
|
+
/** Retrieve the {@link Versioned} surface from a versioning-wrapped driver (or `undefined`). */
|
|
36
|
+
export declare function unwrapVersioning(driver: StorageDriver): Versioned | undefined;
|