@eigenpal/sdk 0.16.3 → 0.16.4

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.
@@ -7,7 +7,37 @@ import {
7
7
  filesUploadsAbort,
8
8
  filesUploadsComplete,
9
9
  filesUploadsCreate,
10
+ filesUploadsGet,
11
+ filesUploadsPartsList,
12
+ filesUploadsPartsPresign,
10
13
  } from '../generated/sdk.gen';
14
+ import {
15
+ byteViewToArrayBuffer,
16
+ destroyUnreadNodeReadable,
17
+ ensureNodeReadableFullySent,
18
+ isNodeReadableStream,
19
+ toPutBody,
20
+ type StreamableRequestInit,
21
+ } from '../lib/fetch-body';
22
+ import {
23
+ isFileDescriptor,
24
+ isFileInput,
25
+ isPathFileInput,
26
+ isStreamFactoryFileInput,
27
+ resolveFileBlob,
28
+ statUploadSize,
29
+ type FileInput,
30
+ } from '../lib/files';
31
+ import {
32
+ annotateMultipartCompleteFailure,
33
+ annotatePresignedPutCompleteFailure,
34
+ filterStorageHeaders,
35
+ PartUploadHttpError,
36
+ shouldAbortMultipartUploadSession,
37
+ shouldAbortPresignedPutUploadSession,
38
+ uploadPresignedMultipartParts,
39
+ type ListedUploadPart,
40
+ } from '../lib/upload-presigned-multipart';
11
41
 
