@yetter/client 0.0.10 → 0.0.12
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/README.md +70 -15
- package/bowow2.jpeg +0 -0
- package/dist/api.d.ts +13 -1
- package/dist/api.js +40 -0
- package/dist/client.d.ts +53 -1
- package/dist/client.js +298 -46
- package/dist/index.d.ts +1 -0
- package/dist/types.d.ts +60 -0
- package/examples/submit.ts +1 -1
- package/examples/subscribe.ts +1 -1
- package/examples/upload-and-generate.ts +39 -0
- package/package.json +4 -2
- package/src/api.ts +54 -0
- package/src/client.ts +404 -47
- package/src/index.ts +10 -1
- package/src/types.ts +76 -0
package/src/client.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
StreamOptions,
|
|
15
15
|
StreamEvent,
|
|
16
16
|
YetterStream,
|
|
17
|
+
UploadOptions,
|
|
18
|
+
UploadCompleteResponse,
|
|
17
19
|
} from "./types.js";
|
|
18
20
|
|
|
19
21
|
export class yetter {
|
|
@@ -56,7 +58,7 @@ export class yetter {
|
|
|
56
58
|
const startTime = Date.now();
|
|
57
59
|
const timeoutMilliseconds = 30 * 60 * 1000; // 30 minutes
|
|
58
60
|
|
|
59
|
-
while (status !== "COMPLETED" && status !== "
|
|
61
|
+
while (status !== "COMPLETED" && status !== "ERROR" && status !== "CANCELLED") {
|
|
60
62
|
if (Date.now() - startTime > timeoutMilliseconds) {
|
|
61
63
|
console.warn(`Subscription timed out after 30 minutes for request ID: ${generateResponse.request_id}. Attempting to cancel.`);
|
|
62
64
|
try {
|
|
@@ -84,9 +86,11 @@ export class yetter {
|
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
if (status === "
|
|
89
|
+
if (status === "ERROR") {
|
|
88
90
|
const errorMessage = lastStatusResponse?.logs?.map(log => log.message).join("\n") || "Image generation failed.";
|
|
89
91
|
throw new Error(errorMessage);
|
|
92
|
+
} else if (status === "CANCELLED") {
|
|
93
|
+
throw new Error("Image generation was cancelled by user.");
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
const finalResponse = await client.getResponse({
|
|
@@ -182,14 +186,12 @@ export class yetter {
|
|
|
182
186
|
model: model,
|
|
183
187
|
...options.input,
|
|
184
188
|
});
|
|
185
|
-
console.timeEnd("Initial API Response");
|
|
186
189
|
const requestId = initialApiResponse.request_id;
|
|
187
190
|
const responseUrl = initialApiResponse.response_url;
|
|
188
191
|
const cancelUrl = initialApiResponse.cancel_url;
|
|
189
192
|
const sseStreamUrl = `${client.getApiEndpoint()}/${model}/requests/${requestId}/status/stream`;
|
|
190
193
|
|
|
191
194
|
let eventSource: EventSource;
|
|
192
|
-
let streamEnded = false;
|
|
193
195
|
|
|
194
196
|
// Setup the promise for the done() method
|
|
195
197
|
let resolveDonePromise: (value: GetResponseResponse) => void;
|
|
@@ -205,6 +207,18 @@ export class yetter {
|
|
|
205
207
|
events: [] as StreamEvent[],
|
|
206
208
|
resolvers: [] as Array<(value: IteratorResult<StreamEvent, any>) => void>,
|
|
207
209
|
isClosed: false,
|
|
210
|
+
isSettled: false,
|
|
211
|
+
currentStatus: "" as "COMPLETED" | "ERROR" | "CANCELLED" | "IN_PROGRESS" | "IN_QUEUE",
|
|
212
|
+
callResolver(value: GetResponseResponse) {
|
|
213
|
+
if (this.isSettled) return;
|
|
214
|
+
this.isSettled = true;
|
|
215
|
+
resolveDonePromise(value);
|
|
216
|
+
},
|
|
217
|
+
callRejecter(reason?: any) {
|
|
218
|
+
if (this.isSettled) return;
|
|
219
|
+
this.isSettled = true;
|
|
220
|
+
rejectDonePromise(reason);
|
|
221
|
+
},
|
|
208
222
|
push(event: StreamEvent) {
|
|
209
223
|
if (this.isClosed) return;
|
|
210
224
|
if (this.resolvers.length > 0) {
|
|
@@ -213,51 +227,39 @@ export class yetter {
|
|
|
213
227
|
this.events.push(event);
|
|
214
228
|
}
|
|
215
229
|
// Check for terminal events to resolve/reject the donePromise
|
|
216
|
-
|
|
217
|
-
streamEnded = true;
|
|
218
|
-
client.getResponse({ url: responseUrl })
|
|
219
|
-
.then(resolveDonePromise)
|
|
220
|
-
.catch(rejectDonePromise)
|
|
221
|
-
.finally(() => this.close());
|
|
222
|
-
} else if (event.status === "FAILED") {
|
|
223
|
-
streamEnded = true;
|
|
224
|
-
rejectDonePromise(new Error(event.logs?.map(l => l.message).join('\n') || `Stream reported FAILED for ${requestId}`));
|
|
225
|
-
this.close();
|
|
226
|
-
}
|
|
230
|
+
this.currentStatus = event.status as "COMPLETED" | "ERROR" | "CANCELLED" | "IN_PROGRESS" | "IN_QUEUE";
|
|
227
231
|
},
|
|
228
232
|
error(err: any) {
|
|
229
233
|
if (this.isClosed) return;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
// has already been processed and is handling the donePromise.
|
|
233
|
-
// This error is likely a secondary effect of the connection closing.
|
|
234
|
-
if (!streamEnded) {
|
|
235
|
-
rejectDonePromise(err); // Only reject if no terminal event was processed
|
|
236
|
-
} else {
|
|
237
|
-
console.warn("SSE 'onerror' event after stream was considered ended (COMPLETED/FAILED). This error will not alter the done() promise.", err);
|
|
234
|
+
if (this.resolvers.length > 0) {
|
|
235
|
+
this.resolvers.shift()!({ value: undefined, done: true });
|
|
238
236
|
}
|
|
239
|
-
|
|
240
|
-
|
|
237
|
+
this._close();
|
|
238
|
+
const errorToReport = err instanceof Error ? err : new Error("Stream closed prematurely or unexpectedly.");
|
|
239
|
+
this.callRejecter(errorToReport);
|
|
240
|
+
},
|
|
241
|
+
done(value: GetResponseResponse) {
|
|
242
|
+
if (this.isClosed) return;
|
|
243
|
+
if (this.resolvers.length > 0) {
|
|
244
|
+
this.resolvers.shift()!({ value: undefined, done: true });
|
|
245
|
+
}
|
|
246
|
+
this._close();
|
|
247
|
+
this.callResolver(value);
|
|
248
|
+
},
|
|
249
|
+
cancel() {
|
|
250
|
+
if (this.isClosed) return;
|
|
241
251
|
if (this.resolvers.length > 0) {
|
|
242
|
-
this.resolvers.shift()!({ value: undefined, done: true });
|
|
252
|
+
this.resolvers.shift()!({ value: undefined, done: true });
|
|
243
253
|
}
|
|
244
|
-
this.
|
|
254
|
+
this._close();
|
|
255
|
+
this.callRejecter(new Error("Stream was cancelled by user."));
|
|
245
256
|
},
|
|
246
|
-
|
|
257
|
+
_close() {
|
|
247
258
|
if (this.isClosed) return;
|
|
248
259
|
this.isClosed = true;
|
|
249
260
|
this.resolvers.forEach(resolve => resolve({ value: undefined, done: true }));
|
|
250
261
|
this.resolvers = [];
|
|
251
262
|
eventSource?.close();
|
|
252
|
-
// If donePromise is still pending, reject it as stream closed prematurely
|
|
253
|
-
// Use a timeout to allow any final event processing for COMPLETED/FAILED to settle it first
|
|
254
|
-
setTimeout(() => {
|
|
255
|
-
donePromise.catch(() => {}).finally(() => { // check if it's already settled
|
|
256
|
-
if (!streamEnded) { // If not explicitly completed or failed
|
|
257
|
-
rejectDonePromise(new Error("Stream closed prematurely or unexpectedly."));
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
}, 100);
|
|
261
263
|
},
|
|
262
264
|
next(): Promise<IteratorResult<StreamEvent, any>> {
|
|
263
265
|
if (this.events.length > 0) {
|
|
@@ -272,7 +274,6 @@ export class yetter {
|
|
|
272
274
|
|
|
273
275
|
eventSource = new EventSourcePolyfill(sseStreamUrl, {
|
|
274
276
|
headers: { 'Authorization': `${yetter.apiKey}` },
|
|
275
|
-
heartbeatTimeout: 3000,
|
|
276
277
|
} as any);
|
|
277
278
|
|
|
278
279
|
eventSource.onopen = (event: Event) => {
|
|
@@ -289,10 +290,31 @@ export class yetter {
|
|
|
289
290
|
controller.error(new Error(`Error parsing SSE 'data' event: ${e.message}`));
|
|
290
291
|
}
|
|
291
292
|
});
|
|
292
|
-
|
|
293
|
-
|
|
293
|
+
// when 'done' event is received, currentStatus can only be COMPLETED or CANCELLED
|
|
294
|
+
// TODO: remove currentStatus and branch two cases(COMPLETED and CANCELLED) by response status by responseUrl
|
|
295
|
+
// TODO: Determine whether raise error or not when response status is CANCELLED
|
|
296
|
+
// => current code raise error when response status is CANCELLED because resolveDonePromise only get completed response
|
|
297
|
+
// => this mean that user expect only completed response from .done() method
|
|
298
|
+
eventSource.addEventListener('done', async (event: MessageEvent) => {
|
|
299
|
+
// console.log("SSE 'done' event received, raw data:", event.data);
|
|
300
|
+
try {
|
|
301
|
+
if (controller.currentStatus === "COMPLETED") {
|
|
302
|
+
// Close SSE immediately to avoid any late 'error' events during await
|
|
303
|
+
try { eventSource?.close(); } catch {}
|
|
304
|
+
const response = await client.getResponse({ url: responseUrl });
|
|
305
|
+
controller.done(response);
|
|
306
|
+
} else if (controller.currentStatus === "CANCELLED") {
|
|
307
|
+
controller.cancel();
|
|
308
|
+
}
|
|
309
|
+
} catch (e: any) {
|
|
310
|
+
console.error("Error parsing SSE 'done' event:", e, "Raw data:", event.data);
|
|
311
|
+
controller.error(new Error(`Error parsing SSE 'done' event: ${e.message}`));
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
eventSource.addEventListener('error', (event: MessageEvent) => {
|
|
294
315
|
// console.log("SSE 'done' event received, raw data:", event.data);
|
|
295
|
-
|
|
316
|
+
console.log("SSE 'error' event received, raw data:", event.data);
|
|
317
|
+
controller.error(new Error("Stream reported ERROR for ${requestId}"));
|
|
296
318
|
});
|
|
297
319
|
|
|
298
320
|
eventSource.onerror = (err: Event | MessageEvent) => {
|
|
@@ -308,7 +330,7 @@ export class yetter {
|
|
|
308
330
|
// Handle if API immediately returns a terminal status in initialApiResponse (e.g. already completed/failed)
|
|
309
331
|
if (initialApiResponse.status === "COMPLETED"){
|
|
310
332
|
controller.push(initialApiResponse as any as StreamEvent);
|
|
311
|
-
} else if (initialApiResponse.status === "
|
|
333
|
+
} else if (initialApiResponse.status === "ERROR"){
|
|
312
334
|
controller.push(initialApiResponse as any as StreamEvent);
|
|
313
335
|
}
|
|
314
336
|
|
|
@@ -322,19 +344,354 @@ export class yetter {
|
|
|
322
344
|
},
|
|
323
345
|
done: () => donePromise,
|
|
324
346
|
cancel: async () => {
|
|
325
|
-
controller.close();
|
|
326
347
|
try {
|
|
327
348
|
await client.cancel({ url: cancelUrl });
|
|
328
349
|
console.log(`Stream for ${requestId} - underlying request cancelled.`);
|
|
329
350
|
} catch (e: any) {
|
|
330
351
|
console.error(`Error cancelling underlying request for stream ${requestId}:`, e.message);
|
|
331
352
|
}
|
|
332
|
-
// Ensure donePromise is settled if not already
|
|
333
|
-
if (!streamEnded) {
|
|
334
|
-
rejectDonePromise(new Error("Stream was cancelled by user."));
|
|
335
|
-
}
|
|
336
353
|
},
|
|
337
354
|
getRequestId: () => requestId,
|
|
338
355
|
};
|
|
339
356
|
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Upload a file from the filesystem (Node.js) or File/Blob object (browser)
|
|
360
|
+
*
|
|
361
|
+
* @param fileOrPath File path (Node.js) or File/Blob object (browser)
|
|
362
|
+
* @param options Upload configuration options
|
|
363
|
+
* @returns Promise resolving to upload result with public URL
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* ```typescript
|
|
367
|
+
* // Node.js
|
|
368
|
+
* const result = await yetter.uploadFile("/path/to/image.jpg", {
|
|
369
|
+
* onProgress: (pct) => console.log(`Upload: ${pct}%`)
|
|
370
|
+
* });
|
|
371
|
+
*
|
|
372
|
+
* // Browser
|
|
373
|
+
* const fileInput = document.querySelector('input[type="file"]');
|
|
374
|
+
* const file = fileInput.files[0];
|
|
375
|
+
* const result = await yetter.uploadFile(file, {
|
|
376
|
+
* onProgress: (pct) => updateProgressBar(pct)
|
|
377
|
+
* });
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
public static async uploadFile(
|
|
381
|
+
fileOrPath: string | File | Blob,
|
|
382
|
+
options: UploadOptions = {}
|
|
383
|
+
): Promise<UploadCompleteResponse> {
|
|
384
|
+
if (!yetter.apiKey) {
|
|
385
|
+
throw new Error("API key is not configured. Call yetter.configure()");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (typeof fileOrPath === 'string') {
|
|
389
|
+
// Node.js path
|
|
390
|
+
return yetter._uploadFromPath(fileOrPath, options);
|
|
391
|
+
} else {
|
|
392
|
+
// Browser File/Blob
|
|
393
|
+
return yetter._uploadFromBlob(fileOrPath, options);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Upload a file from browser (File or Blob object)
|
|
399
|
+
* This is an alias for uploadFile for better clarity in browser contexts
|
|
400
|
+
*/
|
|
401
|
+
public static async uploadBlob(
|
|
402
|
+
file: File | Blob,
|
|
403
|
+
options: UploadOptions = {}
|
|
404
|
+
): Promise<UploadCompleteResponse> {
|
|
405
|
+
return yetter.uploadFile(file, options);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Upload file from filesystem path (Node.js only)
|
|
410
|
+
*/
|
|
411
|
+
private static async _uploadFromPath(
|
|
412
|
+
filePath: string,
|
|
413
|
+
options: UploadOptions
|
|
414
|
+
): Promise<UploadCompleteResponse> {
|
|
415
|
+
// Dynamic import for Node.js modules
|
|
416
|
+
const fs = await import('fs');
|
|
417
|
+
const mime = await import('mime-types');
|
|
418
|
+
|
|
419
|
+
// Validate file exists
|
|
420
|
+
if (!fs.existsSync(filePath)) {
|
|
421
|
+
throw new Error(`File not found: ${filePath}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const stats = fs.statSync(filePath);
|
|
425
|
+
const fileSize = stats.size;
|
|
426
|
+
const fileName = filePath.split(/[\\/]/).pop() || "upload";
|
|
427
|
+
const mimeType = mime.default.lookup(filePath) || "application/octet-stream";
|
|
428
|
+
|
|
429
|
+
const client = new YetterImageClient({
|
|
430
|
+
apiKey: yetter.apiKey,
|
|
431
|
+
endpoint: yetter.endpoint,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Step 1: Request presigned URL(s)
|
|
435
|
+
const uploadUrlResponse = await client.getUploadUrl({
|
|
436
|
+
file_name: fileName,
|
|
437
|
+
content_type: mimeType,
|
|
438
|
+
size: fileSize,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Step 2: Upload file content
|
|
442
|
+
if (uploadUrlResponse.mode === "single") {
|
|
443
|
+
await yetter._uploadFileSingle(
|
|
444
|
+
filePath,
|
|
445
|
+
uploadUrlResponse.put_url!,
|
|
446
|
+
mimeType,
|
|
447
|
+
fileSize,
|
|
448
|
+
options,
|
|
449
|
+
fs
|
|
450
|
+
);
|
|
451
|
+
} else {
|
|
452
|
+
await yetter._uploadFileMultipart(
|
|
453
|
+
filePath,
|
|
454
|
+
uploadUrlResponse.part_urls!,
|
|
455
|
+
uploadUrlResponse.part_size!,
|
|
456
|
+
fileSize,
|
|
457
|
+
options,
|
|
458
|
+
fs
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Step 3: Notify completion
|
|
463
|
+
const result = await client.uploadComplete({
|
|
464
|
+
key: uploadUrlResponse.key,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
if (options.onProgress) {
|
|
468
|
+
options.onProgress(100);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return result;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Upload file from Blob/File object (browser)
|
|
476
|
+
*/
|
|
477
|
+
private static async _uploadFromBlob(
|
|
478
|
+
file: File | Blob,
|
|
479
|
+
options: UploadOptions
|
|
480
|
+
): Promise<UploadCompleteResponse> {
|
|
481
|
+
const fileSize = file.size;
|
|
482
|
+
const fileName = options.filename ||
|
|
483
|
+
(file instanceof File ? file.name : "blob-upload");
|
|
484
|
+
const mimeType = file.type || "application/octet-stream";
|
|
485
|
+
|
|
486
|
+
const client = new YetterImageClient({
|
|
487
|
+
apiKey: yetter.apiKey,
|
|
488
|
+
endpoint: yetter.endpoint,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Step 1: Request presigned URL(s)
|
|
492
|
+
const uploadUrlResponse = await client.getUploadUrl({
|
|
493
|
+
file_name: fileName,
|
|
494
|
+
content_type: mimeType,
|
|
495
|
+
size: fileSize,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Step 2: Upload file content
|
|
499
|
+
if (uploadUrlResponse.mode === "single") {
|
|
500
|
+
await yetter._uploadBlobSingle(
|
|
501
|
+
file,
|
|
502
|
+
uploadUrlResponse.put_url!,
|
|
503
|
+
mimeType,
|
|
504
|
+
fileSize,
|
|
505
|
+
options
|
|
506
|
+
);
|
|
507
|
+
} else {
|
|
508
|
+
await yetter._uploadBlobMultipart(
|
|
509
|
+
file,
|
|
510
|
+
uploadUrlResponse.part_urls!,
|
|
511
|
+
uploadUrlResponse.part_size!,
|
|
512
|
+
fileSize,
|
|
513
|
+
options
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Step 3: Notify completion
|
|
518
|
+
const result = await client.uploadComplete({
|
|
519
|
+
key: uploadUrlResponse.key,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
if (options.onProgress) {
|
|
523
|
+
options.onProgress(100);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Upload file using single PUT request (Node.js, private helper)
|
|
531
|
+
*/
|
|
532
|
+
private static async _uploadFileSingle(
|
|
533
|
+
filePath: string,
|
|
534
|
+
presignedUrl: string,
|
|
535
|
+
contentType: string,
|
|
536
|
+
totalSize: number,
|
|
537
|
+
options: UploadOptions,
|
|
538
|
+
fs: any
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
541
|
+
|
|
542
|
+
const response = await fetch(presignedUrl, {
|
|
543
|
+
method: "PUT",
|
|
544
|
+
headers: {
|
|
545
|
+
"Content-Type": contentType,
|
|
546
|
+
"Content-Length": String(totalSize),
|
|
547
|
+
},
|
|
548
|
+
body: fileBuffer,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
if (!response.ok) {
|
|
552
|
+
const errorText = await response.text();
|
|
553
|
+
throw new Error(
|
|
554
|
+
`Single-part upload failed (${response.status}): ${errorText}`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (options.onProgress) {
|
|
559
|
+
options.onProgress(90);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Upload file using multipart upload (Node.js, private helper)
|
|
565
|
+
*/
|
|
566
|
+
private static async _uploadFileMultipart(
|
|
567
|
+
filePath: string,
|
|
568
|
+
partUrls: Array<{ part_number: number; url: string }>,
|
|
569
|
+
partSize: number,
|
|
570
|
+
totalSize: number,
|
|
571
|
+
options: UploadOptions,
|
|
572
|
+
fs: any
|
|
573
|
+
): Promise<void> {
|
|
574
|
+
const sortedParts = [...partUrls].sort((a, b) => a.part_number - b.part_number);
|
|
575
|
+
|
|
576
|
+
const fileHandle = fs.openSync(filePath, "r");
|
|
577
|
+
try {
|
|
578
|
+
for (let i = 0; i < sortedParts.length; i++) {
|
|
579
|
+
const part = sortedParts[i];
|
|
580
|
+
const buffer = Buffer.alloc(partSize);
|
|
581
|
+
const offset = (part.part_number - 1) * partSize;
|
|
582
|
+
|
|
583
|
+
const bytesRead = fs.readSync(fileHandle, buffer, 0, partSize, offset);
|
|
584
|
+
const chunk = buffer.slice(0, bytesRead);
|
|
585
|
+
|
|
586
|
+
if (chunk.length === 0) {
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const response = await fetch(part.url, {
|
|
591
|
+
method: "PUT",
|
|
592
|
+
headers: {
|
|
593
|
+
"Content-Length": String(chunk.length),
|
|
594
|
+
},
|
|
595
|
+
body: chunk,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
if (!response.ok) {
|
|
599
|
+
const errorText = await response.text();
|
|
600
|
+
throw new Error(
|
|
601
|
+
`Multipart upload failed at part ${part.part_number} ` +
|
|
602
|
+
`(${response.status}): ${errorText}`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (options.onProgress) {
|
|
607
|
+
const progress = Math.min(
|
|
608
|
+
90,
|
|
609
|
+
Math.floor(((i + 1) / sortedParts.length) * 90)
|
|
610
|
+
);
|
|
611
|
+
options.onProgress(progress);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
} finally {
|
|
615
|
+
fs.closeSync(fileHandle);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Upload blob using single PUT request (browser, private helper)
|
|
621
|
+
*/
|
|
622
|
+
private static async _uploadBlobSingle(
|
|
623
|
+
blob: Blob,
|
|
624
|
+
presignedUrl: string,
|
|
625
|
+
contentType: string,
|
|
626
|
+
totalSize: number,
|
|
627
|
+
options: UploadOptions
|
|
628
|
+
): Promise<void> {
|
|
629
|
+
const response = await fetch(presignedUrl, {
|
|
630
|
+
method: "PUT",
|
|
631
|
+
headers: {
|
|
632
|
+
"Content-Type": contentType,
|
|
633
|
+
"Content-Length": String(totalSize),
|
|
634
|
+
},
|
|
635
|
+
body: blob,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
if (!response.ok) {
|
|
639
|
+
const errorText = await response.text();
|
|
640
|
+
throw new Error(
|
|
641
|
+
`Single-part upload failed (${response.status}): ${errorText}`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (options.onProgress) {
|
|
646
|
+
options.onProgress(90);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Upload blob using multipart upload (browser, private helper)
|
|
652
|
+
*/
|
|
653
|
+
private static async _uploadBlobMultipart(
|
|
654
|
+
blob: Blob,
|
|
655
|
+
partUrls: Array<{ part_number: number; url: string }>,
|
|
656
|
+
partSize: number,
|
|
657
|
+
totalSize: number,
|
|
658
|
+
options: UploadOptions
|
|
659
|
+
): Promise<void> {
|
|
660
|
+
const sortedParts = [...partUrls].sort((a, b) => a.part_number - b.part_number);
|
|
661
|
+
|
|
662
|
+
for (let i = 0; i < sortedParts.length; i++) {
|
|
663
|
+
const part = sortedParts[i];
|
|
664
|
+
const start = (part.part_number - 1) * partSize;
|
|
665
|
+
const end = Math.min(start + partSize, totalSize);
|
|
666
|
+
const chunk = blob.slice(start, end);
|
|
667
|
+
|
|
668
|
+
if (chunk.size === 0) {
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const response = await fetch(part.url, {
|
|
673
|
+
method: "PUT",
|
|
674
|
+
headers: {
|
|
675
|
+
"Content-Length": String(chunk.size),
|
|
676
|
+
},
|
|
677
|
+
body: chunk,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
if (!response.ok) {
|
|
681
|
+
const errorText = await response.text();
|
|
682
|
+
throw new Error(
|
|
683
|
+
`Multipart upload failed at part ${part.part_number} ` +
|
|
684
|
+
`(${response.status}): ${errorText}`
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (options.onProgress) {
|
|
689
|
+
const progress = Math.min(
|
|
690
|
+
90,
|
|
691
|
+
Math.floor(((i + 1) / sortedParts.length) * 90)
|
|
692
|
+
);
|
|
693
|
+
options.onProgress(progress);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
340
697
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
export { YetterImageClient } from "./api.js";
|
|
2
2
|
export * from "./types.js";
|
|
3
|
-
export { yetter } from "./client.js";
|
|
3
|
+
export { yetter } from "./client.js";
|
|
4
|
+
|
|
5
|
+
// Explicitly export upload-related types for better discoverability
|
|
6
|
+
export type {
|
|
7
|
+
UploadOptions,
|
|
8
|
+
GetUploadUrlRequest,
|
|
9
|
+
GetUploadUrlResponse,
|
|
10
|
+
UploadCompleteRequest,
|
|
11
|
+
UploadCompleteResponse,
|
|
12
|
+
} from "./types.js";
|
package/src/types.ts
CHANGED
|
@@ -114,3 +114,79 @@ export interface YetterStream extends AsyncIterable<StreamEvent> {
|
|
|
114
114
|
cancel(): Promise<void>; // Cancels the stream and attempts to cancel the underlying API request
|
|
115
115
|
getRequestId(): string; // Helper to get the request ID associated with the stream
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
// ============= Upload-Related Types =============
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Options for yetter.uploadFile() and yetter.uploadBlob()
|
|
122
|
+
*/
|
|
123
|
+
export interface UploadOptions {
|
|
124
|
+
/** Optional callback for upload progress (0-100) */
|
|
125
|
+
onProgress?: (progress: number) => void;
|
|
126
|
+
|
|
127
|
+
/** Optional custom filename (browser File uploads) */
|
|
128
|
+
filename?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Request to get presigned upload URL(s)
|
|
133
|
+
*/
|
|
134
|
+
export interface GetUploadUrlRequest {
|
|
135
|
+
/** Original filename with extension */
|
|
136
|
+
file_name: string;
|
|
137
|
+
|
|
138
|
+
/** MIME type (e.g., "image/jpeg") */
|
|
139
|
+
content_type: string;
|
|
140
|
+
|
|
141
|
+
/** File size in bytes */
|
|
142
|
+
size: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Response containing presigned URL(s) for upload
|
|
147
|
+
*/
|
|
148
|
+
export interface GetUploadUrlResponse {
|
|
149
|
+
/** Upload mode: "single" for small files, "multipart" for large files */
|
|
150
|
+
mode: "single" | "multipart";
|
|
151
|
+
|
|
152
|
+
/** S3 object key for tracking */
|
|
153
|
+
key: string;
|
|
154
|
+
|
|
155
|
+
/** Presigned PUT URL (single mode only) */
|
|
156
|
+
put_url?: string;
|
|
157
|
+
|
|
158
|
+
/** Size of each part in bytes (multipart mode only) */
|
|
159
|
+
part_size?: number;
|
|
160
|
+
|
|
161
|
+
/** Array of part URLs with part numbers (multipart mode only) */
|
|
162
|
+
part_urls?: Array<{
|
|
163
|
+
part_number: number;
|
|
164
|
+
url: string;
|
|
165
|
+
}>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Request to notify upload completion
|
|
170
|
+
*/
|
|
171
|
+
export interface UploadCompleteRequest {
|
|
172
|
+
/** S3 object key from GetUploadUrlResponse */
|
|
173
|
+
key: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Response after successful upload completion
|
|
178
|
+
*/
|
|
179
|
+
export interface UploadCompleteResponse {
|
|
180
|
+
/** Public URL to access the uploaded file */
|
|
181
|
+
url: string;
|
|
182
|
+
|
|
183
|
+
/** S3 object key */
|
|
184
|
+
key: string;
|
|
185
|
+
|
|
186
|
+
/** Optional metadata */
|
|
187
|
+
metadata?: {
|
|
188
|
+
size: number;
|
|
189
|
+
content_type: string;
|
|
190
|
+
uploaded_at?: string;
|
|
191
|
+
};
|
|
192
|
+
}
|