@yetter/client 0.0.11 → 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/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 !== "FAILED") {
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 === "FAILED") {
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({
@@ -188,7 +192,6 @@ export class yetter {
188
192
  const sseStreamUrl = `${client.getApiEndpoint()}/${model}/requests/${requestId}/status/stream`;
189
193
 
190
194
  let eventSource: EventSource;
191
- let streamEnded = false;
192
195
 
193
196
  // Setup the promise for the done() method
194
197
  let resolveDonePromise: (value: GetResponseResponse) => void;
@@ -204,6 +207,18 @@ export class yetter {
204
207
  events: [] as StreamEvent[],
205
208
  resolvers: [] as Array<(value: IteratorResult<StreamEvent, any>) => void>,
206
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
+ },
207
222
  push(event: StreamEvent) {
208
223
  if (this.isClosed) return;
209
224
  if (this.resolvers.length > 0) {
@@ -212,51 +227,39 @@ export class yetter {
212
227
  this.events.push(event);
213
228
  }
214
229
  // Check for terminal events to resolve/reject the donePromise
215
- if (event.status === "COMPLETED") {
216
- streamEnded = true;
217
- client.getResponse({ url: responseUrl })
218
- .then(resolveDonePromise)
219
- .catch(rejectDonePromise)
220
- .finally(() => this.close());
221
- } else if (event.status === "FAILED") {
222
- streamEnded = true;
223
- rejectDonePromise(new Error(event.logs?.map(l => l.message).join('\n') || `Stream reported FAILED for ${requestId}`));
224
- this.close();
225
- }
230
+ this.currentStatus = event.status as "COMPLETED" | "ERROR" | "CANCELLED" | "IN_PROGRESS" | "IN_QUEUE";
226
231
  },
227
232
  error(err: any) {
228
233
  if (this.isClosed) return;
229
-
230
- // If streamEnded is true, it means a terminal event (COMPLETED/FAILED)
231
- // has already been processed and is handling the donePromise.
232
- // This error is likely a secondary effect of the connection closing.
233
- if (!streamEnded) {
234
- rejectDonePromise(err); // Only reject if no terminal event was processed
235
- } else {
236
- 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 });
237
236
  }
238
-
239
- streamEnded = true; // Ensure it's marked as ended
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;
240
251
  if (this.resolvers.length > 0) {
241
- this.resolvers.shift()!({ value: undefined, done: true }); // Signal error to iterator consumer
252
+ this.resolvers.shift()!({ value: undefined, done: true });
242
253
  }
243
- this.close(); // Proceed to close EventSource etc.
254
+ this._close();
255
+ this.callRejecter(new Error("Stream was cancelled by user."));
244
256
  },
245
- close() {
257
+ _close() {
246
258
  if (this.isClosed) return;
247
259
  this.isClosed = true;
248
260
  this.resolvers.forEach(resolve => resolve({ value: undefined, done: true }));
249
261
  this.resolvers = [];
250
262
  eventSource?.close();
251
- // If donePromise is still pending, reject it as stream closed prematurely
252
- // Use a timeout to allow any final event processing for COMPLETED/FAILED to settle it first
253
- setTimeout(() => {
254
- donePromise.catch(() => {}).finally(() => { // check if it's already settled
255
- if (!streamEnded) { // If not explicitly completed or failed
256
- rejectDonePromise(new Error("Stream closed prematurely or unexpectedly."));
257
- }
258
- });
259
- }, 100);
260
263
  },
261
264
  next(): Promise<IteratorResult<StreamEvent, any>> {
262
265
  if (this.events.length > 0) {
@@ -287,10 +290,31 @@ export class yetter {
287
290
  controller.error(new Error(`Error parsing SSE 'data' event: ${e.message}`));
288
291
  }
289
292
  });
290
-
291
- eventSource.addEventListener('done', (event: MessageEvent) => {
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) => {
292
315
  // console.log("SSE 'done' event received, raw data:", event.data);
293
- controller.close();
316
+ console.log("SSE 'error' event received, raw data:", event.data);
317
+ controller.error(new Error("Stream reported ERROR for ${requestId}"));
294
318
  });
295
319
 
296
320
  eventSource.onerror = (err: Event | MessageEvent) => {
@@ -306,7 +330,7 @@ export class yetter {
306
330
  // Handle if API immediately returns a terminal status in initialApiResponse (e.g. already completed/failed)
307
331
  if (initialApiResponse.status === "COMPLETED"){
308
332
  controller.push(initialApiResponse as any as StreamEvent);
309
- } else if (initialApiResponse.status === "FAILED"){
333
+ } else if (initialApiResponse.status === "ERROR"){
310
334
  controller.push(initialApiResponse as any as StreamEvent);
311
335
  }
312
336
 
@@ -320,19 +344,354 @@ export class yetter {
320
344
  },
321
345
  done: () => donePromise,
322
346
  cancel: async () => {
323
- controller.close();
324
347
  try {
325
348
  await client.cancel({ url: cancelUrl });
326
349
  console.log(`Stream for ${requestId} - underlying request cancelled.`);
327
350
  } catch (e: any) {
328
351
  console.error(`Error cancelling underlying request for stream ${requestId}:`, e.message);
329
352
  }
330
- // Ensure donePromise is settled if not already
331
- if (!streamEnded) {
332
- rejectDonePromise(new Error("Stream was cancelled by user."));
333
- }
334
353
  },
335
354
  getRequestId: () => requestId,
336
355
  };
337
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
+ }
338
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
+ }