@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +56 -0
  4. package/dist/base64.d.ts +8 -0
  5. package/dist/base64.js +25 -0
  6. package/dist/client/index.d.ts +50 -0
  7. package/dist/client/index.js +243 -0
  8. package/dist/decorators/inject-storage.decorator.d.ts +17 -0
  9. package/dist/decorators/inject-storage.decorator.js +21 -0
  10. package/dist/drivers/memory/index.d.ts +16 -0
  11. package/dist/drivers/memory/index.js +202 -0
  12. package/dist/drivers/r2/index.d.ts +11 -0
  13. package/dist/drivers/r2/index.js +166 -0
  14. package/dist/drivers/r2/r2.types.d.ts +67 -0
  15. package/dist/drivers/r2/r2.types.js +5 -0
  16. package/dist/drivers/r2-http/index.d.ts +33 -0
  17. package/dist/drivers/r2-http/index.js +52 -0
  18. package/dist/drivers/s3/index.d.ts +5 -0
  19. package/dist/drivers/s3/index.js +3 -0
  20. package/dist/drivers/s3/s3-client.d.ts +33 -0
  21. package/dist/drivers/s3/s3-client.js +154 -0
  22. package/dist/drivers/s3/s3.driver.d.ts +4 -0
  23. package/dist/drivers/s3/s3.driver.js +404 -0
  24. package/dist/drivers/s3/s3.types.d.ts +28 -0
  25. package/dist/drivers/s3/s3.types.js +1 -0
  26. package/dist/drivers/s3/xml.d.ts +6 -0
  27. package/dist/drivers/s3/xml.js +52 -0
  28. package/dist/http/authorizer.types.d.ts +59 -0
  29. package/dist/http/authorizer.types.js +1 -0
  30. package/dist/http/http-helpers.d.ts +22 -0
  31. package/dist/http/http-helpers.js +80 -0
  32. package/dist/http/protocol.types.d.ts +76 -0
  33. package/dist/http/protocol.types.js +1 -0
  34. package/dist/index.d.ts +17 -0
  35. package/dist/index.js +16 -0
  36. package/dist/internal/body.d.ts +18 -0
  37. package/dist/internal/body.js +90 -0
  38. package/dist/internal/retry.d.ts +41 -0
  39. package/dist/internal/retry.js +127 -0
  40. package/dist/internal/stored-file.d.ts +27 -0
  41. package/dist/internal/stored-file.js +114 -0
  42. package/dist/middleware/cache.d.ts +50 -0
  43. package/dist/middleware/cache.js +101 -0
  44. package/dist/middleware/compose.d.ts +12 -0
  45. package/dist/middleware/compose.js +11 -0
  46. package/dist/middleware/compression.d.ts +16 -0
  47. package/dist/middleware/compression.js +101 -0
  48. package/dist/middleware/encryption.d.ts +14 -0
  49. package/dist/middleware/encryption.js +134 -0
  50. package/dist/middleware/failover.d.ts +18 -0
  51. package/dist/middleware/failover.js +52 -0
  52. package/dist/middleware/index.d.ts +15 -0
  53. package/dist/middleware/index.js +8 -0
  54. package/dist/middleware/retry.d.ts +14 -0
  55. package/dist/middleware/retry.js +47 -0
  56. package/dist/middleware/versioning.d.ts +36 -0
  57. package/dist/middleware/versioning.js +89 -0
  58. package/dist/middleware/wrap.d.ts +11 -0
  59. package/dist/middleware/wrap.js +46 -0
  60. package/dist/object-key.d.ts +17 -0
  61. package/dist/object-key.js +42 -0
  62. package/dist/storage.controller.d.ts +21 -0
  63. package/dist/storage.controller.js +380 -0
  64. package/dist/storage.error.d.ts +28 -0
  65. package/dist/storage.error.js +46 -0
  66. package/dist/storage.facade.d.ts +47 -0
  67. package/dist/storage.facade.js +443 -0
  68. package/dist/storage.module.d.ts +46 -0
  69. package/dist/storage.module.js +113 -0
  70. package/dist/storage.service.d.ts +42 -0
  71. package/dist/storage.service.js +86 -0
  72. package/dist/storage.tokens.d.ts +13 -0
  73. package/dist/storage.tokens.js +26 -0
  74. package/dist/storage.types.d.ts +245 -0
  75. package/dist/storage.types.js +1 -0
  76. package/dist/storagesdk/index.d.ts +85 -0
  77. package/dist/storagesdk/index.js +136 -0
  78. 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.
@@ -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
+ }