@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/dist/client.js CHANGED
@@ -32,7 +32,7 @@ export class yetter {
32
32
  let lastStatusResponse;
33
33
  const startTime = Date.now();
34
34
  const timeoutMilliseconds = 30 * 60 * 1000; // 30 minutes
35
- while (status !== "COMPLETED" && status !== "FAILED") {
35
+ while (status !== "COMPLETED" && status !== "ERROR" && status !== "CANCELLED") {
36
36
  if (Date.now() - startTime > timeoutMilliseconds) {
37
37
  console.warn(`Subscription timed out after 30 minutes for request ID: ${generateResponse.request_id}. Attempting to cancel.`);
38
38
  try {
@@ -59,10 +59,13 @@ export class yetter {
59
59
  throw error;
60
60
  }
61
61
  }
62
- if (status === "FAILED") {
62
+ if (status === "ERROR") {
63
63
  const errorMessage = ((_b = lastStatusResponse === null || lastStatusResponse === void 0 ? void 0 : lastStatusResponse.logs) === null || _b === void 0 ? void 0 : _b.map(log => log.message).join("\n")) || "Image generation failed.";
64
64
  throw new Error(errorMessage);
65
65
  }
66
+ else if (status === "CANCELLED") {
67
+ throw new Error("Image generation was cancelled by user.");
68
+ }
66
69
  const finalResponse = await client.getResponse({
67
70
  url: generateResponse.response_url,
68
71
  });
@@ -80,13 +83,11 @@ export class yetter {
80
83
  model: model,
81
84
  ...options.input,
82
85
  });
83
- console.timeEnd("Initial API Response");
84
86
  const requestId = initialApiResponse.request_id;
85
87
  const responseUrl = initialApiResponse.response_url;
86
88
  const cancelUrl = initialApiResponse.cancel_url;
87
89
  const sseStreamUrl = `${client.getApiEndpoint()}/${model}/requests/${requestId}/status/stream`;
88
90
  let eventSource;
89
- let streamEnded = false;
90
91
  // Setup the promise for the done() method
91
92
  let resolveDonePromise;
92
93
  let rejectDonePromise;
@@ -100,8 +101,21 @@ export class yetter {
100
101
  events: [],
101
102
  resolvers: [],
102
103
  isClosed: false,
104
+ isSettled: false,
105
+ currentStatus: "",
106
+ callResolver(value) {
107
+ if (this.isSettled)
108
+ return;
109
+ this.isSettled = true;
110
+ resolveDonePromise(value);
111
+ },
112
+ callRejecter(reason) {
113
+ if (this.isSettled)
114
+ return;
115
+ this.isSettled = true;
116
+ rejectDonePromise(reason);
117
+ },
103
118
  push(event) {
104
- var _b;
105
119
  if (this.isClosed)
106
120
  return;
107
121
  if (this.resolvers.length > 0) {
@@ -111,53 +125,43 @@ export class yetter {
111
125
  this.events.push(event);
112
126
  }
113
127
  // Check for terminal events to resolve/reject the donePromise
114
- if (event.status === "COMPLETED") {
115
- streamEnded = true;
116
- client.getResponse({ url: responseUrl })
117
- .then(resolveDonePromise)
118
- .catch(rejectDonePromise)
119
- .finally(() => this.close());
120
- }
121
- else if (event.status === "FAILED") {
122
- streamEnded = true;
123
- rejectDonePromise(new Error(((_b = event.logs) === null || _b === void 0 ? void 0 : _b.map(l => l.message).join('\n')) || `Stream reported FAILED for ${requestId}`));
124
- this.close();
125
- }
128
+ this.currentStatus = event.status;
126
129
  },
127
130
  error(err) {
128
131
  if (this.isClosed)
129
132
  return;
130
- // If streamEnded is true, it means a terminal event (COMPLETED/FAILED)
131
- // has already been processed and is handling the donePromise.
132
- // This error is likely a secondary effect of the connection closing.
133
- if (!streamEnded) {
134
- rejectDonePromise(err); // Only reject if no terminal event was processed
133
+ if (this.resolvers.length > 0) {
134
+ this.resolvers.shift()({ value: undefined, done: true });
135
135
  }
136
- else {
137
- console.warn("SSE 'onerror' event after stream was considered ended (COMPLETED/FAILED). This error will not alter the done() promise.", err);
136
+ this._close();
137
+ const errorToReport = err instanceof Error ? err : new Error("Stream closed prematurely or unexpectedly.");
138
+ this.callRejecter(errorToReport);
139
+ },
140
+ done(value) {
141
+ if (this.isClosed)
142
+ return;
143
+ if (this.resolvers.length > 0) {
144
+ this.resolvers.shift()({ value: undefined, done: true });
138
145
  }
139
- streamEnded = true; // Ensure it's marked as ended
146
+ this._close();
147
+ this.callResolver(value);
148
+ },
149
+ cancel() {
150
+ if (this.isClosed)
151
+ return;
140
152
  if (this.resolvers.length > 0) {
141
- this.resolvers.shift()({ value: undefined, done: true }); // Signal error to iterator consumer
153
+ this.resolvers.shift()({ value: undefined, done: true });
142
154
  }
143
- this.close(); // Proceed to close EventSource etc.
155
+ this._close();
156
+ this.callRejecter(new Error("Stream was cancelled by user."));
144
157
  },
145
- close() {
158
+ _close() {
146
159
  if (this.isClosed)
147
160
  return;
148
161
  this.isClosed = true;
149
162
  this.resolvers.forEach(resolve => resolve({ value: undefined, done: true }));
150
163
  this.resolvers = [];
151
164
  eventSource === null || eventSource === void 0 ? void 0 : eventSource.close();
152
- // If donePromise is still pending, reject it as stream closed prematurely
153
- // Use a timeout to allow any final event processing for COMPLETED/FAILED to settle it first
154
- setTimeout(() => {
155
- donePromise.catch(() => { }).finally(() => {
156
- if (!streamEnded) { // If not explicitly completed or failed
157
- rejectDonePromise(new Error("Stream closed prematurely or unexpectedly."));
158
- }
159
- });
160
- }, 100);
161
165
  },
162
166
  next() {
163
167
  if (this.events.length > 0) {
@@ -171,7 +175,6 @@ export class yetter {
171
175
  };
172
176
  eventSource = new EventSourcePolyfill(sseStreamUrl, {
173
177
  headers: { 'Authorization': `${_a.apiKey}` },
174
- heartbeatTimeout: 3000,
175
178
  });
176
179
  eventSource.onopen = (event) => {
177
180
  console.log("SSE Connection Opened:", event);
@@ -187,9 +190,36 @@ export class yetter {
187
190
  controller.error(new Error(`Error parsing SSE 'data' event: ${e.message}`));
188
191
  }
189
192
  });
190
- eventSource.addEventListener('done', (event) => {
193
+ // when 'done' event is received, currentStatus can only be COMPLETED or CANCELLED
194
+ // TODO: remove currentStatus and branch two cases(COMPLETED and CANCELLED) by response status by responseUrl
195
+ // TODO: Determine whether raise error or not when response status is CANCELLED
196
+ // => current code raise error when response status is CANCELLED because resolveDonePromise only get completed response
197
+ // => this mean that user expect only completed response from .done() method
198
+ eventSource.addEventListener('done', async (event) => {
191
199
  // console.log("SSE 'done' event received, raw data:", event.data);
192
- controller.close();
200
+ try {
201
+ if (controller.currentStatus === "COMPLETED") {
202
+ // Close SSE immediately to avoid any late 'error' events during await
203
+ try {
204
+ eventSource === null || eventSource === void 0 ? void 0 : eventSource.close();
205
+ }
206
+ catch { }
207
+ const response = await client.getResponse({ url: responseUrl });
208
+ controller.done(response);
209
+ }
210
+ else if (controller.currentStatus === "CANCELLED") {
211
+ controller.cancel();
212
+ }
213
+ }
214
+ catch (e) {
215
+ console.error("Error parsing SSE 'done' event:", e, "Raw data:", event.data);
216
+ controller.error(new Error(`Error parsing SSE 'done' event: ${e.message}`));
217
+ }
218
+ });
219
+ eventSource.addEventListener('error', (event) => {
220
+ // console.log("SSE 'done' event received, raw data:", event.data);
221
+ console.log("SSE 'error' event received, raw data:", event.data);
222
+ controller.error(new Error("Stream reported ERROR for ${requestId}"));
193
223
  });
194
224
  eventSource.onerror = (err) => {
195
225
  var _b;
@@ -205,7 +235,7 @@ export class yetter {
205
235
  if (initialApiResponse.status === "COMPLETED") {
206
236
  controller.push(initialApiResponse);
207
237
  }
208
- else if (initialApiResponse.status === "FAILED") {
238
+ else if (initialApiResponse.status === "ERROR") {
209
239
  controller.push(initialApiResponse);
210
240
  }
211
241
  return {
@@ -219,7 +249,6 @@ export class yetter {
219
249
  },
220
250
  done: () => donePromise,
221
251
  cancel: async () => {
222
- controller.close();
223
252
  try {
224
253
  await client.cancel({ url: cancelUrl });
225
254
  console.log(`Stream for ${requestId} - underlying request cancelled.`);
@@ -227,14 +256,237 @@ export class yetter {
227
256
  catch (e) {
228
257
  console.error(`Error cancelling underlying request for stream ${requestId}:`, e.message);
229
258
  }
230
- // Ensure donePromise is settled if not already
231
- if (!streamEnded) {
232
- rejectDonePromise(new Error("Stream was cancelled by user."));
233
- }
234
259
  },
235
260
  getRequestId: () => requestId,
236
261
  };
237
262
  }
263
+ /**
264
+ * Upload a file from the filesystem (Node.js) or File/Blob object (browser)
265
+ *
266
+ * @param fileOrPath File path (Node.js) or File/Blob object (browser)
267
+ * @param options Upload configuration options
268
+ * @returns Promise resolving to upload result with public URL
269
+ *
270
+ * @example
271
+ * ```typescript
272
+ * // Node.js
273
+ * const result = await yetter.uploadFile("/path/to/image.jpg", {
274
+ * onProgress: (pct) => console.log(`Upload: ${pct}%`)
275
+ * });
276
+ *
277
+ * // Browser
278
+ * const fileInput = document.querySelector('input[type="file"]');
279
+ * const file = fileInput.files[0];
280
+ * const result = await yetter.uploadFile(file, {
281
+ * onProgress: (pct) => updateProgressBar(pct)
282
+ * });
283
+ * ```
284
+ */
285
+ static async uploadFile(fileOrPath, options = {}) {
286
+ if (!_a.apiKey) {
287
+ throw new Error("API key is not configured. Call yetter.configure()");
288
+ }
289
+ if (typeof fileOrPath === 'string') {
290
+ // Node.js path
291
+ return _a._uploadFromPath(fileOrPath, options);
292
+ }
293
+ else {
294
+ // Browser File/Blob
295
+ return _a._uploadFromBlob(fileOrPath, options);
296
+ }
297
+ }
298
+ /**
299
+ * Upload a file from browser (File or Blob object)
300
+ * This is an alias for uploadFile for better clarity in browser contexts
301
+ */
302
+ static async uploadBlob(file, options = {}) {
303
+ return _a.uploadFile(file, options);
304
+ }
305
+ /**
306
+ * Upload file from filesystem path (Node.js only)
307
+ */
308
+ static async _uploadFromPath(filePath, options) {
309
+ // Dynamic import for Node.js modules
310
+ const fs = await import('fs');
311
+ const mime = await import('mime-types');
312
+ // Validate file exists
313
+ if (!fs.existsSync(filePath)) {
314
+ throw new Error(`File not found: ${filePath}`);
315
+ }
316
+ const stats = fs.statSync(filePath);
317
+ const fileSize = stats.size;
318
+ const fileName = filePath.split(/[\\/]/).pop() || "upload";
319
+ const mimeType = mime.default.lookup(filePath) || "application/octet-stream";
320
+ const client = new YetterImageClient({
321
+ apiKey: _a.apiKey,
322
+ endpoint: _a.endpoint,
323
+ });
324
+ // Step 1: Request presigned URL(s)
325
+ const uploadUrlResponse = await client.getUploadUrl({
326
+ file_name: fileName,
327
+ content_type: mimeType,
328
+ size: fileSize,
329
+ });
330
+ // Step 2: Upload file content
331
+ if (uploadUrlResponse.mode === "single") {
332
+ await _a._uploadFileSingle(filePath, uploadUrlResponse.put_url, mimeType, fileSize, options, fs);
333
+ }
334
+ else {
335
+ await _a._uploadFileMultipart(filePath, uploadUrlResponse.part_urls, uploadUrlResponse.part_size, fileSize, options, fs);
336
+ }
337
+ // Step 3: Notify completion
338
+ const result = await client.uploadComplete({
339
+ key: uploadUrlResponse.key,
340
+ });
341
+ if (options.onProgress) {
342
+ options.onProgress(100);
343
+ }
344
+ return result;
345
+ }
346
+ /**
347
+ * Upload file from Blob/File object (browser)
348
+ */
349
+ static async _uploadFromBlob(file, options) {
350
+ const fileSize = file.size;
351
+ const fileName = options.filename ||
352
+ (file instanceof File ? file.name : "blob-upload");
353
+ const mimeType = file.type || "application/octet-stream";
354
+ const client = new YetterImageClient({
355
+ apiKey: _a.apiKey,
356
+ endpoint: _a.endpoint,
357
+ });
358
+ // Step 1: Request presigned URL(s)
359
+ const uploadUrlResponse = await client.getUploadUrl({
360
+ file_name: fileName,
361
+ content_type: mimeType,
362
+ size: fileSize,
363
+ });
364
+ // Step 2: Upload file content
365
+ if (uploadUrlResponse.mode === "single") {
366
+ await _a._uploadBlobSingle(file, uploadUrlResponse.put_url, mimeType, fileSize, options);
367
+ }
368
+ else {
369
+ await _a._uploadBlobMultipart(file, uploadUrlResponse.part_urls, uploadUrlResponse.part_size, fileSize, options);
370
+ }
371
+ // Step 3: Notify completion
372
+ const result = await client.uploadComplete({
373
+ key: uploadUrlResponse.key,
374
+ });
375
+ if (options.onProgress) {
376
+ options.onProgress(100);
377
+ }
378
+ return result;
379
+ }
380
+ /**
381
+ * Upload file using single PUT request (Node.js, private helper)
382
+ */
383
+ static async _uploadFileSingle(filePath, presignedUrl, contentType, totalSize, options, fs) {
384
+ const fileBuffer = fs.readFileSync(filePath);
385
+ const response = await fetch(presignedUrl, {
386
+ method: "PUT",
387
+ headers: {
388
+ "Content-Type": contentType,
389
+ "Content-Length": String(totalSize),
390
+ },
391
+ body: fileBuffer,
392
+ });
393
+ if (!response.ok) {
394
+ const errorText = await response.text();
395
+ throw new Error(`Single-part upload failed (${response.status}): ${errorText}`);
396
+ }
397
+ if (options.onProgress) {
398
+ options.onProgress(90);
399
+ }
400
+ }
401
+ /**
402
+ * Upload file using multipart upload (Node.js, private helper)
403
+ */
404
+ static async _uploadFileMultipart(filePath, partUrls, partSize, totalSize, options, fs) {
405
+ const sortedParts = [...partUrls].sort((a, b) => a.part_number - b.part_number);
406
+ const fileHandle = fs.openSync(filePath, "r");
407
+ try {
408
+ for (let i = 0; i < sortedParts.length; i++) {
409
+ const part = sortedParts[i];
410
+ const buffer = Buffer.alloc(partSize);
411
+ const offset = (part.part_number - 1) * partSize;
412
+ const bytesRead = fs.readSync(fileHandle, buffer, 0, partSize, offset);
413
+ const chunk = buffer.slice(0, bytesRead);
414
+ if (chunk.length === 0) {
415
+ break;
416
+ }
417
+ const response = await fetch(part.url, {
418
+ method: "PUT",
419
+ headers: {
420
+ "Content-Length": String(chunk.length),
421
+ },
422
+ body: chunk,
423
+ });
424
+ if (!response.ok) {
425
+ const errorText = await response.text();
426
+ throw new Error(`Multipart upload failed at part ${part.part_number} ` +
427
+ `(${response.status}): ${errorText}`);
428
+ }
429
+ if (options.onProgress) {
430
+ const progress = Math.min(90, Math.floor(((i + 1) / sortedParts.length) * 90));
431
+ options.onProgress(progress);
432
+ }
433
+ }
434
+ }
435
+ finally {
436
+ fs.closeSync(fileHandle);
437
+ }
438
+ }
439
+ /**
440
+ * Upload blob using single PUT request (browser, private helper)
441
+ */
442
+ static async _uploadBlobSingle(blob, presignedUrl, contentType, totalSize, options) {
443
+ const response = await fetch(presignedUrl, {
444
+ method: "PUT",
445
+ headers: {
446
+ "Content-Type": contentType,
447
+ "Content-Length": String(totalSize),
448
+ },
449
+ body: blob,
450
+ });
451
+ if (!response.ok) {
452
+ const errorText = await response.text();
453
+ throw new Error(`Single-part upload failed (${response.status}): ${errorText}`);
454
+ }
455
+ if (options.onProgress) {
456
+ options.onProgress(90);
457
+ }
458
+ }
459
+ /**
460
+ * Upload blob using multipart upload (browser, private helper)
461
+ */
462
+ static async _uploadBlobMultipart(blob, partUrls, partSize, totalSize, options) {
463
+ const sortedParts = [...partUrls].sort((a, b) => a.part_number - b.part_number);
464
+ for (let i = 0; i < sortedParts.length; i++) {
465
+ const part = sortedParts[i];
466
+ const start = (part.part_number - 1) * partSize;
467
+ const end = Math.min(start + partSize, totalSize);
468
+ const chunk = blob.slice(start, end);
469
+ if (chunk.size === 0) {
470
+ break;
471
+ }
472
+ const response = await fetch(part.url, {
473
+ method: "PUT",
474
+ headers: {
475
+ "Content-Length": String(chunk.size),
476
+ },
477
+ body: chunk,
478
+ });
479
+ if (!response.ok) {
480
+ const errorText = await response.text();
481
+ throw new Error(`Multipart upload failed at part ${part.part_number} ` +
482
+ `(${response.status}): ${errorText}`);
483
+ }
484
+ if (options.onProgress) {
485
+ const progress = Math.min(90, Math.floor(((i + 1) / sortedParts.length) * 90));
486
+ options.onProgress(progress);
487
+ }
488
+ }
489
+ }
238
490
  }
239
491
  _a = yetter;
240
492
  yetter.apiKey = 'Key ' + (process.env.YTR_API_KEY || process.env.REACT_APP_YTR_API_KEY || "");
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { YetterImageClient } from "./api.js";
2
2
  export * from "./types.js";
3
3
  export { yetter } from "./client.js";
4
+ export type { UploadOptions, GetUploadUrlRequest, GetUploadUrlResponse, UploadCompleteRequest, UploadCompleteResponse, } from "./types.js";
package/dist/types.d.ts CHANGED
@@ -90,3 +90,63 @@ export interface YetterStream extends AsyncIterable<StreamEvent> {
90
90
  cancel(): Promise<void>;
91
91
  getRequestId(): string;
92
92
  }
93
+ /**
94
+ * Options for yetter.uploadFile() and yetter.uploadBlob()
95
+ */
96
+ export interface UploadOptions {
97
+ /** Optional callback for upload progress (0-100) */
98
+ onProgress?: (progress: number) => void;
99
+ /** Optional custom filename (browser File uploads) */
100
+ filename?: string;
101
+ }
102
+ /**
103
+ * Request to get presigned upload URL(s)
104
+ */
105
+ export interface GetUploadUrlRequest {
106
+ /** Original filename with extension */
107
+ file_name: string;
108
+ /** MIME type (e.g., "image/jpeg") */
109
+ content_type: string;
110
+ /** File size in bytes */
111
+ size: number;
112
+ }
113
+ /**
114
+ * Response containing presigned URL(s) for upload
115
+ */
116
+ export interface GetUploadUrlResponse {
117
+ /** Upload mode: "single" for small files, "multipart" for large files */
118
+ mode: "single" | "multipart";
119
+ /** S3 object key for tracking */
120
+ key: string;
121
+ /** Presigned PUT URL (single mode only) */
122
+ put_url?: string;
123
+ /** Size of each part in bytes (multipart mode only) */
124
+ part_size?: number;
125
+ /** Array of part URLs with part numbers (multipart mode only) */
126
+ part_urls?: Array<{
127
+ part_number: number;
128
+ url: string;
129
+ }>;
130
+ }
131
+ /**
132
+ * Request to notify upload completion
133
+ */
134
+ export interface UploadCompleteRequest {
135
+ /** S3 object key from GetUploadUrlResponse */
136
+ key: string;
137
+ }
138
+ /**
139
+ * Response after successful upload completion
140
+ */
141
+ export interface UploadCompleteResponse {
142
+ /** Public URL to access the uploaded file */
143
+ url: string;
144
+ /** S3 object key */
145
+ key: string;
146
+ /** Optional metadata */
147
+ metadata?: {
148
+ size: number;
149
+ content_type: string;
150
+ uploaded_at?: string;
151
+ };
152
+ }
@@ -44,7 +44,7 @@ async function main() {
44
44
  console.log("Prompt:", finalResult.data.prompt);
45
45
  success = true;
46
46
  break; // Exit loop on success
47
- } else if (currentStatus === "FAILED") {
47
+ } else if (currentStatus === "ERROR") {
48
48
  console.error(`Request ${request_id} FAILED. Logs:`, statusResult.data.logs);
49
49
  break; // Exit loop on failure
50
50
  }
@@ -19,7 +19,7 @@ async function main() {
19
19
  update.logs.map((log) => log.message).forEach(logMessage => console.log(` - ${logMessage}`));
20
20
  } else if (update.status === "COMPLETED") {
21
21
  console.log("Processing completed!");
22
- } else if (update.status === "FAILED") {
22
+ } else if (update.status === "ERROR") {
23
23
  console.error("Processing failed. Logs:", update.logs);
24
24
  }
25
25
  },
@@ -0,0 +1,39 @@
1
+ import { yetter } from "../src/client.js";
2
+
3
+ async function main() {
4
+ // Configure with API key
5
+ yetter.configure({
6
+ apiKey: process.env.YTR_API_KEY || "your_api_key_here"
7
+ });
8
+
9
+ console.log("Step 1: Uploading input image...");
10
+
11
+ // Replace with your actual image path
12
+ const imagePath = "./test-image.jpg";
13
+
14
+ try {
15
+ const uploadResult = await yetter.uploadFile(imagePath, {
16
+ onProgress: (pct) => console.log(` Upload progress: ${pct}%`),
17
+ });
18
+ console.log(`✓ Uploaded: ${uploadResult.url}\n`);
19
+
20
+ console.log("Step 2: Generating with uploaded image...");
21
+ const genResult = await yetter.subscribe("ytr-ai/qwen/image-edit/i2i", {
22
+ input: {
23
+ prompt: "Transform to watercolor painting style",
24
+ image_url: [uploadResult.url],
25
+ num_inference_steps: 28,
26
+ },
27
+ onQueueUpdate: (status) => {
28
+ console.log(` Status: ${status.status}, Queue: ${status.queue_position}`);
29
+ },
30
+ });
31
+
32
+ console.log("\n✓ Generation complete!");
33
+ console.log("Generated images:", genResult.images);
34
+ } catch (error: any) {
35
+ console.error("Error:", error.message);
36
+ }
37
+ }
38
+
39
+ main().catch(console.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yetter/client",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc",
@@ -23,9 +23,11 @@
23
23
  "description": "",
24
24
  "dependencies": {
25
25
  "@types/eventsource": "^1.1.15",
26
+ "@types/mime-types": "^3.0.1",
26
27
  "cross-fetch": "^4.1.0",
27
28
  "event-source-polyfill": "^1.0.31",
28
- "eventsource": "^4.0.0"
29
+ "eventsource": "^4.0.0",
30
+ "mime-types": "^3.0.2"
29
31
  },
30
32
  "devDependencies": {
31
33
  "@types/event-source-polyfill": "^1.0.5",
package/src/api.ts CHANGED
@@ -9,6 +9,10 @@ import {
9
9
  CancelResponse,
10
10
  GetResponseRequest,
11
11
  GetResponseResponse,
12
+ GetUploadUrlRequest,
13
+ GetUploadUrlResponse,
14
+ UploadCompleteRequest,
15
+ UploadCompleteResponse,
12
16
  } from "./types";
13
17
 
14
18
  export class YetterImageClient {
@@ -102,4 +106,54 @@ export class YetterImageClient {
102
106
  }
103
107
  return (await res.json()) as GetResponseResponse;
104
108
  }
109
+
110
+ /**
111
+ * Request presigned URL(s) for file upload
112
+ * @param body Upload request parameters
113
+ * @returns Presigned URL response with mode (single/multipart)
114
+ */
115
+ public async getUploadUrl(
116
+ body: GetUploadUrlRequest
117
+ ): Promise<GetUploadUrlResponse> {
118
+ const res = await fetch(`${this.endpoint}/uploads`, {
119
+ method: "POST",
120
+ headers: {
121
+ "Content-Type": "application/json",
122
+ Authorization: this.apiKey,
123
+ },
124
+ body: JSON.stringify(body),
125
+ });
126
+
127
+ if (!res.ok) {
128
+ const errorText = await res.text();
129
+ throw new Error(`Upload URL request failed (${res.status}): ${errorText}`);
130
+ }
131
+
132
+ return (await res.json()) as GetUploadUrlResponse;
133
+ }
134
+
135
+ /**
136
+ * Notify server that upload is complete
137
+ * @param body Completion request with S3 key
138
+ * @returns Uploaded file metadata with public URL
139
+ */
140
+ public async uploadComplete(
141
+ body: UploadCompleteRequest
142
+ ): Promise<UploadCompleteResponse> {
143
+ const res = await fetch(`${this.endpoint}/uploads/complete`, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "application/json",
147
+ Authorization: this.apiKey,
148
+ },
149
+ body: JSON.stringify(body),
150
+ });
151
+
152
+ if (!res.ok) {
153
+ const errorText = await res.text();
154
+ throw new Error(`Upload completion failed (${res.status}): ${errorText}`);
155
+ }
156
+
157
+ return (await res.json()) as UploadCompleteResponse;
158
+ }
105
159
  }