@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/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
  });
@@ -85,7 +88,6 @@ export class yetter {
85
88
  const cancelUrl = initialApiResponse.cancel_url;
86
89
  const sseStreamUrl = `${client.getApiEndpoint()}/${model}/requests/${requestId}/status/stream`;
87
90
  let eventSource;
88
- let streamEnded = false;
89
91
  // Setup the promise for the done() method
90
92
  let resolveDonePromise;
91
93
  let rejectDonePromise;
@@ -99,8 +101,21 @@ export class yetter {
99
101
  events: [],
100
102
  resolvers: [],
101
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
+ },
102
118
  push(event) {
103
- var _b;
104
119
  if (this.isClosed)
105
120
  return;
106
121
  if (this.resolvers.length > 0) {
@@ -110,53 +125,43 @@ export class yetter {
110
125
  this.events.push(event);
111
126
  }
112
127
  // Check for terminal events to resolve/reject the donePromise
113
- if (event.status === "COMPLETED") {
114
- streamEnded = true;
115
- client.getResponse({ url: responseUrl })
116
- .then(resolveDonePromise)
117
- .catch(rejectDonePromise)
118
- .finally(() => this.close());
119
- }
120
- else if (event.status === "FAILED") {
121
- streamEnded = true;
122
- rejectDonePromise(new Error(((_b = event.logs) === null || _b === void 0 ? void 0 : _b.map(l => l.message).join('\n')) || `Stream reported FAILED for ${requestId}`));
123
- this.close();
124
- }
128
+ this.currentStatus = event.status;
125
129
  },
126
130
  error(err) {
127
131
  if (this.isClosed)
128
132
  return;
129
- // If streamEnded is true, it means a terminal event (COMPLETED/FAILED)
130
- // has already been processed and is handling the donePromise.
131
- // This error is likely a secondary effect of the connection closing.
132
- if (!streamEnded) {
133
- rejectDonePromise(err); // Only reject if no terminal event was processed
133
+ if (this.resolvers.length > 0) {
134
+ this.resolvers.shift()({ value: undefined, done: true });
134
135
  }
135
- else {
136
- 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 });
137
145
  }
138
- streamEnded = true; // Ensure it's marked as ended
146
+ this._close();
147
+ this.callResolver(value);
148
+ },
149
+ cancel() {
150
+ if (this.isClosed)
151
+ return;
139
152
  if (this.resolvers.length > 0) {
140
- this.resolvers.shift()({ value: undefined, done: true }); // Signal error to iterator consumer
153
+ this.resolvers.shift()({ value: undefined, done: true });
141
154
  }
142
- this.close(); // Proceed to close EventSource etc.
155
+ this._close();
156
+ this.callRejecter(new Error("Stream was cancelled by user."));
143
157
  },
144
- close() {
158
+ _close() {
145
159
  if (this.isClosed)
146
160
  return;
147
161
  this.isClosed = true;
148
162
  this.resolvers.forEach(resolve => resolve({ value: undefined, done: true }));
149
163
  this.resolvers = [];
150
164
  eventSource === null || eventSource === void 0 ? void 0 : eventSource.close();
151
- // If donePromise is still pending, reject it as stream closed prematurely
152
- // Use a timeout to allow any final event processing for COMPLETED/FAILED to settle it first
153
- setTimeout(() => {
154
- donePromise.catch(() => { }).finally(() => {
155
- if (!streamEnded) { // If not explicitly completed or failed
156
- rejectDonePromise(new Error("Stream closed prematurely or unexpectedly."));
157
- }
158
- });
159
- }, 100);
160
165
  },
161
166
  next() {
162
167
  if (this.events.length > 0) {
@@ -185,9 +190,36 @@ export class yetter {
185
190
  controller.error(new Error(`Error parsing SSE 'data' event: ${e.message}`));
186
191
  }
187
192
  });
188
- 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) => {
189
199
  // console.log("SSE 'done' event received, raw data:", event.data);
190
- 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}"));
191
223
  });
192
224
  eventSource.onerror = (err) => {
193
225
  var _b;
@@ -203,7 +235,7 @@ export class yetter {
203
235
  if (initialApiResponse.status === "COMPLETED") {
204
236
  controller.push(initialApiResponse);
205
237
  }
206
- else if (initialApiResponse.status === "FAILED") {
238
+ else if (initialApiResponse.status === "ERROR") {
207
239
  controller.push(initialApiResponse);
208
240
  }
209
241
  return {
@@ -217,7 +249,6 @@ export class yetter {
217
249
  },
218
250
  done: () => donePromise,
219
251
  cancel: async () => {
220
- controller.close();
221
252
  try {
222
253
  await client.cancel({ url: cancelUrl });
223
254
  console.log(`Stream for ${requestId} - underlying request cancelled.`);
@@ -225,14 +256,237 @@ export class yetter {
225
256
  catch (e) {
226
257
  console.error(`Error cancelling underlying request for stream ${requestId}:`, e.message);
227
258
  }
228
- // Ensure donePromise is settled if not already
229
- if (!streamEnded) {
230
- rejectDonePromise(new Error("Stream was cancelled by user."));
231
- }
232
259
  },
233
260
  getRequestId: () => requestId,
234
261
  };
235
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
+ }
236
490
  }
237
491
  _a = yetter;
238
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.11",
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
  }