@moogodev/client 0.0.0-stage → 0.1.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/dist/client.d.ts +155 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +546 -0
- package/dist/errors.d.ts +84 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +246 -0
- package/dist/esm/client.d.ts +155 -0
- package/dist/esm/client.d.ts.map +1 -0
- package/dist/esm/client.js +541 -0
- package/dist/esm/errors.d.ts +84 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +220 -0
- package/dist/esm/index.d.ts +35 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/types.d.ts +183 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +5 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.mjs +35 -0
- package/dist/types.d.ts +183 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/package.json +43 -3
- package/src/client.ts +720 -0
- package/src/errors.ts +259 -0
- package/src/index.test.ts +277 -0
- package/src/index.ts +91 -0
- package/src/types.ts +206 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +9 -0
- package/README.md +0 -3
package/src/client.ts
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moogo JavaScript/TypeScript Client
|
|
3
|
+
* Official client for Moogo object storage API.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ClientConfig,
|
|
8
|
+
Bucket,
|
|
9
|
+
CreateBucketRequest,
|
|
10
|
+
UpdateBucketRequest,
|
|
11
|
+
ListBucketsResponse,
|
|
12
|
+
BucketInfoResponse,
|
|
13
|
+
ObjectResponse,
|
|
14
|
+
UploadResponse,
|
|
15
|
+
ListObjectsQuery,
|
|
16
|
+
ListObjectsResponse,
|
|
17
|
+
PresignedUrlRequest,
|
|
18
|
+
PresignedUrlResponse,
|
|
19
|
+
SignedUploadResponse,
|
|
20
|
+
StorageCredential,
|
|
21
|
+
ListCredentialsResponse,
|
|
22
|
+
CreateCredentialRequest,
|
|
23
|
+
CreateCredentialResponse,
|
|
24
|
+
RotateCredentialResponse,
|
|
25
|
+
DeleteResponse,
|
|
26
|
+
} from './types.js';
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
MoogoError,
|
|
30
|
+
createErrorFromResponse,
|
|
31
|
+
} from './errors.js';
|
|
32
|
+
|
|
33
|
+
/** Default timeout: 30 seconds */
|
|
34
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Moogo client for object storage operations.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* import { MoogoClient } from '@moogodev/client';
|
|
42
|
+
*
|
|
43
|
+
* const client = new MoogoClient({
|
|
44
|
+
* baseUrl: 'https://api.moogo.dev',
|
|
45
|
+
* projectId: 'your-project-id',
|
|
46
|
+
* accessKeyId: 'your-access-key',
|
|
47
|
+
* secretKey: 'your-secret-key',
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // Upload a file
|
|
51
|
+
* const response = await client.upload('avatars/user.png', fileBlob, {
|
|
52
|
+
* contentType: 'image/png',
|
|
53
|
+
* cacheControl: 'public, max-age=31536000, immutable',
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* // List objects
|
|
57
|
+
* const { objects, nextCursor } = await client.listObjects({ prefix: 'avatars/' });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export class MoogoClient {
|
|
61
|
+
private readonly baseUrl: string;
|
|
62
|
+
private readonly projectId: string;
|
|
63
|
+
private readonly accessKeyId: string;
|
|
64
|
+
private readonly secretKey: string;
|
|
65
|
+
private readonly fetchImpl: typeof fetch;
|
|
66
|
+
private readonly timeout: number;
|
|
67
|
+
|
|
68
|
+
constructor(config: ClientConfig) {
|
|
69
|
+
if (!config.baseUrl) throw new Error('baseUrl is required');
|
|
70
|
+
if (!config.projectId) throw new Error('projectId is required');
|
|
71
|
+
if (!config.accessKeyId) throw new Error('accessKeyId is required');
|
|
72
|
+
if (!config.secretKey) throw new Error('secretKey is required');
|
|
73
|
+
|
|
74
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
75
|
+
this.projectId = config.projectId;
|
|
76
|
+
this.accessKeyId = config.accessKeyId;
|
|
77
|
+
this.secretKey = config.secretKey;
|
|
78
|
+
this.fetchImpl = config.fetch ?? fetch;
|
|
79
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build the bucket catalog path */
|
|
83
|
+
private getCatalogPath(): string {
|
|
84
|
+
return `${this.baseUrl}/buckets/${this.projectId}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build the credentials path */
|
|
88
|
+
private getCredentialsPath(): string {
|
|
89
|
+
return `${this.baseUrl}/storage-credentials/${this.projectId}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Create authorization header using AWS Signature V4-like scheme */
|
|
93
|
+
private async createAuthHeader(
|
|
94
|
+
method: string,
|
|
95
|
+
path: string,
|
|
96
|
+
headers: Record<string, string>,
|
|
97
|
+
body?: string | Uint8Array
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const timestamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
|
100
|
+
const date = timestamp.slice(0, 8);
|
|
101
|
+
|
|
102
|
+
const signedHeaders = Object.keys(headers)
|
|
103
|
+
.map(k => k.toLowerCase())
|
|
104
|
+
.sort()
|
|
105
|
+
.join(';');
|
|
106
|
+
|
|
107
|
+
const canonicalHeaders = Object.keys(headers)
|
|
108
|
+
.map(k => `${k.toLowerCase()}:${headers[k]}`)
|
|
109
|
+
.sort()
|
|
110
|
+
.join('\n') + '\n';
|
|
111
|
+
|
|
112
|
+
let bodyHash: string;
|
|
113
|
+
if (body instanceof Uint8Array) {
|
|
114
|
+
bodyHash = await this.hashBody(body);
|
|
115
|
+
} else if (typeof body === 'string') {
|
|
116
|
+
bodyHash = await this.hashBody(new TextEncoder().encode(body));
|
|
117
|
+
} else {
|
|
118
|
+
bodyHash = await this.hashBody(new Uint8Array(0));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const canonicalRequest = [
|
|
122
|
+
method,
|
|
123
|
+
path,
|
|
124
|
+
'',
|
|
125
|
+
canonicalHeaders,
|
|
126
|
+
signedHeaders,
|
|
127
|
+
bodyHash,
|
|
128
|
+
].join('\n');
|
|
129
|
+
|
|
130
|
+
const stringToSign = [
|
|
131
|
+
'MOOGO-HMAC-SHA256',
|
|
132
|
+
timestamp,
|
|
133
|
+
`${date}/moogo/storage/moogo_request`,
|
|
134
|
+
await this.hashString(canonicalRequest),
|
|
135
|
+
].join('\n');
|
|
136
|
+
|
|
137
|
+
const signingKey = await this.getSigningKey(date);
|
|
138
|
+
const signature = await this.hmacSha256(signingKey, stringToSign);
|
|
139
|
+
|
|
140
|
+
return `MOOGO-HMAC-SHA256 Credential=${this.accessKeyId}/${date}/moogo/storage/moogo_request, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async hashBody(body: Uint8Array): Promise<string> {
|
|
144
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', body as BufferSource);
|
|
145
|
+
return Array.from(new Uint8Array(hashBuffer))
|
|
146
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
147
|
+
.join('');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async hashString(str: string): Promise<string> {
|
|
151
|
+
return this.hashBody(new TextEncoder().encode(str));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async getSigningKey(date: string): Promise<CryptoKey> {
|
|
155
|
+
const encoder = new TextEncoder();
|
|
156
|
+
const secretKey = await crypto.subtle.importKey(
|
|
157
|
+
'raw',
|
|
158
|
+
encoder.encode(this.secretKey),
|
|
159
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
160
|
+
false,
|
|
161
|
+
['sign']
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const kDate = await crypto.subtle.sign('HMAC', secretKey, encoder.encode(date));
|
|
165
|
+
const kRegion = await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', kDate, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']), encoder.encode('moogo'));
|
|
166
|
+
const kService = await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', kRegion, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']), encoder.encode('storage'));
|
|
167
|
+
const kSigning = await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', kService, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']), encoder.encode('moogo_request'));
|
|
168
|
+
|
|
169
|
+
return crypto.subtle.importKey('raw', kSigning, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async hmacSha256(key: CryptoKey, data: string): Promise<string> {
|
|
173
|
+
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
|
|
174
|
+
return Array.from(new Uint8Array(signature))
|
|
175
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
176
|
+
.join('');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Execute a request with auth and error handling */
|
|
180
|
+
private async request<T>(
|
|
181
|
+
method: string,
|
|
182
|
+
path: string,
|
|
183
|
+
options: {
|
|
184
|
+
headers?: Record<string, string>;
|
|
185
|
+
body?: BodyInit;
|
|
186
|
+
query?: Record<string, string>;
|
|
187
|
+
signal?: AbortSignal;
|
|
188
|
+
} = {}
|
|
189
|
+
): Promise<T> {
|
|
190
|
+
const url = new URL(path, this.baseUrl);
|
|
191
|
+
if (options.query) {
|
|
192
|
+
Object.entries(options.query).forEach(([k, v]) => {
|
|
193
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const requestHeaders: Record<string, string> = {
|
|
198
|
+
'X-Moogo-Access-Key-Id': this.accessKeyId,
|
|
199
|
+
'Content-Type': 'application/json',
|
|
200
|
+
...options.headers,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Remove Content-Type for FormData (browser sets it with boundary)
|
|
204
|
+
if (options.body instanceof FormData) {
|
|
205
|
+
delete requestHeaders['Content-Type'];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const authHeader = await this.createAuthHeader(method, url.pathname + url.search, requestHeaders);
|
|
209
|
+
requestHeaders['Authorization'] = `Bearer ${authHeader}`;
|
|
210
|
+
|
|
211
|
+
const controller = new AbortController();
|
|
212
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
213
|
+
|
|
214
|
+
const signal = options.signal
|
|
215
|
+
? AbortSignal.any([options.signal, controller.signal])
|
|
216
|
+
: controller.signal;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const response = await this.fetchImpl(url.toString(), {
|
|
220
|
+
method,
|
|
221
|
+
headers: requestHeaders,
|
|
222
|
+
body: options.body,
|
|
223
|
+
signal,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
clearTimeout(timeoutId);
|
|
227
|
+
|
|
228
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
229
|
+
const isJson = contentType.includes('application/json');
|
|
230
|
+
|
|
231
|
+
let data: any;
|
|
232
|
+
if (isJson) {
|
|
233
|
+
data = await response.json();
|
|
234
|
+
} else {
|
|
235
|
+
data = await response.text();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
throw createErrorFromResponse(response, data);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return data as T;
|
|
243
|
+
} catch (error) {
|
|
244
|
+
clearTimeout(timeoutId);
|
|
245
|
+
if (error instanceof MoogoError) throw error;
|
|
246
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
247
|
+
throw new MoogoError('timeout', 'Request timed out', 408);
|
|
248
|
+
}
|
|
249
|
+
throw new MoogoError('network_error', `Network error: ${error}`, 0);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ==================== Bucket Operations ====================
|
|
254
|
+
|
|
255
|
+
/** List all buckets in the project */
|
|
256
|
+
async listBuckets(): Promise<Bucket[]> {
|
|
257
|
+
const response = await this.request<ListBucketsResponse>('GET', this.getCatalogPath());
|
|
258
|
+
return response.buckets;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Get bucket info including usage stats */
|
|
262
|
+
async getBucketInfo(bucketId: string): Promise<BucketInfoResponse> {
|
|
263
|
+
const response = await this.request<BucketInfoResponse>(
|
|
264
|
+
'GET',
|
|
265
|
+
`${this.getCatalogPath()}/${bucketId}`
|
|
266
|
+
);
|
|
267
|
+
return response;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Create a new bucket */
|
|
271
|
+
async createBucket(request: CreateBucketRequest): Promise<Bucket> {
|
|
272
|
+
const response = await this.request<{ success: true; bucket: Bucket }>(
|
|
273
|
+
'POST',
|
|
274
|
+
this.getCatalogPath(),
|
|
275
|
+
{ body: JSON.stringify(request) }
|
|
276
|
+
);
|
|
277
|
+
return response.bucket;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Update bucket settings */
|
|
281
|
+
async updateBucket(bucketId: string, request: UpdateBucketRequest): Promise<Bucket> {
|
|
282
|
+
const response = await this.request<{ success: true; bucket: Bucket }>(
|
|
283
|
+
'PATCH',
|
|
284
|
+
`${this.getCatalogPath()}/${bucketId}`,
|
|
285
|
+
{ body: JSON.stringify(request) }
|
|
286
|
+
);
|
|
287
|
+
return response.bucket;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Delete a bucket and all its objects */
|
|
291
|
+
async deleteBucket(bucketId: string): Promise<void> {
|
|
292
|
+
await this.request<DeleteResponse>('DELETE', `${this.getCatalogPath()}/${bucketId}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Toggle bucket public/private (fans out to all objects) */
|
|
296
|
+
async setBucketPublic(bucketId: string, isPublic: boolean): Promise<Bucket> {
|
|
297
|
+
const response = await this.request<{ success: true; bucket: Bucket }>(
|
|
298
|
+
'POST',
|
|
299
|
+
`${this.getCatalogPath()}/${bucketId}/public`,
|
|
300
|
+
{ body: JSON.stringify({ is_public: isPublic }) }
|
|
301
|
+
);
|
|
302
|
+
return response.bucket;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ==================== Object Operations ====================
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Upload an object to a bucket.
|
|
309
|
+
*
|
|
310
|
+
* @param key - Object key (path within bucket)
|
|
311
|
+
* @param data - File data (Blob, File, Uint8Array, ReadableStream, or string)
|
|
312
|
+
* @param options - Upload options
|
|
313
|
+
*/
|
|
314
|
+
async upload(
|
|
315
|
+
key: string,
|
|
316
|
+
data: Blob | File | Uint8Array | ReadableStream<Uint8Array> | string,
|
|
317
|
+
options: {
|
|
318
|
+
bucket?: string;
|
|
319
|
+
contentType?: string;
|
|
320
|
+
cacheControl?: string;
|
|
321
|
+
onProgress?: (loaded: number, total: number) => void;
|
|
322
|
+
signal?: AbortSignal;
|
|
323
|
+
} = {}
|
|
324
|
+
): Promise<UploadResponse> {
|
|
325
|
+
const { bucket = 'default', contentType, cacheControl, onProgress, signal } = options;
|
|
326
|
+
|
|
327
|
+
const path = `/p/${this.projectId}/${encodeURIComponent(key)}`;
|
|
328
|
+
const query: Record<string, string> = {};
|
|
329
|
+
if (bucket !== 'default') query.bucket = bucket;
|
|
330
|
+
|
|
331
|
+
const requestHeaders: Record<string, string> = {};
|
|
332
|
+
if (contentType) requestHeaders['Content-Type'] = contentType;
|
|
333
|
+
if (cacheControl) requestHeaders['Cache-Control'] = cacheControl;
|
|
334
|
+
|
|
335
|
+
let body: BodyInit;
|
|
336
|
+
let contentLength: number | undefined;
|
|
337
|
+
|
|
338
|
+
if (data instanceof Blob || data instanceof File) {
|
|
339
|
+
body = data;
|
|
340
|
+
contentLength = data.size;
|
|
341
|
+
} else if (data instanceof Uint8Array) {
|
|
342
|
+
body = new Blob([data as unknown as BlobPart]);
|
|
343
|
+
contentLength = data.length;
|
|
344
|
+
} else if (typeof data === 'string') {
|
|
345
|
+
body = data;
|
|
346
|
+
contentLength = new TextEncoder().encode(data).length;
|
|
347
|
+
} else if (data instanceof ReadableStream) {
|
|
348
|
+
// For streams, we can't know content-length upfront
|
|
349
|
+
body = data;
|
|
350
|
+
} else {
|
|
351
|
+
throw new Error('Unsupported data type for upload');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
355
|
+
Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
356
|
+
|
|
357
|
+
const authHeaders: Record<string, string> = {
|
|
358
|
+
'X-Moogo-Access-Key-Id': this.accessKeyId,
|
|
359
|
+
...requestHeaders,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const authHeader = await this.createAuthHeader('POST', url.pathname + url.search, authHeaders, body as string | Uint8Array);
|
|
363
|
+
authHeaders['Authorization'] = `Bearer ${authHeader}`;
|
|
364
|
+
|
|
365
|
+
// Handle progress if callback provided and we have a known size
|
|
366
|
+
let finalBody = body;
|
|
367
|
+
if (onProgress && contentLength !== undefined && body instanceof Blob) {
|
|
368
|
+
finalBody = this.wrapWithProgress(body, contentLength, onProgress);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const controller = new AbortController();
|
|
372
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
373
|
+
const reqSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const response = await this.fetchImpl(url.toString(), {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: authHeaders,
|
|
379
|
+
body: finalBody,
|
|
380
|
+
signal: reqSignal,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
clearTimeout(timeoutId);
|
|
384
|
+
|
|
385
|
+
const responseData = await response.json();
|
|
386
|
+
|
|
387
|
+
if (!response.ok) {
|
|
388
|
+
throw createErrorFromResponse(response, responseData);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return responseData as UploadResponse;
|
|
392
|
+
} catch (error) {
|
|
393
|
+
clearTimeout(timeoutId);
|
|
394
|
+
if (error instanceof MoogoError) throw error;
|
|
395
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
396
|
+
throw new MoogoError('timeout', 'Upload timed out', 408);
|
|
397
|
+
}
|
|
398
|
+
throw new MoogoError('network_error', `Upload failed: ${error}`, 0);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Wrap a Blob with progress tracking */
|
|
403
|
+
private wrapWithProgress(blob: Blob, total: number, onProgress: (loaded: number, total: number) => void): ReadableStream<Uint8Array> {
|
|
404
|
+
let loaded = 0;
|
|
405
|
+
const reader = blob.stream().getReader();
|
|
406
|
+
|
|
407
|
+
return new ReadableStream({
|
|
408
|
+
async pull(controller) {
|
|
409
|
+
const { done, value } = await reader.read();
|
|
410
|
+
if (done) {
|
|
411
|
+
controller.close();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
loaded += value.byteLength;
|
|
415
|
+
onProgress(loaded, total);
|
|
416
|
+
controller.enqueue(value);
|
|
417
|
+
},
|
|
418
|
+
cancel() {
|
|
419
|
+
reader.cancel();
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Download an object */
|
|
425
|
+
async download(
|
|
426
|
+
key: string,
|
|
427
|
+
options: {
|
|
428
|
+
bucket?: string;
|
|
429
|
+
signal?: AbortSignal;
|
|
430
|
+
range?: { start: number; end?: number };
|
|
431
|
+
} = {}
|
|
432
|
+
): Promise<Response> {
|
|
433
|
+
const { bucket = 'default', signal, range } = options;
|
|
434
|
+
|
|
435
|
+
const path = `/p/${this.projectId}/${encodeURIComponent(key)}`;
|
|
436
|
+
const query: Record<string, string> = {};
|
|
437
|
+
if (bucket !== 'default') query.bucket = bucket;
|
|
438
|
+
|
|
439
|
+
const requestHeaders: Record<string, string> = {
|
|
440
|
+
'X-Moogo-Access-Key-Id': this.accessKeyId,
|
|
441
|
+
};
|
|
442
|
+
if (range) {
|
|
443
|
+
const end = range.end !== undefined ? range.end : '';
|
|
444
|
+
requestHeaders['Range'] = `bytes=${range.start}-${end}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
448
|
+
Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
449
|
+
|
|
450
|
+
const authHeader = await this.createAuthHeader('GET', url.pathname + url.search, requestHeaders);
|
|
451
|
+
requestHeaders['Authorization'] = `Bearer ${authHeader}`;
|
|
452
|
+
|
|
453
|
+
const controller = new AbortController();
|
|
454
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
455
|
+
const reqSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const response = await this.fetchImpl(url.toString(), {
|
|
459
|
+
method: 'GET',
|
|
460
|
+
headers: requestHeaders,
|
|
461
|
+
signal: reqSignal,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
clearTimeout(timeoutId);
|
|
465
|
+
|
|
466
|
+
if (!response.ok) {
|
|
467
|
+
const data = await response.json().catch(() => ({}));
|
|
468
|
+
throw createErrorFromResponse(response, data);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return response;
|
|
472
|
+
} catch (error) {
|
|
473
|
+
clearTimeout(timeoutId);
|
|
474
|
+
if (error instanceof MoogoError) throw error;
|
|
475
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
476
|
+
throw new MoogoError('timeout', 'Download timed out', 408);
|
|
477
|
+
}
|
|
478
|
+
throw new MoogoError('network_error', `Download failed: ${error}`, 0);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Download object as blob */
|
|
483
|
+
async downloadBlob(key: string, options?: Parameters<typeof this.download>[1]): Promise<Blob> {
|
|
484
|
+
const response = await this.download(key, options);
|
|
485
|
+
return response.blob();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Download object as text */
|
|
489
|
+
async downloadText(key: string, options?: Parameters<typeof this.download>[1]): Promise<string> {
|
|
490
|
+
const response = await this.download(key, options);
|
|
491
|
+
return response.text();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Download object as ArrayBuffer */
|
|
495
|
+
async downloadArrayBuffer(key: string, options?: Parameters<typeof this.download>[1]): Promise<ArrayBuffer> {
|
|
496
|
+
const response = await this.download(key, options);
|
|
497
|
+
return response.arrayBuffer();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Get object metadata (HEAD request) */
|
|
501
|
+
async headObject(
|
|
502
|
+
key: string,
|
|
503
|
+
options: { bucket?: string; signal?: AbortSignal } = {}
|
|
504
|
+
): Promise<{ headers: Headers; exists: boolean }> {
|
|
505
|
+
const { bucket = 'default', signal } = options;
|
|
506
|
+
|
|
507
|
+
const path = `/p/${this.projectId}/${encodeURIComponent(key)}`;
|
|
508
|
+
const query: Record<string, string> = {};
|
|
509
|
+
if (bucket !== 'default') query.bucket = bucket;
|
|
510
|
+
|
|
511
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
512
|
+
Object.entries(query).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
513
|
+
|
|
514
|
+
const requestHeaders: Record<string, string> = {
|
|
515
|
+
'X-Moogo-Access-Key-Id': this.accessKeyId,
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const authHeader = await this.createAuthHeader('HEAD', url.pathname + url.search, requestHeaders);
|
|
519
|
+
requestHeaders['Authorization'] = `Bearer ${authHeader}`;
|
|
520
|
+
|
|
521
|
+
const controller = new AbortController();
|
|
522
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
523
|
+
const reqSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
const response = await this.fetchImpl(url.toString(), {
|
|
527
|
+
method: 'HEAD',
|
|
528
|
+
headers: requestHeaders,
|
|
529
|
+
signal: reqSignal,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
clearTimeout(timeoutId);
|
|
533
|
+
|
|
534
|
+
if (response.status === 404) {
|
|
535
|
+
return { headers: response.headers, exists: false };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (!response.ok) {
|
|
539
|
+
const data = await response.json().catch(() => ({}));
|
|
540
|
+
throw createErrorFromResponse(response, data);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { headers: response.headers, exists: true };
|
|
544
|
+
} catch (error) {
|
|
545
|
+
clearTimeout(timeoutId);
|
|
546
|
+
if (error instanceof MoogoError) throw error;
|
|
547
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
548
|
+
throw new MoogoError('timeout', 'HEAD request timed out', 408);
|
|
549
|
+
}
|
|
550
|
+
throw new MoogoError('network_error', `HEAD failed: ${error}`, 0);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Delete an object */
|
|
555
|
+
async deleteObject(key: string, options: { bucket?: string; signal?: AbortSignal } = {}): Promise<void> {
|
|
556
|
+
const { bucket = 'default', signal } = options;
|
|
557
|
+
|
|
558
|
+
const path = `/p/${this.projectId}/${encodeURIComponent(key)}`;
|
|
559
|
+
const query: Record<string, string> = {};
|
|
560
|
+
if (bucket !== 'default') query.bucket = bucket;
|
|
561
|
+
|
|
562
|
+
await this.request('DELETE', path, { query, signal });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** List objects in a bucket */
|
|
566
|
+
async listObjects(query: ListObjectsQuery = {}): Promise<ListObjectsResponse> {
|
|
567
|
+
const params: Record<string, string> = {};
|
|
568
|
+
if (query.bucket) params.bucket = query.bucket;
|
|
569
|
+
if (query.prefix) params.prefix = query.prefix;
|
|
570
|
+
if (query.search) params.search = query.search;
|
|
571
|
+
if (query.order_by) params.order = query.order_by;
|
|
572
|
+
if (query.order_dir) params.dir = query.order_dir;
|
|
573
|
+
if (query.limit) params.limit = String(query.limit);
|
|
574
|
+
if (query.cursor) params.cursor = query.cursor;
|
|
575
|
+
|
|
576
|
+
return this.request<ListObjectsResponse>('GET', `/p/${this.projectId}/bucket`, { query: params });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Get a presigned URL for an object */
|
|
580
|
+
async getPresignedUrl(
|
|
581
|
+
key: string,
|
|
582
|
+
request: PresignedUrlRequest,
|
|
583
|
+
options: { bucket?: string; signal?: AbortSignal } = {}
|
|
584
|
+
): Promise<PresignedUrlResponse> {
|
|
585
|
+
const { bucket = 'default', signal } = options;
|
|
586
|
+
|
|
587
|
+
const path = `/p/${this.projectId}/${encodeURIComponent(key)}/sign`;
|
|
588
|
+
const query: Record<string, string> = {};
|
|
589
|
+
if (bucket !== 'default') query.bucket = bucket;
|
|
590
|
+
|
|
591
|
+
return this.request<PresignedUrlResponse>('POST', path, {
|
|
592
|
+
query,
|
|
593
|
+
body: JSON.stringify(request),
|
|
594
|
+
signal,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Create a signed upload URL (returns upload URL + object metadata) */
|
|
599
|
+
async createSignedUpload(
|
|
600
|
+
key: string,
|
|
601
|
+
request: PresignedUrlRequest & { content_type?: string },
|
|
602
|
+
options: { bucket?: string; signal?: AbortSignal } = {}
|
|
603
|
+
): Promise<SignedUploadResponse> {
|
|
604
|
+
const { bucket = 'default', signal } = options;
|
|
605
|
+
|
|
606
|
+
const path = `/p/${this.projectId}/${encodeURIComponent(key)}/sign-upload`;
|
|
607
|
+
const query: Record<string, string> = {};
|
|
608
|
+
if (bucket !== 'default') query.bucket = bucket;
|
|
609
|
+
|
|
610
|
+
return this.request<SignedUploadResponse>('POST', path, {
|
|
611
|
+
query,
|
|
612
|
+
body: JSON.stringify(request),
|
|
613
|
+
signal,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Update object metadata (visibility, key) */
|
|
618
|
+
async updateObject(
|
|
619
|
+
key: string,
|
|
620
|
+
updates: { is_public?: boolean; new_key?: string },
|
|
621
|
+
options: { bucket?: string; signal?: AbortSignal } = {}
|
|
622
|
+
): Promise<ObjectResponse> {
|
|
623
|
+
const { bucket = 'default', signal } = options;
|
|
624
|
+
|
|
625
|
+
const path = `/p/${this.projectId}/${encodeURIComponent(key)}`;
|
|
626
|
+
const query: Record<string, string> = {};
|
|
627
|
+
if (bucket !== 'default') query.bucket = bucket;
|
|
628
|
+
|
|
629
|
+
const response = await this.request<{ success: true; object: ObjectResponse }>(
|
|
630
|
+
'PATCH',
|
|
631
|
+
path,
|
|
632
|
+
{ query, body: JSON.stringify(updates), signal }
|
|
633
|
+
);
|
|
634
|
+
return response.object;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ==================== Credential Operations ====================
|
|
638
|
+
|
|
639
|
+
/** List storage credentials */
|
|
640
|
+
async listCredentials(): Promise<StorageCredential[]> {
|
|
641
|
+
const response = await this.request<ListCredentialsResponse>(
|
|
642
|
+
'GET',
|
|
643
|
+
this.getCredentialsPath()
|
|
644
|
+
);
|
|
645
|
+
return response.credentials;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Create a new storage credential */
|
|
649
|
+
async createCredential(request: CreateCredentialRequest): Promise<CreateCredentialResponse> {
|
|
650
|
+
return this.request<CreateCredentialResponse>(
|
|
651
|
+
'POST',
|
|
652
|
+
this.getCredentialsPath(),
|
|
653
|
+
{ body: JSON.stringify(request) }
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Rotate a credential (generates new secret) */
|
|
658
|
+
async rotateCredential(credentialId: string): Promise<RotateCredentialResponse> {
|
|
659
|
+
return this.request<RotateCredentialResponse>(
|
|
660
|
+
'POST',
|
|
661
|
+
`${this.getCredentialsPath()}/${credentialId}/rotate`
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Revoke a credential */
|
|
666
|
+
async revokeCredential(credentialId: string): Promise<void> {
|
|
667
|
+
await this.request<DeleteResponse>(
|
|
668
|
+
'DELETE',
|
|
669
|
+
`${this.getCredentialsPath()}/${credentialId}`
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ==================== Utility Methods ====================
|
|
674
|
+
|
|
675
|
+
/** Get the public URL for an object (if published) */
|
|
676
|
+
getPublicUrl(key: string, publicUrlBase?: string): string {
|
|
677
|
+
if (publicUrlBase) {
|
|
678
|
+
const normalizedBase = publicUrlBase.replace(/\/+$/, '');
|
|
679
|
+
return `${normalizedBase}/pub/${this.projectId}/${encodeURIComponent(key)}`;
|
|
680
|
+
}
|
|
681
|
+
return `${this.baseUrl}/pub/${this.projectId}/${encodeURIComponent(key)}`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Get the authenticated download URL for an object */
|
|
685
|
+
getAuthenticatedUrl(key: string): string {
|
|
686
|
+
return `${this.baseUrl}/p/${this.projectId}/${encodeURIComponent(key)}`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Check if an object exists (efficient HEAD) */
|
|
690
|
+
async objectExists(key: string, options?: { bucket?: string }): Promise<boolean> {
|
|
691
|
+
const result = await this.headObject(key, options);
|
|
692
|
+
return result.exists;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Get object size without downloading */
|
|
696
|
+
async getObjectSize(key: string, options?: { bucket?: string }): Promise<number | null> {
|
|
697
|
+
const result = await this.headObject(key, options);
|
|
698
|
+
if (!result.exists) return null;
|
|
699
|
+
const contentLength = result.headers.get('Content-Length');
|
|
700
|
+
return contentLength ? parseInt(contentLength, 10) : null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Get object ETag */
|
|
704
|
+
async getObjectETag(key: string, options?: { bucket?: string }): Promise<string | null> {
|
|
705
|
+
const result = await this.headObject(key, options);
|
|
706
|
+
if (!result.exists) return null;
|
|
707
|
+
return result.headers.get('ETag');
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Convenience function to create a client from environment variables */
|
|
712
|
+
export function createClientFromEnv(env?: Record<string, string | undefined>): MoogoClient {
|
|
713
|
+
const getEnv = (key: string) => env?.[key] ?? process.env[key];
|
|
714
|
+
return new MoogoClient({
|
|
715
|
+
baseUrl: getEnv('MOOGO_API_URL') || 'https://api.moogo.dev',
|
|
716
|
+
projectId: getEnv('MOOGO_PROJECT_ID')!,
|
|
717
|
+
accessKeyId: getEnv('MOOGO_BUCKET_ACCESS_KEY_ID')!,
|
|
718
|
+
secretKey: getEnv('MOOGO_BUCKET_SECRET_KEY')!,
|
|
719
|
+
});
|
|
720
|
+
}
|