@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-07-04)
|
|
4
|
+
|
|
5
|
+
- Rebuilt on vela 1.11 `defineModule` + `lazyProvider` (hand-rolled forRoot/forRootAsync deleted; public API byte-identical). Requires `@velajs/vela >=1.11.0`.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## 0.1.0 (unreleased)
|
|
9
|
+
|
|
10
|
+
- Initial release: edge-first, driver-based object/file storage for Vela.
|
|
11
|
+
- `StorageModule` (`forRoot` / `forRootAsync`) with multi-bucket named instances.
|
|
12
|
+
- `Storage` facade with capability-gating, retry/timeout/abort, and multipart orchestration.
|
|
13
|
+
- Drivers: in-memory, S3 / S3-compatible (aws4fetch SigV4), R2 native binding, R2 over HTTP + hybrid.
|
|
14
|
+
- Opt-in `storageSdkDriver()` bridge for `@storagesdk/adapters` (Node/Bun).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kauan Guesser
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @velajs/storage
|
|
2
|
+
|
|
3
|
+
Edge-first, driver-based object/file storage for the [Vela](https://github.com/velajs/vela) framework.
|
|
4
|
+
|
|
5
|
+
Runs on Cloudflare Workers, Deno, Bun, Node 20+, and Vercel Edge — **no AWS SDK, ever**. S3/R2
|
|
6
|
+
requests are signed with [`aws4fetch`](https://github.com/mhart/aws4fetch) (`fetch` + Web Crypto),
|
|
7
|
+
so the whole default path passes Vela's `edge-runtime-audit` gate.
|
|
8
|
+
|
|
9
|
+
## Why not just wrap files-sdk / storagesdk?
|
|
10
|
+
|
|
11
|
+
Both are great libraries, but their object-store adapters statically import the AWS SDK v3 (~500KB,
|
|
12
|
+
breaks on Workers). `@velajs/storage` owns an **edge-native `StorageDriver` contract** and ships
|
|
13
|
+
first-party drivers that sign with `aws4fetch`. You can still reach the wider ecosystem through the
|
|
14
|
+
opt-in `storageSdkDriver()` bridge (`@velajs/storage/storagesdk`) on Node/Bun.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pnpm add @velajs/storage
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { StorageModule, StorageService } from '@velajs/storage';
|
|
26
|
+
import { s3Driver } from '@velajs/storage/drivers/s3';
|
|
27
|
+
|
|
28
|
+
@Module({
|
|
29
|
+
imports: [
|
|
30
|
+
StorageModule.forRootAsync({
|
|
31
|
+
inject: [EnvService],
|
|
32
|
+
useFactory: (env) =>
|
|
33
|
+
s3Driver({
|
|
34
|
+
endpoint: 'https://s3.us-east-1.amazonaws.com',
|
|
35
|
+
region: 'us-east-1',
|
|
36
|
+
bucket: 'my-bucket',
|
|
37
|
+
credentials: { accessKeyId: env.get('AWS_KEY'), secretAccessKey: env.get('AWS_SECRET') },
|
|
38
|
+
}),
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
class AppModule {}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Drivers
|
|
46
|
+
|
|
47
|
+
| Driver | Import | Edge? | Presign |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| Memory (tests) | `@velajs/storage/drivers/memory` | ✅ | ❌ |
|
|
50
|
+
| S3 / S3-compatible | `@velajs/storage/drivers/s3` | ✅ | ✅ |
|
|
51
|
+
| R2 (native binding) | `@velajs/storage/drivers/r2` | ✅ | via hybrid |
|
|
52
|
+
| R2 (HTTP + hybrid) | `@velajs/storage/drivers/r2-http` | ✅ | ✅ |
|
|
53
|
+
| storagesdk bridge | `@velajs/storage/storagesdk` | Node/Bun only | depends on adapter |
|
|
54
|
+
|
|
55
|
+
See the docs site for presigned uploads, the HTTP upload controller + browser client, middleware,
|
|
56
|
+
multi-bucket, and the full capability matrix.
|
package/dist/base64.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Encode bytes to standard base64. */
|
|
2
|
+
export declare function bytesToBase64(bytes: Uint8Array): string;
|
|
3
|
+
/** Decode standard base64 to bytes. */
|
|
4
|
+
export declare function base64ToBytes(b64: string): Uint8Array;
|
|
5
|
+
/** Encode a UTF-8 string to base64 (unicode-safe, unlike raw `btoa`). */
|
|
6
|
+
export declare function utf8ToBase64(text: string): string;
|
|
7
|
+
/** URL-safe base64 (base64url) without padding — used by SigV4 / policies. */
|
|
8
|
+
export declare function bytesToBase64Url(bytes: Uint8Array): string;
|
package/dist/base64.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Edge-safe base64 — NO `Buffer` (banned by the edge-runtime audit).
|
|
2
|
+
// Uses `btoa`/`atob` over chunked `String.fromCharCode`, present on every
|
|
3
|
+
// target runtime (Workers, Deno, Bun, Node 20+, browsers).
|
|
4
|
+
// Chunk stays <= 0x8000 so the spread into String.fromCharCode never exceeds
|
|
5
|
+
// the engine argument limit.
|
|
6
|
+
const CHUNK = 0x8000;
|
|
7
|
+
/** Encode bytes to standard base64. */ export function bytesToBase64(bytes) {
|
|
8
|
+
let binary = '';
|
|
9
|
+
for(let i = 0; i < bytes.length; i += CHUNK){
|
|
10
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
11
|
+
}
|
|
12
|
+
return btoa(binary);
|
|
13
|
+
}
|
|
14
|
+
/** Decode standard base64 to bytes. */ export function base64ToBytes(b64) {
|
|
15
|
+
const binary = atob(b64);
|
|
16
|
+
const out = new Uint8Array(binary.length);
|
|
17
|
+
for(let i = 0; i < binary.length; i++)out[i] = binary.charCodeAt(i);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/** Encode a UTF-8 string to base64 (unicode-safe, unlike raw `btoa`). */ export function utf8ToBase64(text) {
|
|
21
|
+
return bytesToBase64(new TextEncoder().encode(text));
|
|
22
|
+
}
|
|
23
|
+
/** URL-safe base64 (base64url) without padding — used by SigV4 / policies. */ export function bytesToBase64Url(bytes) {
|
|
24
|
+
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
25
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { UploadResult } from '../storage.types';
|
|
2
|
+
import type { ListResponse } from '../http/protocol.types';
|
|
3
|
+
import type { DeleteManyResult } from '../storage.types';
|
|
4
|
+
export interface StorageClientOptions {
|
|
5
|
+
/** Base URL of the mounted controller, e.g. `/api/storage`. */
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
/** Per-request headers (auth token/cookie). */
|
|
8
|
+
headers?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
9
|
+
/** Override fetch (SSR/tests). */
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
/** Files larger than this switch to multipart. Default 16 MiB. */
|
|
12
|
+
multipartThreshold?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface ClientUploadOptions {
|
|
15
|
+
/** Object key. Defaults to the file's name. The server may override it. */
|
|
16
|
+
key?: string;
|
|
17
|
+
contentType?: string;
|
|
18
|
+
metadata?: Record<string, string>;
|
|
19
|
+
multipart?: boolean | {
|
|
20
|
+
partSize?: number;
|
|
21
|
+
concurrency?: number;
|
|
22
|
+
};
|
|
23
|
+
onProgress?: (p: {
|
|
24
|
+
loaded: number;
|
|
25
|
+
total: number;
|
|
26
|
+
}) => void;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
/** Stable id enabling resume across reloads (multipart only). */
|
|
29
|
+
resumeKey?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare class StorageClientError extends Error {
|
|
32
|
+
readonly code: string;
|
|
33
|
+
readonly status: number;
|
|
34
|
+
constructor(code: string, message: string, status: number);
|
|
35
|
+
static from(res: Response): Promise<StorageClientError>;
|
|
36
|
+
}
|
|
37
|
+
export declare class StorageClient {
|
|
38
|
+
#private;
|
|
39
|
+
private readonly options;
|
|
40
|
+
constructor(options: StorageClientOptions);
|
|
41
|
+
upload(file: File | Blob, opts?: ClientUploadOptions): Promise<UploadResult>;
|
|
42
|
+
list(query?: {
|
|
43
|
+
prefix?: string;
|
|
44
|
+
cursor?: string;
|
|
45
|
+
limit?: number;
|
|
46
|
+
delimiter?: string;
|
|
47
|
+
}): Promise<ListResponse>;
|
|
48
|
+
delete(keys: string[]): Promise<DeleteManyResult>;
|
|
49
|
+
downloadUrl(key: string): Promise<string>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Browser upload client for @velajs/storage. Framework-agnostic, zero-dep.
|
|
2
|
+
// Ships from the `./client` subpath (DOM code, not edge code). Talks to the
|
|
3
|
+
// StorageController's JSON control-plane over fetch, and uploads bytes DIRECTLY
|
|
4
|
+
// to the bucket via presigned URLs (XHR for progress; fetch has no upload
|
|
5
|
+
// progress event). Wire types are imported type-only.
|
|
6
|
+
const DEFAULT_MULTIPART_THRESHOLD = 16 * 1024 * 1024; // 16 MiB
|
|
7
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
8
|
+
export class StorageClientError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
status;
|
|
11
|
+
constructor(code, message, status){
|
|
12
|
+
super(message), this.code = code, this.status = status;
|
|
13
|
+
this.name = 'StorageClientError';
|
|
14
|
+
}
|
|
15
|
+
static async from(res) {
|
|
16
|
+
let code = 'upstream_error';
|
|
17
|
+
let message = `request failed: ${res.status}`;
|
|
18
|
+
try {
|
|
19
|
+
const body = await res.json();
|
|
20
|
+
if (body?.error) {
|
|
21
|
+
code = body.error.code;
|
|
22
|
+
message = body.error.message;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
/* non-JSON body */ }
|
|
26
|
+
return new StorageClientError(code, message, res.status);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function pool(items, limit, worker) {
|
|
30
|
+
let i = 0;
|
|
31
|
+
const run = async ()=>{
|
|
32
|
+
while(i < items.length){
|
|
33
|
+
const index = i++;
|
|
34
|
+
await worker(items[index], index);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
await Promise.all(Array.from({
|
|
38
|
+
length: Math.min(Math.max(1, limit), items.length || 1)
|
|
39
|
+
}, run));
|
|
40
|
+
}
|
|
41
|
+
function xhrSend(o) {
|
|
42
|
+
return new Promise((resolve, reject)=>{
|
|
43
|
+
const xhr = new XMLHttpRequest();
|
|
44
|
+
xhr.open(o.method, o.url, true);
|
|
45
|
+
for (const [k, v] of Object.entries(o.headers ?? {}))xhr.setRequestHeader(k, v);
|
|
46
|
+
if (o.onProgress) {
|
|
47
|
+
xhr.upload.onprogress = (e)=>o.onProgress({
|
|
48
|
+
loaded: e.loaded,
|
|
49
|
+
total: e.total
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
xhr.onload = ()=>xhr.status >= 200 && xhr.status < 300 ? resolve({
|
|
53
|
+
status: xhr.status,
|
|
54
|
+
getHeader: (n)=>xhr.getResponseHeader(n)
|
|
55
|
+
}) : reject(new StorageClientError('upstream_error', `upload failed: ${xhr.status}`, xhr.status));
|
|
56
|
+
xhr.onerror = ()=>reject(new StorageClientError('upstream_error', 'network error', 0));
|
|
57
|
+
o.signal?.addEventListener('abort', ()=>xhr.abort(), {
|
|
58
|
+
once: true
|
|
59
|
+
});
|
|
60
|
+
xhr.send(o.body);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function loadSession(resumeKey, fingerprint) {
|
|
64
|
+
if (!resumeKey || typeof localStorage === 'undefined') return undefined;
|
|
65
|
+
try {
|
|
66
|
+
const raw = localStorage.getItem(`vela.storage.resume:${resumeKey}`);
|
|
67
|
+
if (!raw) return undefined;
|
|
68
|
+
const s = JSON.parse(raw);
|
|
69
|
+
return s.fingerprint === fingerprint ? s : undefined;
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function saveSession(resumeKey, fingerprint, s) {
|
|
75
|
+
if (!resumeKey || typeof localStorage === 'undefined') return;
|
|
76
|
+
try {
|
|
77
|
+
localStorage.setItem(`vela.storage.resume:${resumeKey}`, JSON.stringify({
|
|
78
|
+
...s,
|
|
79
|
+
fingerprint
|
|
80
|
+
}));
|
|
81
|
+
} catch {
|
|
82
|
+
/* quota / private mode — resume is best-effort */ }
|
|
83
|
+
}
|
|
84
|
+
function clearSession(resumeKey) {
|
|
85
|
+
if (!resumeKey || typeof localStorage === 'undefined') return;
|
|
86
|
+
try {
|
|
87
|
+
localStorage.removeItem(`vela.storage.resume:${resumeKey}`);
|
|
88
|
+
} catch {
|
|
89
|
+
/* ignore */ }
|
|
90
|
+
}
|
|
91
|
+
export class StorageClient {
|
|
92
|
+
options;
|
|
93
|
+
constructor(options){
|
|
94
|
+
this.options = options;
|
|
95
|
+
}
|
|
96
|
+
async upload(file, opts = {}) {
|
|
97
|
+
const threshold = this.options.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD;
|
|
98
|
+
const multipart = opts.multipart === true || opts.multipart !== false && file.size > threshold;
|
|
99
|
+
return multipart ? this.#multipartUpload(file, opts) : this.#simpleUpload(file, opts);
|
|
100
|
+
}
|
|
101
|
+
list(query = {}) {
|
|
102
|
+
const qs = new URLSearchParams();
|
|
103
|
+
for (const [k, v] of Object.entries(query))if (v != null) qs.set(k, String(v));
|
|
104
|
+
return this.#json('GET', `/list?${qs.toString()}`);
|
|
105
|
+
}
|
|
106
|
+
delete(keys) {
|
|
107
|
+
return this.#json('POST', '/delete', {
|
|
108
|
+
keys
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async downloadUrl(key) {
|
|
112
|
+
const r = await this.#json('POST', '/sign-download', {
|
|
113
|
+
key
|
|
114
|
+
});
|
|
115
|
+
return r.url;
|
|
116
|
+
}
|
|
117
|
+
// ---- internals ----
|
|
118
|
+
async #simpleUpload(file, opts) {
|
|
119
|
+
const key = opts.key ?? fileName(file);
|
|
120
|
+
const { upload } = await this.#json('POST', '/sign-upload', {
|
|
121
|
+
key,
|
|
122
|
+
contentType: opts.contentType ?? (file.type || undefined),
|
|
123
|
+
size: file.size,
|
|
124
|
+
metadata: opts.metadata
|
|
125
|
+
});
|
|
126
|
+
if (upload.method === 'PUT') {
|
|
127
|
+
await xhrSend({
|
|
128
|
+
method: 'PUT',
|
|
129
|
+
url: upload.url,
|
|
130
|
+
body: file,
|
|
131
|
+
headers: {
|
|
132
|
+
'content-type': file.type || 'application/octet-stream',
|
|
133
|
+
...upload.headers
|
|
134
|
+
},
|
|
135
|
+
signal: opts.signal,
|
|
136
|
+
onProgress: opts.onProgress
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
const form = new FormData();
|
|
140
|
+
for (const [k, v] of Object.entries(upload.fields))form.append(k, v);
|
|
141
|
+
form.append('file', file); // 'file' must be last for an S3/R2 POST policy
|
|
142
|
+
await xhrSend({
|
|
143
|
+
method: 'POST',
|
|
144
|
+
url: upload.url,
|
|
145
|
+
body: form,
|
|
146
|
+
signal: opts.signal,
|
|
147
|
+
onProgress: opts.onProgress
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
key,
|
|
152
|
+
size: file.size,
|
|
153
|
+
contentType: opts.contentType ?? (file.type || 'application/octet-stream')
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async #multipartUpload(file, opts) {
|
|
157
|
+
const key = opts.key ?? fileName(file);
|
|
158
|
+
const fingerprint = `${fileName(file)}:${file.size}`;
|
|
159
|
+
const concurrency = typeof opts.multipart === 'object' && opts.multipart.concurrency || DEFAULT_CONCURRENCY;
|
|
160
|
+
let session = loadSession(opts.resumeKey, fingerprint);
|
|
161
|
+
if (!session) {
|
|
162
|
+
const created = await this.#json('POST', '/multipart/create', {
|
|
163
|
+
key,
|
|
164
|
+
contentType: opts.contentType ?? (file.type || undefined),
|
|
165
|
+
metadata: opts.metadata,
|
|
166
|
+
partSize: typeof opts.multipart === 'object' ? opts.multipart.partSize : undefined
|
|
167
|
+
});
|
|
168
|
+
session = {
|
|
169
|
+
key: created.key,
|
|
170
|
+
uploadId: created.uploadId,
|
|
171
|
+
partSize: created.partSize,
|
|
172
|
+
uploaded: {}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const partSize = session.partSize;
|
|
176
|
+
const count = Math.ceil(file.size / partSize) || 1;
|
|
177
|
+
const loaded = new Array(count + 1).fill(0);
|
|
178
|
+
for (const n of Object.keys(session.uploaded))loaded[Number(n)] = partBytes(Number(n), partSize, file.size);
|
|
179
|
+
const report = ()=>opts.onProgress?.({
|
|
180
|
+
loaded: loaded.reduce((a, b)=>a + b, 0),
|
|
181
|
+
total: file.size
|
|
182
|
+
});
|
|
183
|
+
const todo = Array.from({
|
|
184
|
+
length: count
|
|
185
|
+
}, (_, i)=>i + 1).filter((n)=>!session.uploaded[n]);
|
|
186
|
+
await pool(todo, concurrency, async (n)=>{
|
|
187
|
+
const blob = file.slice((n - 1) * partSize, Math.min(n * partSize, file.size));
|
|
188
|
+
const signed = await this.#json('POST', '/multipart/sign-part', {
|
|
189
|
+
key: session.key,
|
|
190
|
+
uploadId: session.uploadId,
|
|
191
|
+
partNumber: n
|
|
192
|
+
});
|
|
193
|
+
const res = await xhrSend({
|
|
194
|
+
method: 'PUT',
|
|
195
|
+
url: signed.url,
|
|
196
|
+
body: blob,
|
|
197
|
+
headers: signed.headers,
|
|
198
|
+
signal: opts.signal,
|
|
199
|
+
onProgress: (p)=>{
|
|
200
|
+
loaded[n] = p.loaded;
|
|
201
|
+
report();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
const etag = res.getHeader('ETag') ?? res.getHeader('etag');
|
|
205
|
+
if (!etag) {
|
|
206
|
+
throw new StorageClientError('invalid_request', 'missing ETag on part upload (check bucket CORS ExposeHeaders: ETag)', res.status);
|
|
207
|
+
}
|
|
208
|
+
session.uploaded[n] = etag;
|
|
209
|
+
saveSession(opts.resumeKey, fingerprint, session);
|
|
210
|
+
});
|
|
211
|
+
const parts = Object.entries(session.uploaded).map(([n, etag])=>({
|
|
212
|
+
partNumber: Number(n),
|
|
213
|
+
etag
|
|
214
|
+
})).sort((a, b)=>a.partNumber - b.partNumber);
|
|
215
|
+
const result = await this.#json('POST', '/multipart/complete', {
|
|
216
|
+
key: session.key,
|
|
217
|
+
uploadId: session.uploadId,
|
|
218
|
+
parts
|
|
219
|
+
});
|
|
220
|
+
clearSession(opts.resumeKey);
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
async #json(method, path, body) {
|
|
224
|
+
const fetchImpl = this.options.fetch ?? fetch;
|
|
225
|
+
const res = await fetchImpl(this.options.baseUrl + path, {
|
|
226
|
+
method,
|
|
227
|
+
headers: {
|
|
228
|
+
'content-type': 'application/json',
|
|
229
|
+
...await this.options.headers?.() ?? {}
|
|
230
|
+
},
|
|
231
|
+
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
232
|
+
});
|
|
233
|
+
if (!res.ok) throw await StorageClientError.from(res);
|
|
234
|
+
return await res.json();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function fileName(file) {
|
|
238
|
+
return 'name' in file && typeof file.name === 'string' && file.name ? file.name : 'upload.bin';
|
|
239
|
+
}
|
|
240
|
+
function partBytes(partNumber, partSize, total) {
|
|
241
|
+
const start = (partNumber - 1) * partSize;
|
|
242
|
+
return Math.max(0, Math.min(partSize, total - start));
|
|
243
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inject the {@link StorageService} for a named bucket.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* class UploadService {
|
|
6
|
+
* constructor(
|
|
7
|
+
* @InjectStorage() private readonly uploads: StorageService, // default bucket
|
|
8
|
+
* @InjectStorage('backups') private readonly backups: StorageService, // named bucket
|
|
9
|
+
* ) {}
|
|
10
|
+
* }
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The default bucket resolves the `StorageService` class token directly (so
|
|
14
|
+
* plain `@Inject(StorageService)` and `Test.overrideProvider(StorageService)`
|
|
15
|
+
* keep working); named buckets resolve their per-name token.
|
|
16
|
+
*/
|
|
17
|
+
export declare function InjectStorage(name?: string): ParameterDecorator;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Inject } from "@velajs/vela";
|
|
2
|
+
import { StorageService } from "../storage.service.js";
|
|
3
|
+
import { DEFAULT_STORAGE_NAME, storageToken } from "../storage.tokens.js";
|
|
4
|
+
/**
|
|
5
|
+
* Inject the {@link StorageService} for a named bucket.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* class UploadService {
|
|
9
|
+
* constructor(
|
|
10
|
+
* @InjectStorage() private readonly uploads: StorageService, // default bucket
|
|
11
|
+
* @InjectStorage('backups') private readonly backups: StorageService, // named bucket
|
|
12
|
+
* ) {}
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The default bucket resolves the `StorageService` class token directly (so
|
|
17
|
+
* plain `@Inject(StorageService)` and `Test.overrideProvider(StorageService)`
|
|
18
|
+
* keep working); named buckets resolve their per-name token.
|
|
19
|
+
*/ export function InjectStorage(name = DEFAULT_STORAGE_NAME) {
|
|
20
|
+
return Inject(name === DEFAULT_STORAGE_NAME ? StorageService : storageToken(name));
|
|
21
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { StorageDriver } from '../../storage.types';
|
|
2
|
+
export interface MemoryEntry {
|
|
3
|
+
bytes: Uint8Array;
|
|
4
|
+
contentType: string;
|
|
5
|
+
cacheControl?: string;
|
|
6
|
+
metadata?: Record<string, string>;
|
|
7
|
+
etag: string;
|
|
8
|
+
lastModified: number;
|
|
9
|
+
}
|
|
10
|
+
export interface MemoryDriverOptions {
|
|
11
|
+
/** Pre-seed the store with `{ key: bytes|string }`. */
|
|
12
|
+
initial?: Record<string, Uint8Array | string>;
|
|
13
|
+
}
|
|
14
|
+
export type MemoryDriver = StorageDriver<Map<string, MemoryEntry>>;
|
|
15
|
+
/** In-memory storage driver — the test/dev workhorse and reference impl. */
|
|
16
|
+
export declare function memoryDriver(options?: MemoryDriverOptions): MemoryDriver;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { concatChunks, toBytes } from "../../internal/body.js";
|
|
2
|
+
import { createStoredFile } from "../../internal/stored-file.js";
|
|
3
|
+
import { StorageError } from "../../storage.error.js";
|
|
4
|
+
// FNV-1a 64-bit content hash — deterministic, edge-safe (BigInt, no node:crypto).
|
|
5
|
+
// Memory is a dev/test driver; the ETag never interoperates with real S3 ETags.
|
|
6
|
+
const FNV_OFFSET = 0xcbf29ce484222325n;
|
|
7
|
+
const FNV_PRIME = 0x100000001b3n;
|
|
8
|
+
const U64 = 0xffffffffffffffffn;
|
|
9
|
+
function contentEtag(bytes) {
|
|
10
|
+
let h = FNV_OFFSET;
|
|
11
|
+
for (const b of bytes){
|
|
12
|
+
h ^= BigInt(b);
|
|
13
|
+
h = h * FNV_PRIME & U64;
|
|
14
|
+
}
|
|
15
|
+
return `"${h.toString(16).padStart(16, '0')}"`;
|
|
16
|
+
}
|
|
17
|
+
function makeEntry(bytes, opts) {
|
|
18
|
+
return {
|
|
19
|
+
bytes,
|
|
20
|
+
contentType: opts?.contentType ?? 'application/octet-stream',
|
|
21
|
+
cacheControl: opts?.cacheControl,
|
|
22
|
+
metadata: opts?.metadata,
|
|
23
|
+
etag: contentEtag(bytes),
|
|
24
|
+
lastModified: Date.now()
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** In-memory storage driver — the test/dev workhorse and reference impl. */ export function memoryDriver(options) {
|
|
28
|
+
const store = new Map();
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
for (const [key, value] of Object.entries(options?.initial ?? {})){
|
|
31
|
+
const bytes = typeof value === 'string' ? encoder.encode(value) : value;
|
|
32
|
+
store.set(key, makeEntry(bytes));
|
|
33
|
+
}
|
|
34
|
+
const toStored = (key, entry, bytes)=>createStoredFile({
|
|
35
|
+
key,
|
|
36
|
+
size: bytes.byteLength,
|
|
37
|
+
type: entry.contentType,
|
|
38
|
+
lastModified: entry.lastModified,
|
|
39
|
+
etag: entry.etag,
|
|
40
|
+
metadata: entry.metadata
|
|
41
|
+
}, {
|
|
42
|
+
kind: 'bytes',
|
|
43
|
+
bytes
|
|
44
|
+
});
|
|
45
|
+
const mustGet = (key)=>{
|
|
46
|
+
const e = store.get(key);
|
|
47
|
+
if (!e) throw new StorageError('NotFound', `object not found: ${key}`);
|
|
48
|
+
return e;
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
name: 'memory',
|
|
52
|
+
raw: store,
|
|
53
|
+
supportsRange: true,
|
|
54
|
+
supportsDelimiter: true,
|
|
55
|
+
supportsMetadata: true,
|
|
56
|
+
supportsCacheControl: true,
|
|
57
|
+
supportsServerSideCopy: true,
|
|
58
|
+
reportsUploadProgress: false,
|
|
59
|
+
signedUrl: {
|
|
60
|
+
supported: false,
|
|
61
|
+
upload: false
|
|
62
|
+
},
|
|
63
|
+
async upload (key, body, opts) {
|
|
64
|
+
const bytes = await toBytes(body);
|
|
65
|
+
const entry = makeEntry(bytes, opts);
|
|
66
|
+
store.set(key, entry);
|
|
67
|
+
return {
|
|
68
|
+
key,
|
|
69
|
+
size: bytes.byteLength,
|
|
70
|
+
contentType: entry.contentType,
|
|
71
|
+
etag: entry.etag,
|
|
72
|
+
lastModified: entry.lastModified
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
async download (key, opts) {
|
|
76
|
+
const entry = mustGet(key);
|
|
77
|
+
if (opts?.range) {
|
|
78
|
+
const start = opts.range.start;
|
|
79
|
+
const end = opts.range.end ?? entry.bytes.byteLength - 1;
|
|
80
|
+
return toStored(key, entry, entry.bytes.subarray(start, end + 1));
|
|
81
|
+
}
|
|
82
|
+
return toStored(key, entry, entry.bytes);
|
|
83
|
+
},
|
|
84
|
+
async head (key) {
|
|
85
|
+
const entry = mustGet(key);
|
|
86
|
+
return toStored(key, entry, entry.bytes);
|
|
87
|
+
},
|
|
88
|
+
async exists (key) {
|
|
89
|
+
return store.has(key);
|
|
90
|
+
},
|
|
91
|
+
async delete (key) {
|
|
92
|
+
store.delete(key);
|
|
93
|
+
},
|
|
94
|
+
async deleteMany (keys) {
|
|
95
|
+
for (const k of keys)store.delete(k);
|
|
96
|
+
return {
|
|
97
|
+
deleted: keys
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
async copy (from, to) {
|
|
101
|
+
const e = mustGet(from);
|
|
102
|
+
store.set(to, {
|
|
103
|
+
...e,
|
|
104
|
+
bytes: e.bytes.slice(),
|
|
105
|
+
lastModified: Date.now()
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
async move (from, to) {
|
|
109
|
+
const e = mustGet(from);
|
|
110
|
+
store.set(to, e);
|
|
111
|
+
store.delete(from);
|
|
112
|
+
},
|
|
113
|
+
async list (opts) {
|
|
114
|
+
const prefix = opts?.prefix ?? '';
|
|
115
|
+
const delimiter = opts?.delimiter;
|
|
116
|
+
const limit = opts?.limit ?? 1000;
|
|
117
|
+
const start = opts?.cursor ? Number.parseInt(opts.cursor, 10) : 0;
|
|
118
|
+
const matched = [
|
|
119
|
+
...store.keys()
|
|
120
|
+
].filter((k)=>k.startsWith(prefix)).sort();
|
|
121
|
+
if (delimiter) {
|
|
122
|
+
const prefixes = new Set();
|
|
123
|
+
const direct = [];
|
|
124
|
+
for (const k of matched){
|
|
125
|
+
const rest = k.slice(prefix.length);
|
|
126
|
+
const idx = rest.indexOf(delimiter);
|
|
127
|
+
if (idx === -1) direct.push(k);
|
|
128
|
+
else prefixes.add(prefix + rest.slice(0, idx + delimiter.length));
|
|
129
|
+
}
|
|
130
|
+
const page = direct.slice(start, start + limit);
|
|
131
|
+
const cursor = start + limit < direct.length ? String(start + limit) : undefined;
|
|
132
|
+
return {
|
|
133
|
+
items: page.map((k)=>toStored(k, mustGet(k), mustGet(k).bytes)),
|
|
134
|
+
prefixes: start === 0 && prefixes.size ? [
|
|
135
|
+
...prefixes
|
|
136
|
+
].sort() : undefined,
|
|
137
|
+
cursor
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const page = matched.slice(start, start + limit);
|
|
141
|
+
const cursor = start + limit < matched.length ? String(start + limit) : undefined;
|
|
142
|
+
return {
|
|
143
|
+
items: page.map((k)=>toStored(k, mustGet(k), mustGet(k).bytes)),
|
|
144
|
+
cursor
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
async url (key) {
|
|
148
|
+
throw new StorageError('Unsupported', 'memory driver cannot mint URLs');
|
|
149
|
+
},
|
|
150
|
+
async signedUploadUrl () {
|
|
151
|
+
throw new StorageError('Unsupported', 'memory driver cannot presign uploads');
|
|
152
|
+
},
|
|
153
|
+
async createMultipartUpload (key, opts) {
|
|
154
|
+
const uploadId = crypto.randomUUID();
|
|
155
|
+
const parts = new Map();
|
|
156
|
+
return {
|
|
157
|
+
key,
|
|
158
|
+
uploadId,
|
|
159
|
+
async uploadPart (partNumber, body) {
|
|
160
|
+
const bytes = await toBytes(body);
|
|
161
|
+
parts.set(partNumber, bytes);
|
|
162
|
+
return {
|
|
163
|
+
partNumber,
|
|
164
|
+
etag: contentEtag(bytes),
|
|
165
|
+
size: bytes.byteLength
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
async complete (uploaded) {
|
|
169
|
+
const ordered = [
|
|
170
|
+
...uploaded
|
|
171
|
+
].sort((a, b)=>a.partNumber - b.partNumber).map((p)=>{
|
|
172
|
+
const bytes = parts.get(p.partNumber);
|
|
173
|
+
if (!bytes) throw new StorageError('InvalidRequest', `missing part ${p.partNumber}`);
|
|
174
|
+
return bytes;
|
|
175
|
+
});
|
|
176
|
+
const all = concatChunks(ordered);
|
|
177
|
+
const entry = makeEntry(all, opts);
|
|
178
|
+
store.set(key, entry);
|
|
179
|
+
return {
|
|
180
|
+
key,
|
|
181
|
+
size: all.byteLength,
|
|
182
|
+
contentType: entry.contentType,
|
|
183
|
+
etag: entry.etag,
|
|
184
|
+
lastModified: entry.lastModified
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
async abort () {
|
|
188
|
+
parts.clear();
|
|
189
|
+
},
|
|
190
|
+
async listParts () {
|
|
191
|
+
return [
|
|
192
|
+
...parts.entries()
|
|
193
|
+
].sort((a, b)=>a[0] - b[0]).map(([partNumber, bytes])=>({
|
|
194
|
+
partNumber,
|
|
195
|
+
etag: contentEtag(bytes),
|
|
196
|
+
size: bytes.byteLength
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|