@donkeylabs/server 2.0.7 → 2.0.11

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.
@@ -0,0 +1,409 @@
1
+ // S3-Compatible Storage Adapter
2
+ // Supports AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2
3
+
4
+ import type {
5
+ StorageAdapter,
6
+ StorageFile,
7
+ UploadOptions,
8
+ UploadResult,
9
+ DownloadResult,
10
+ ListOptions,
11
+ ListResult,
12
+ GetUrlOptions,
13
+ CopyOptions,
14
+ S3ProviderConfig,
15
+ StorageVisibility,
16
+ } from "./storage";
17
+
18
+ // Type definitions for AWS SDK (dynamically imported)
19
+ type S3Client = any;
20
+ type GetObjectCommand = any;
21
+ type PutObjectCommand = any;
22
+ type DeleteObjectCommand = any;
23
+ type DeleteObjectsCommand = any;
24
+ type ListObjectsV2Command = any;
25
+ type HeadObjectCommand = any;
26
+ type CopyObjectCommand = any;
27
+ type GetSignedUrl = (client: any, command: any, options: { expiresIn: number }) => Promise<string>;
28
+
29
+ /** S3-compatible storage adapter */
30
+ export class S3StorageAdapter implements StorageAdapter {
31
+ private client: S3Client | null = null;
32
+ private config: S3ProviderConfig;
33
+ private s3Module: any = null;
34
+ private presignerModule: any = null;
35
+
36
+ constructor(config: S3ProviderConfig) {
37
+ this.config = config;
38
+ }
39
+
40
+ private async getClient(): Promise<S3Client> {
41
+ if (this.client) return this.client;
42
+
43
+ try {
44
+ // Dynamically import AWS SDK (optional dependency)
45
+ // @ts-expect-error - Optional peer dependency, may not be installed
46
+ this.s3Module = await import("@aws-sdk/client-s3");
47
+ // @ts-expect-error - Optional peer dependency, may not be installed
48
+ this.presignerModule = await import("@aws-sdk/s3-request-presigner");
49
+
50
+ const { S3Client } = this.s3Module;
51
+
52
+ this.client = new S3Client({
53
+ region: this.config.region,
54
+ credentials: {
55
+ accessKeyId: this.config.accessKeyId,
56
+ secretAccessKey: this.config.secretAccessKey,
57
+ },
58
+ endpoint: this.config.endpoint,
59
+ forcePathStyle: this.config.forcePathStyle,
60
+ });
61
+
62
+ return this.client;
63
+ } catch (err) {
64
+ throw new Error(
65
+ "S3 storage adapter requires @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner. " +
66
+ "Install them with: bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner"
67
+ );
68
+ }
69
+ }
70
+
71
+ private visibilityToAcl(visibility?: StorageVisibility): string | undefined {
72
+ if (visibility === "public") return "public-read";
73
+ if (visibility === "private") return "private";
74
+ return undefined;
75
+ }
76
+
77
+ async upload(options: UploadOptions): Promise<UploadResult> {
78
+ const client = await this.getClient();
79
+ const { PutObjectCommand } = this.s3Module;
80
+
81
+ // Convert body to appropriate format
82
+ const body = await this.normalizeBody(options.body);
83
+
84
+ const command = new PutObjectCommand({
85
+ Bucket: this.config.bucket,
86
+ Key: options.key,
87
+ Body: body,
88
+ ContentType: options.contentType,
89
+ ContentDisposition: options.contentDisposition,
90
+ CacheControl: options.cacheControl,
91
+ ACL: this.visibilityToAcl(options.visibility),
92
+ Metadata: options.metadata,
93
+ });
94
+
95
+ const response = await client.send(command);
96
+
97
+ // Calculate size
98
+ let size = 0;
99
+ if (body instanceof Uint8Array || Buffer.isBuffer(body)) {
100
+ size = body.byteLength;
101
+ } else if (typeof body === "string") {
102
+ size = new TextEncoder().encode(body).length;
103
+ }
104
+
105
+ // Generate public URL if visibility is public
106
+ let url: string | undefined;
107
+ if (options.visibility === "public") {
108
+ url = this.getPublicUrl(options.key);
109
+ }
110
+
111
+ return {
112
+ key: options.key,
113
+ size,
114
+ etag: response.ETag?.replace(/"/g, ""),
115
+ url,
116
+ };
117
+ }
118
+
119
+ async download(key: string): Promise<DownloadResult | null> {
120
+ const client = await this.getClient();
121
+ const { GetObjectCommand } = this.s3Module;
122
+
123
+ try {
124
+ const command = new GetObjectCommand({
125
+ Bucket: this.config.bucket,
126
+ Key: key,
127
+ });
128
+
129
+ const response = await client.send(command);
130
+
131
+ if (!response.Body) {
132
+ return null;
133
+ }
134
+
135
+ // Convert S3 body to web ReadableStream
136
+ const body = response.Body.transformToWebStream
137
+ ? response.Body.transformToWebStream()
138
+ : response.Body;
139
+
140
+ return {
141
+ body,
142
+ size: response.ContentLength || 0,
143
+ contentType: response.ContentType,
144
+ lastModified: response.LastModified || new Date(),
145
+ etag: response.ETag?.replace(/"/g, ""),
146
+ metadata: response.Metadata,
147
+ };
148
+ } catch (err: any) {
149
+ if (err.name === "NoSuchKey" || err.$metadata?.httpStatusCode === 404) {
150
+ return null;
151
+ }
152
+ throw err;
153
+ }
154
+ }
155
+
156
+ async delete(key: string): Promise<boolean> {
157
+ const client = await this.getClient();
158
+ const { DeleteObjectCommand } = this.s3Module;
159
+
160
+ try {
161
+ const command = new DeleteObjectCommand({
162
+ Bucket: this.config.bucket,
163
+ Key: key,
164
+ });
165
+
166
+ await client.send(command);
167
+ return true;
168
+ } catch (err: any) {
169
+ if (err.name === "NoSuchKey" || err.$metadata?.httpStatusCode === 404) {
170
+ return false;
171
+ }
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ async deleteMany(keys: string[]): Promise<{ deleted: string[]; errors: string[] }> {
177
+ if (keys.length === 0) {
178
+ return { deleted: [], errors: [] };
179
+ }
180
+
181
+ const client = await this.getClient();
182
+ const { DeleteObjectsCommand } = this.s3Module;
183
+
184
+ const deleted: string[] = [];
185
+ const errors: string[] = [];
186
+
187
+ // S3 allows max 1000 objects per delete request
188
+ const batches = this.chunk(keys, 1000);
189
+
190
+ for (const batch of batches) {
191
+ try {
192
+ const command = new DeleteObjectsCommand({
193
+ Bucket: this.config.bucket,
194
+ Delete: {
195
+ Objects: batch.map((key) => ({ Key: key })),
196
+ Quiet: false,
197
+ },
198
+ });
199
+
200
+ const response = await client.send(command);
201
+
202
+ // Track deleted
203
+ if (response.Deleted) {
204
+ for (const obj of response.Deleted) {
205
+ if (obj.Key) deleted.push(obj.Key);
206
+ }
207
+ }
208
+
209
+ // Track errors
210
+ if (response.Errors) {
211
+ for (const err of response.Errors) {
212
+ if (err.Key) errors.push(err.Key);
213
+ }
214
+ }
215
+ } catch (err) {
216
+ // If batch fails, all keys in batch are errors
217
+ errors.push(...batch);
218
+ }
219
+ }
220
+
221
+ return { deleted, errors };
222
+ }
223
+
224
+ async list(options: ListOptions = {}): Promise<ListResult> {
225
+ const client = await this.getClient();
226
+ const { ListObjectsV2Command } = this.s3Module;
227
+
228
+ const command = new ListObjectsV2Command({
229
+ Bucket: this.config.bucket,
230
+ Prefix: options.prefix,
231
+ MaxKeys: options.limit || 1000,
232
+ ContinuationToken: options.cursor || undefined,
233
+ Delimiter: options.delimiter,
234
+ });
235
+
236
+ const response = await client.send(command);
237
+
238
+ const files: StorageFile[] = (response.Contents || []).map((obj: any) => ({
239
+ key: obj.Key,
240
+ size: obj.Size || 0,
241
+ lastModified: obj.LastModified || new Date(),
242
+ etag: obj.ETag?.replace(/"/g, ""),
243
+ }));
244
+
245
+ const prefixes: string[] = (response.CommonPrefixes || [])
246
+ .map((p: any) => p.Prefix)
247
+ .filter(Boolean);
248
+
249
+ return {
250
+ files,
251
+ prefixes,
252
+ cursor: response.NextContinuationToken || null,
253
+ hasMore: response.IsTruncated || false,
254
+ };
255
+ }
256
+
257
+ async head(key: string): Promise<StorageFile | null> {
258
+ const client = await this.getClient();
259
+ const { HeadObjectCommand } = this.s3Module;
260
+
261
+ try {
262
+ const command = new HeadObjectCommand({
263
+ Bucket: this.config.bucket,
264
+ Key: key,
265
+ });
266
+
267
+ const response = await client.send(command);
268
+
269
+ return {
270
+ key,
271
+ size: response.ContentLength || 0,
272
+ contentType: response.ContentType,
273
+ lastModified: response.LastModified || new Date(),
274
+ etag: response.ETag?.replace(/"/g, ""),
275
+ metadata: response.Metadata,
276
+ };
277
+ } catch (err: any) {
278
+ if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
279
+ return null;
280
+ }
281
+ throw err;
282
+ }
283
+ }
284
+
285
+ async exists(key: string): Promise<boolean> {
286
+ const result = await this.head(key);
287
+ return result !== null;
288
+ }
289
+
290
+ async getUrl(key: string, options: GetUrlOptions = {}): Promise<string> {
291
+ const { expiresIn = 3600 } = options;
292
+
293
+ // If we have a public URL configured, use it for public files
294
+ if (this.config.publicUrl) {
295
+ // Check if file is public (we'd need to HEAD it to know for sure)
296
+ // For simplicity, if publicUrl is set, assume it can be used
297
+ let url = `${this.config.publicUrl}/${key}`;
298
+
299
+ if (options.download) {
300
+ const filename =
301
+ typeof options.download === "string" ? options.download : key.split("/").pop();
302
+ url += `?response-content-disposition=${encodeURIComponent(`attachment; filename="${filename}"`)}`;
303
+ }
304
+
305
+ return url;
306
+ }
307
+
308
+ // Generate signed URL
309
+ const client = await this.getClient();
310
+ const { GetObjectCommand } = this.s3Module;
311
+ const { getSignedUrl } = this.presignerModule;
312
+
313
+ const commandOptions: any = {
314
+ Bucket: this.config.bucket,
315
+ Key: key,
316
+ };
317
+
318
+ if (options.download) {
319
+ const filename =
320
+ typeof options.download === "string" ? options.download : key.split("/").pop();
321
+ commandOptions.ResponseContentDisposition = `attachment; filename="${filename}"`;
322
+ }
323
+
324
+ if (options.contentType) {
325
+ commandOptions.ResponseContentType = options.contentType;
326
+ }
327
+
328
+ const command = new GetObjectCommand(commandOptions);
329
+ return getSignedUrl(client, command, { expiresIn });
330
+ }
331
+
332
+ async copy(options: CopyOptions): Promise<UploadResult> {
333
+ const client = await this.getClient();
334
+ const { CopyObjectCommand } = this.s3Module;
335
+
336
+ const command = new CopyObjectCommand({
337
+ Bucket: this.config.bucket,
338
+ CopySource: `${this.config.bucket}/${options.source}`,
339
+ Key: options.destination,
340
+ ACL: this.visibilityToAcl(options.visibility),
341
+ Metadata: options.metadata,
342
+ MetadataDirective: options.metadata ? "REPLACE" : "COPY",
343
+ });
344
+
345
+ const response = await client.send(command);
346
+
347
+ // Get size of the copied object
348
+ const headResult = await this.head(options.destination);
349
+
350
+ return {
351
+ key: options.destination,
352
+ size: headResult?.size || 0,
353
+ etag: response.CopyObjectResult?.ETag?.replace(/"/g, ""),
354
+ };
355
+ }
356
+
357
+ stop(): void {
358
+ if (this.client && typeof this.client.destroy === "function") {
359
+ this.client.destroy();
360
+ }
361
+ this.client = null;
362
+ }
363
+
364
+ /** Get public URL for an object */
365
+ private getPublicUrl(key: string): string {
366
+ if (this.config.publicUrl) {
367
+ return `${this.config.publicUrl}/${key}`;
368
+ }
369
+
370
+ // Construct default S3 URL
371
+ if (this.config.endpoint) {
372
+ // Custom endpoint (R2, MinIO, etc.)
373
+ const endpoint = this.config.endpoint.replace(/\/$/, "");
374
+ if (this.config.forcePathStyle) {
375
+ return `${endpoint}/${this.config.bucket}/${key}`;
376
+ }
377
+ return `${endpoint}/${key}`;
378
+ }
379
+
380
+ // AWS S3 URL
381
+ return `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${key}`;
382
+ }
383
+
384
+ /** Normalize body to a format S3 accepts */
385
+ private async normalizeBody(
386
+ body: Buffer | Uint8Array | string | Blob | ReadableStream<Uint8Array>
387
+ ): Promise<Buffer | Uint8Array | string | ReadableStream<Uint8Array>> {
388
+ if (Buffer.isBuffer(body) || body instanceof Uint8Array || typeof body === "string") {
389
+ return body;
390
+ }
391
+
392
+ if (body instanceof Blob) {
393
+ const arrayBuffer = await body.arrayBuffer();
394
+ return Buffer.from(arrayBuffer);
395
+ }
396
+
397
+ // ReadableStream - pass through
398
+ return body;
399
+ }
400
+
401
+ /** Split array into chunks */
402
+ private chunk<T>(arr: T[], size: number): T[][] {
403
+ const chunks: T[][] = [];
404
+ for (let i = 0; i < arr.length; i += size) {
405
+ chunks.push(arr.slice(i, i + size));
406
+ }
407
+ return chunks;
408
+ }
409
+ }