12
42
  type Dispatch = <T>(
13
43
  call: () => Promise<OperationResult<T>>,
@@ -54,58 +84,136 @@ function newIdempotencyKey(): string {
54
84
  return `upload_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
55
85
  }
56
86
 
87
+ type UploadableFile = FileInput | Uint8Array | ArrayBuffer;
88
+
57
89
  export class FilesResource {
58
90
  constructor(
59
91
  private readonly client: Client,
60
92
  private readonly dispatch: Dispatch
61
93
  ) {}
62
94
 
63
- async upload(file: Blob | File, options: UploadOptions = {}): Promise<AnyResponse> {
64
- const filename =
65
- options.filename ??
66
- (typeof File !== 'undefined' && file instanceof File ? file.name : undefined);
67
- if (!filename) throw new Error('filename is required when uploading a Blob');
95
+ async upload(file: UploadableFile, options: UploadOptions = {}): Promise<AnyResponse> {
96
+ const source = await resolveUploadableSource(file, options.filename);
68
97
  const idempotencyKey = options.idempotencyKey ?? newIdempotencyKey();
69
98
  const negotiation = await this.createUpload(
70
99
  {
71
- filename,
72
- contentType: file.type || 'application/octet-stream',
73
- size: file.size,
100
+ filename: source.filename,
101
+ contentType: source.contentType,
102
+ size: source.size,
74
103
  idempotencyKey,
75
104
  ...(options.purpose ? { purpose: options.purpose } : {}),
76
105
  },
77
106
  options
78
107
  );
79
108
 
80
- if (negotiation.transport === 'presigned-put') {
109
+ if (negotiation.transport === 'presigned-multipart') {
110
+ if (!source.replayable) {
111
+ throw new Error(
112
+ 'Cannot resume a multipart upload from a one-shot stream. Pass a file path, Blob, or a reusable stream factory.'
113
+ );
114
+ }
115
+ let partsReady = false;
81
116
  try {
82
- const response = await fetch(negotiation.url, {
83
- method: 'PUT',
84
- headers: Object.fromEntries(
85
- Object.entries((negotiation.headers ?? {}) as Record<string, string>).filter(
86
- ([name]) => name.toLowerCase() !== 'content-length'
87
- )
88
- ),
89
- body: file,
117
+ await uploadPresignedMultipartParts({
118
+ partCount: negotiation.partCount,
119
+ partSizeBytes: negotiation.partSizeBytes,
120
+ totalSize: source.size,
90
121
  signal: options.signal,
122
+ onProgress: options.onProgress,
123
+ listParts: async () => {
124
+ const listed = await this.dispatch(
125
+ () =>
126
+ this.client.get({
127
+ url: negotiation.partsUrl,
128
+ signal: options.signal,
129
+ }) as Promise<OperationResult<{ parts?: ListedUploadPart[] }>>
130
+ );
131
+ return listed.parts ?? [];
132
+ },
133
+ presignPart: async (partNumber) => {
134
+ return this.dispatch(
135
+ () =>
136
+ this.client.post({
137
+ url: negotiation.partsUrl,
138
+ body: { partNumber } as never,
139
+ signal: options.signal,
140
+ }) as Promise<
141
+ OperationResult<{
142
+ url: string;
143
+ headers?: Record<string, string>;
144
+ partSizeBytes: number;
145
+ }>
146
+ >
147
+ );
148
+ },
149
+ putPart: async ({ url, headers, start, length }) => {
150
+ const body = await source.openPart(start, length);
151
+ const response = await putStorage(
152
+ url,
153
+ headers,
154
+ body,
155
+ options.signal,
156
+ source.stripContentLength,
157
+ length
158
+ );
159
+ if (!response.ok) throw new PartUploadHttpError(response.status);
160
+ },
91
161
  });
162
+ partsReady = true;
163
+ return await this.dispatch(
164
+ () =>
165
+ this.client.post({
166
+ url: negotiation.completeUrl,
167
+ body: {} as never,
168
+ signal: options.signal,
169
+ }) as Promise<OperationResult<AnyResponse>>
170
+ );
171
+ } catch (error) {
172
+ if (shouldAbortMultipartUploadSession({ partsReady })) {
173
+ await this.abortUpload(negotiation.uploadId, {
174
+ signal: uploadAbortCleanupSignal(),
175
+ }).catch(() => undefined);
176
+ throw error;
177
+ }
178
+ throw annotateMultipartCompleteFailure(negotiation.uploadId, error);
179
+ }
180
+ }
181
+
182
+ if (negotiation.transport === 'presigned-put') {
183
+ let putReady = false;
184
+ try {
185
+ const body = await source.openPart(0, source.size);
186
+ const response = await putStorage(
187
+ negotiation.url,
188
+ (negotiation.headers ?? {}) as Record<string, string>,
189
+ body,
190
+ options.signal,
191
+ source.stripContentLength,
192
+ source.size
193
+ );
92
194
  if (!response.ok) {
93
195
  throw new Error(`Storage upload failed (${response.status}); retry the upload`);
94
196
  }
95
- options.onProgress?.(file.size, file.size);
96
- return await this.completeUpload(negotiation.uploadId, options);
197
+ putReady = true;
198
+ options.onProgress?.(source.size, source.size);
97
199
  } catch (error) {
98
- // Caller cancellation and response-loss also reach here. Cleanup must
99
- // not inherit the failed/aborted signal, and must remain bounded.
100
- await this.abortUpload(negotiation.uploadId, {
101
- signal: uploadAbortCleanupSignal(),
102
- }).catch(() => undefined);
200
+ if (shouldAbortPresignedPutUploadSession({ putReady })) {
201
+ await this.abortUpload(negotiation.uploadId, {
202
+ signal: uploadAbortCleanupSignal(),
203
+ }).catch(() => undefined);
204
+ }
103
205
  throw error;
104
206
  }
207
+ try {
208
+ return await this.completeUpload(negotiation.uploadId, options);
209
+ } catch (error) {
210
+ throw annotatePresignedPutCompleteFailure(negotiation.uploadId, error);
211
+ }
105
212
  }
106
213
 
214
+ const blob = await source.asBlob();
107
215
  const form = new FormData();
108
- form.append('file', file, filename);
216
+ form.append('file', blob, source.filename);
109
217
  if (options.purpose) form.append('purpose', options.purpose);
110
218
  const uploaded = await this.dispatch(
111
219
  () =>
@@ -117,7 +225,7 @@ export class FilesResource {
117
225
  signal: options.signal,
118
226
  }) as Promise<OperationResult<AnyResponse>>
119
227
  );
120
- options.onProgress?.(file.size, file.size);
228
+ options.onProgress?.(source.size, source.size);
121
229
  return uploaded;
122
230
  }
123
231
 
@@ -159,6 +267,41 @@ export class FilesResource {
159
267
  );
160
268
  }
161
269
 
270
+ async getUpload(uploadId: string, options: SignalOptions = {}): Promise<AnyResponse> {
271
+ return this.dispatch(() =>
272
+ filesUploadsGet({
273
+ client: this.client,
274
+ path: { uploadId },
275
+ signal: options.signal,
276
+ })
277
+ );
278
+ }
279
+
280
+ async listUploadParts(uploadId: string, options: SignalOptions = {}): Promise<AnyResponse> {
281
+ return this.dispatch(() =>
282
+ filesUploadsPartsList({
283
+ client: this.client,
284
+ path: { uploadId },
285
+ signal: options.signal,
286
+ })
287
+ );
288
+ }
289
+
290
+ async presignUploadPart(
291
+ uploadId: string,
292
+ partNumber: number,
293
+ options: SignalOptions = {}
294
+ ): Promise<AnyResponse> {
295
+ return this.dispatch(() =>
296
+ filesUploadsPartsPresign({
297
+ client: this.client,
298
+ path: { uploadId },
299
+ body: { partNumber },
300
+ signal: options.signal,
301
+ })
302
+ );
303
+ }
304
+
162
305
  async get(fileId: string, options: SignalOptions = {}): Promise<AnyResponse> {
163
306
  return this.dispatch(() =>
164
307
  filesGet({ client: this.client, path: { id: fileId }, signal: options.signal })
@@ -186,3 +329,179 @@ export class FilesResource {
186
329
  );
187
330
  }
188
331
  }
332
+
333
+ type ResolvedUploadSource = {
334
+ filename: string;
335
+ contentType: string;
336
+ size: number;
337
+ replayable: boolean;
338
+ stripContentLength: boolean;
339
+ openPart: (start: number, length: number) => Promise<BodyInit>;
340
+ asBlob: () => Promise<Blob>;
341
+ };
342
+
343
+ async function resolveUploadableSource(
344
+ file: UploadableFile,
345
+ filenameOption?: string
346
+ ): Promise<ResolvedUploadSource> {
347
+ if (file instanceof Uint8Array) {
348
+ const filename = filenameOption || 'file';
349
+ return {
350
+ filename,
351
+ contentType: 'application/octet-stream',
352
+ size: file.byteLength,
353
+ replayable: true,
354
+ stripContentLength: false,
355
+ openPart: async (start, length) =>
356
+ byteViewToArrayBuffer(file.subarray(start, start + length)),
357
+ asBlob: async () => new Blob([file as BlobPart]),
358
+ };
359
+ }
360
+ if (file instanceof ArrayBuffer) {
361
+ const filename = filenameOption || 'file';
362
+ const bytes = new Uint8Array(file);
363
+ return {
364
+ filename,
365
+ contentType: 'application/octet-stream',
366
+ size: bytes.byteLength,
367
+ replayable: true,
368
+ stripContentLength: false,
369
+ openPart: async (start, length) =>
370
+ byteViewToArrayBuffer(bytes.subarray(start, start + length)),
371
+ asBlob: async () => new Blob([bytes as BlobPart]),
372
+ };
373
+ }
374
+
375
+ if (!isFileInput(file)) {
376
+ throw new Error('filename is required when uploading a Blob');
377
+ }
378
+
379
+ if (typeof Blob !== 'undefined' && file instanceof Blob) {
380
+ const filename =
381
+ filenameOption ??
382
+ (typeof File !== 'undefined' && file instanceof File ? file.name : undefined);
383
+ if (!filename) throw new Error('filename is required when uploading a Blob');
384
+ return {
385
+ filename,
386
+ contentType: file.type || 'application/octet-stream',
387
+ size: file.size,
388
+ replayable: true,
389
+ stripContentLength: true,
390
+ openPart: async (start, length) => file.slice(start, start + length),
391
+ asBlob: async () => file,
392
+ };
393
+ }
394
+
395
+ if (isPathFileInput(file) || hasNodePath(file)) {
396
+ const filePath = isPathFileInput(file) ? file.path : (file as { path: string }).path;
397
+ const meta = await statUploadSize(file);
398
+ const filename = filenameOption || meta.filename;
399
+ return {
400
+ filename,
401
+ contentType: meta.contentType,
402
+ size: meta.size,
403
+ replayable: true,
404
+ stripContentLength: false,
405
+ openPart: async (start, length) => {
406
+ if (length === 0) return byteViewToArrayBuffer(new Uint8Array());
407
+ const { createReadStream } = await import('node:fs');
408
+ return toPutBody(
409
+ createReadStream(filePath, {
410
+ start,
411
+ end: start + length - 1,
412
+ })
413
+ );
414
+ },
415
+ asBlob: async () => (await resolveFileBlob(file)).blob,
416
+ };
417
+ }
418
+
419
+ if (isStreamFactoryFileInput(file)) {
420
+ return {
421
+ filename: filenameOption || file.filename,
422
+ contentType: file.mimeType || 'application/octet-stream',
423
+ size: file.size,
424
+ replayable: true,
425
+ stripContentLength: false,
426
+ openPart: async (start, length) => {
427
+ const part = file.open(start, length);
428
+ if (part instanceof Uint8Array) return byteViewToArrayBuffer(part);
429
+ if (isNodeReadableStream(part)) return toPutBody(part);
430
+ return part;
431
+ },
432
+ asBlob: async () => (await resolveFileBlob(file)).blob,
433
+ };
434
+ }
435
+
436
+ if (isFileDescriptor(file)) {
437
+ const filename = filenameOption || file.filename;
438
+ const type = file.mimeType || 'application/octet-stream';
439
+ if (typeof Blob !== 'undefined' && file.content instanceof Blob) {
440
+ const blob = file.content;
441
+ return {
442
+ filename,
443
+ contentType: type,
444
+ size: blob.size,
445
+ replayable: true,
446
+ stripContentLength: true,
447
+ openPart: async (start, length) => blob.slice(start, start + length),
448
+ asBlob: async () => blob,
449
+ };
450
+ }
451
+ const bytes =
452
+ file.content instanceof ArrayBuffer
453
+ ? new Uint8Array(file.content)
454
+ : new Uint8Array(
455
+ (file.content as ArrayBufferView).buffer,
456
+ (file.content as ArrayBufferView).byteOffset,
457
+ (file.content as ArrayBufferView).byteLength
458
+ );
459
+ return {
460
+ filename,
461
+ contentType: type,
462
+ size: bytes.byteLength,
463
+ replayable: true,
464
+ stripContentLength: false,
465
+ openPart: async (start, length) =>
466
+ byteViewToArrayBuffer(bytes.subarray(start, start + length)),
467
+ asBlob: async () => new Blob([bytes as BlobPart], { type }),
468
+ };
469
+ }
470
+
471
+ throw new Error(
472
+ 'Cannot upload a one-shot stream. Pass a file path, Blob, or a reusable stream factory.'
473
+ );
474
+ }
475
+
476
+ function hasNodePath(file: FileInput): file is FileInput & { path: string } {
477
+ return typeof (file as { path?: unknown }).path === 'string';
478
+ }
479
+
480
+ async function putStorage(
481
+ url: string,
482
+ headers: Record<string, string>,
483
+ body: BodyInit,
484
+ signal: AbortSignal | undefined,
485
+ stripContentLength: boolean,
486
+ expectedByteLength?: number
487
+ ): Promise<Response> {
488
+ const stream = isNodeReadableStream(body);
489
+ const init: StreamableRequestInit = {
490
+ method: 'PUT',
491
+ headers: filterStorageHeaders(headers, stripContentLength && !stream),
492
+ body,
493
+ signal,
494
+ ...(stream ? { duplex: 'half' as const } : {}),
495
+ };
496
+ let response: Response;
497
+ try {
498
+ response = await fetch(url, init);
499
+ } catch (error) {
500
+ if (stream) destroyUnreadNodeReadable(body as NodeJS.ReadableStream);
501
+ throw error;
502
+ }
503
+ if (stream) {
504
+ await ensureNodeReadableFullySent(body as NodeJS.ReadableStream, expectedByteLength);
505
+ }
506
+ return response;
507
+ }
package/src/telemetry.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  export const SDK_LANGUAGE = 'typescript';
20
20
  // Rewritten at publish time by scripts/release/platform/render-sdk-versions.sh.
21
21
  // Keep this string literal exactly stable — sed matches on it.
22
- export const SDK_VERSION = '0.16.3';
22
+ export const SDK_VERSION = '0.16.4';
23
23
 
24
24
  function detectRuntime(): string {
25
25
  const g = globalThis as unknown as {