@eigenpal/sdk 0.16.3 → 0.16.5
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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/client.ts +47 -9
- package/src/generated/index.ts +2 -2
- package/src/generated/sdk.gen.ts +41 -4
- package/src/generated/types.gen.ts +210 -1
- package/src/index.ts +7 -1
- package/src/lib/fetch-body.ts +92 -0
- package/src/lib/files.ts +154 -8
- package/src/lib/upload-presigned-multipart.ts +285 -0
- package/src/resources/files.ts +345 -26
- package/src/telemetry.ts +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Request init for Node/Bun fetch when streaming a Node readable body. */
|
|
2
|
+
export type StreamableRequestInit = RequestInit & { duplex?: 'half' };
|
|
3
|
+
|
|
4
|
+
/** True when `body` is a Node.js readable stream (fs.createReadStream, etc.). */
|
|
5
|
+
export function isNodeReadableStream(body: unknown): body is NodeJS.ReadableStream {
|
|
6
|
+
return typeof (body as { pipe?: unknown }).pipe === 'function';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Copy a byte view into a standalone ArrayBuffer accepted by DOM `BodyInit`.
|
|
11
|
+
* Avoids TS generic mismatches on `Uint8Array<ArrayBufferLike>`.
|
|
12
|
+
*/
|
|
13
|
+
export function byteViewToArrayBuffer(view: Uint8Array): ArrayBuffer {
|
|
14
|
+
if (
|
|
15
|
+
view.buffer instanceof ArrayBuffer &&
|
|
16
|
+
view.byteOffset === 0 &&
|
|
17
|
+
view.byteLength === view.buffer.byteLength
|
|
18
|
+
) {
|
|
19
|
+
return view.buffer;
|
|
20
|
+
}
|
|
21
|
+
const copy = new Uint8Array(view.byteLength);
|
|
22
|
+
copy.set(view);
|
|
23
|
+
return copy.buffer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Convert bytes or a Node readable into a fetch-compatible PUT body. */
|
|
27
|
+
export function toPutBody(body: Uint8Array | NodeJS.ReadableStream): BodyInit {
|
|
28
|
+
if (isNodeReadableStream(body)) {
|
|
29
|
+
// Node/Bun fetch accepts Node readables with `duplex: 'half'`.
|
|
30
|
+
return body as unknown as BodyInit;
|
|
31
|
+
}
|
|
32
|
+
return byteViewToArrayBuffer(body);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function putRequestInit(
|
|
36
|
+
body: Uint8Array | NodeJS.ReadableStream,
|
|
37
|
+
headers: HeadersInit,
|
|
38
|
+
signal?: AbortSignal
|
|
39
|
+
): StreamableRequestInit {
|
|
40
|
+
const stream = isNodeReadableStream(body);
|
|
41
|
+
return {
|
|
42
|
+
method: 'PUT',
|
|
43
|
+
headers,
|
|
44
|
+
body: toPutBody(body),
|
|
45
|
+
signal,
|
|
46
|
+
...(stream ? { duplex: 'half' as const } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type NodeReadableState = NodeJS.ReadableStream & {
|
|
51
|
+
readableEnded?: boolean;
|
|
52
|
+
destroyed?: boolean;
|
|
53
|
+
destroy?: (error?: Error) => void;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Drop a Node readable that never started sending (fetch failed before upload). */
|
|
57
|
+
export function destroyUnreadNodeReadable(stream: NodeJS.ReadableStream): void {
|
|
58
|
+
const readable = stream as NodeReadableState;
|
|
59
|
+
if (!readable.readableEnded && !readable.destroyed && typeof readable.destroy === 'function') {
|
|
60
|
+
readable.destroy();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Block until every byte of a Node readable PUT body has been consumed.
|
|
66
|
+
* Real fetch/undici should drain the stream before resolving; mocks that
|
|
67
|
+
* return early leave bytes unread and must be drained here so callers do
|
|
68
|
+
* not destroy the stream while a retry or background upload is still running.
|
|
69
|
+
*/
|
|
70
|
+
export async function ensureNodeReadableFullySent(
|
|
71
|
+
stream: NodeJS.ReadableStream,
|
|
72
|
+
expectedByteLength?: number
|
|
73
|
+
): Promise<number> {
|
|
74
|
+
const readable = stream as NodeReadableState;
|
|
75
|
+
if (readable.readableEnded) {
|
|
76
|
+
return expectedByteLength ?? 0;
|
|
77
|
+
}
|
|
78
|
+
if (readable.destroyed) {
|
|
79
|
+
throw new Error('Upload stream was destroyed before the request body finished sending');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let bytes = 0;
|
|
83
|
+
for await (const chunk of stream as AsyncIterable<Buffer | Uint8Array | string>) {
|
|
84
|
+
bytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length;
|
|
85
|
+
}
|
|
86
|
+
if (expectedByteLength !== undefined && bytes !== expectedByteLength) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Upload sent ${bytes} byte(s) but ${expectedByteLength} were expected for this part`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return bytes;
|
|
92
|
+
}
|
package/src/lib/files.ts
CHANGED
|
@@ -51,9 +51,33 @@ export interface FileDescriptor {
|
|
|
51
51
|
mimeType?: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Disk path that can be stat'd and re-opened per part for resumable multipart.
|
|
56
|
+
*/
|
|
57
|
+
export interface PathFileInput {
|
|
58
|
+
path: string;
|
|
59
|
+
filename?: string;
|
|
60
|
+
mimeType?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Replayable byte-range factory for Node streams that are not a disk path.
|
|
65
|
+
* `open` must return a fresh body for `[start, start+length)` on every call.
|
|
66
|
+
*/
|
|
67
|
+
export interface StreamFactoryFileInput {
|
|
68
|
+
size: number;
|
|
69
|
+
filename: string;
|
|
70
|
+
mimeType?: string;
|
|
71
|
+
open: (
|
|
72
|
+
start: number,
|
|
73
|
+
length: number
|
|
74
|
+
) => Blob | Uint8Array | ReadableStream | NodeJS.ReadableStream;
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
/**
|
|
55
78
|
* A Node readable stream — typically `fs.createReadStream('contract.pdf')`.
|
|
56
|
-
*
|
|
79
|
+
* Streams with a `path` are uploaded from disk without draining. A one-shot
|
|
80
|
+
* stream without a path cannot be multipart-resumed.
|
|
57
81
|
*/
|
|
58
82
|
export interface NodeReadableStream extends AsyncIterable<unknown> {
|
|
59
83
|
/** Source path; used to infer the upload filename. */
|
|
@@ -61,7 +85,12 @@ export interface NodeReadableStream extends AsyncIterable<unknown> {
|
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
/** Any value the SDK accepts as a "file" workflow input. */
|
|
64
|
-
export type FileInput =
|
|
88
|
+
export type FileInput =
|
|
89
|
+
| Blob
|
|
90
|
+
| FileDescriptor
|
|
91
|
+
| PathFileInput
|
|
92
|
+
| StreamFactoryFileInput
|
|
93
|
+
| NodeReadableStream;
|
|
65
94
|
|
|
66
95
|
/**
|
|
67
96
|
* Attach a filename (and optional MIME type) to raw bytes. The escape hatch
|
|
@@ -83,7 +112,7 @@ export function toFile(
|
|
|
83
112
|
}
|
|
84
113
|
|
|
85
114
|
/** Detect a Node readable stream (`fs.createReadStream`, etc.). */
|
|
86
|
-
function isReadStream(value: unknown): value is NodeReadableStream {
|
|
115
|
+
export function isReadStream(value: unknown): value is NodeReadableStream {
|
|
87
116
|
if (value === null || typeof value !== 'object') return false;
|
|
88
117
|
const v = value as Record<PropertyKey, unknown>;
|
|
89
118
|
return (
|
|
@@ -93,7 +122,7 @@ function isReadStream(value: unknown): value is NodeReadableStream {
|
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
/** Detect an explicit `{ content, filename }` descriptor. */
|
|
96
|
-
function isFileDescriptor(value: unknown): value is FileDescriptor {
|
|
125
|
+
export function isFileDescriptor(value: unknown): value is FileDescriptor {
|
|
97
126
|
if (value === null || typeof value !== 'object') return false;
|
|
98
127
|
const v = value as { content?: unknown; filename?: unknown };
|
|
99
128
|
return (
|
|
@@ -105,9 +134,27 @@ function isFileDescriptor(value: unknown): value is FileDescriptor {
|
|
|
105
134
|
);
|
|
106
135
|
}
|
|
107
136
|
|
|
137
|
+
export function isPathFileInput(value: unknown): value is PathFileInput {
|
|
138
|
+
if (value === null || typeof value !== 'object' || isReadStream(value)) return false;
|
|
139
|
+
const v = value as { path?: unknown; open?: unknown };
|
|
140
|
+
return typeof v.path === 'string' && typeof v.open !== 'function';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function isStreamFactoryFileInput(value: unknown): value is StreamFactoryFileInput {
|
|
144
|
+
if (value === null || typeof value !== 'object') return false;
|
|
145
|
+
const v = value as { open?: unknown; size?: unknown; filename?: unknown };
|
|
146
|
+
return (
|
|
147
|
+
typeof v.open === 'function' &&
|
|
148
|
+
typeof v.filename === 'string' &&
|
|
149
|
+
typeof v.size === 'number' &&
|
|
150
|
+
Number.isFinite(v.size)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
108
154
|
export function isFileInput(value: unknown): value is FileInput {
|
|
109
155
|
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
|
|
110
156
|
if (isReadStream(value)) return true;
|
|
157
|
+
if (isPathFileInput(value) || isStreamFactoryFileInput(value)) return true;
|
|
111
158
|
return isFileDescriptor(value);
|
|
112
159
|
}
|
|
113
160
|
|
|
@@ -128,9 +175,9 @@ function basename(path: string): string {
|
|
|
128
175
|
/**
|
|
129
176
|
* Resolve a `FileInput` to a `{ blob, filename }` pair for `FormData.append`.
|
|
130
177
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
178
|
+
* One-shot streams without a path are drained here so the body can be replayed
|
|
179
|
+
* on HTTP retries. Disk paths and stream factories stay unbuffered until the
|
|
180
|
+
* caller asks for bytes (small multipart leftovers).
|
|
134
181
|
*/
|
|
135
182
|
export async function resolveFileBlob(file: FileInput): Promise<{ blob: Blob; filename: string }> {
|
|
136
183
|
// `File` extends `Blob`, so this branch covers both.
|
|
@@ -138,7 +185,38 @@ export async function resolveFileBlob(file: FileInput): Promise<{ blob: Blob; fi
|
|
|
138
185
|
return { blob: file, filename: (file as File).name || 'file' };
|
|
139
186
|
}
|
|
140
187
|
|
|
141
|
-
|
|
188
|
+
if (isPathFileInput(file) || (isReadStream(file) && typeof file.path === 'string')) {
|
|
189
|
+
const filePath = isPathFileInput(file) ? file.path : file.path!;
|
|
190
|
+
const filename = (isPathFileInput(file) ? file.filename : undefined) ?? basename(filePath);
|
|
191
|
+
if (await pathIsFile(filePath)) {
|
|
192
|
+
const { readFile } = await import('node:fs/promises');
|
|
193
|
+
const bytes = await readFile(filePath);
|
|
194
|
+
const type = (isPathFileInput(file) ? file.mimeType : undefined) ?? guessMimeType(filename);
|
|
195
|
+
return { blob: new Blob([bytes as BlobPart], { type }), filename };
|
|
196
|
+
}
|
|
197
|
+
if (isPathFileInput(file)) {
|
|
198
|
+
throw new Error(`Upload path is not a file: ${filePath}`);
|
|
199
|
+
}
|
|
200
|
+
// Named stream whose path is not readable — drain the in-memory stream.
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (isStreamFactoryFileInput(file)) {
|
|
204
|
+
const body = file.open(0, file.size);
|
|
205
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
206
|
+
return { blob: body, filename: file.filename };
|
|
207
|
+
}
|
|
208
|
+
if (body instanceof Uint8Array) {
|
|
209
|
+
return {
|
|
210
|
+
blob: new Blob([body as BlobPart], { type: file.mimeType ?? guessMimeType(file.filename) }),
|
|
211
|
+
filename: file.filename,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
throw new Error(
|
|
215
|
+
'Stream factory returned a non-Blob body; pass a path or Blob for small multipart leftovers'
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Node readable stream without a path — drain it into a Blob now.
|
|
142
220
|
if (isReadStream(file)) {
|
|
143
221
|
const filename = typeof file.path === 'string' ? basename(file.path) : 'file';
|
|
144
222
|
const parts: BlobPart[] = [];
|
|
@@ -161,6 +239,74 @@ export async function resolveFileBlob(file: FileInput): Promise<{ blob: Blob; fi
|
|
|
161
239
|
return { blob, filename: desc.filename };
|
|
162
240
|
}
|
|
163
241
|
|
|
242
|
+
export async function statUploadSize(
|
|
243
|
+
file: FileInput
|
|
244
|
+
): Promise<{ size: number; filename: string; contentType: string }> {
|
|
245
|
+
if (typeof Blob !== 'undefined' && file instanceof Blob) {
|
|
246
|
+
return {
|
|
247
|
+
size: file.size,
|
|
248
|
+
filename: (file as File).name || 'file',
|
|
249
|
+
contentType: file.type || DEFAULT_MIME,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (isPathFileInput(file) || (isReadStream(file) && typeof file.path === 'string')) {
|
|
253
|
+
const filePath = isPathFileInput(file) ? file.path : file.path!;
|
|
254
|
+
const filename = (isPathFileInput(file) ? file.filename : undefined) ?? basename(filePath);
|
|
255
|
+
if (await pathIsFile(filePath)) {
|
|
256
|
+
const { stat } = await import('node:fs/promises');
|
|
257
|
+
const info = await stat(filePath);
|
|
258
|
+
return {
|
|
259
|
+
size: info.size,
|
|
260
|
+
filename,
|
|
261
|
+
contentType: (isPathFileInput(file) ? file.mimeType : undefined) ?? guessMimeType(filename),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (isPathFileInput(file)) {
|
|
265
|
+
throw new Error(`Upload path is not a file: ${filePath}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (isStreamFactoryFileInput(file)) {
|
|
269
|
+
return {
|
|
270
|
+
size: file.size,
|
|
271
|
+
filename: file.filename,
|
|
272
|
+
contentType: file.mimeType ?? guessMimeType(file.filename),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (isFileDescriptor(file)) {
|
|
276
|
+
const size =
|
|
277
|
+
file.content instanceof Blob
|
|
278
|
+
? file.content.size
|
|
279
|
+
: file.content instanceof ArrayBuffer
|
|
280
|
+
? file.content.byteLength
|
|
281
|
+
: file.content.byteLength;
|
|
282
|
+
return {
|
|
283
|
+
size,
|
|
284
|
+
filename: file.filename,
|
|
285
|
+
contentType: file.mimeType ?? guessMimeType(file.filename),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
throw new Error(
|
|
289
|
+
'Cannot determine size of a non-replayable stream. Pass a file path, Blob, or a stream factory with size.'
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function isReplayableUploadSource(file: FileInput | Uint8Array | ArrayBuffer): boolean {
|
|
294
|
+
if (file instanceof Uint8Array || file instanceof ArrayBuffer) return true;
|
|
295
|
+
if (typeof Blob !== 'undefined' && file instanceof Blob) return true;
|
|
296
|
+
if (isPathFileInput(file) || isStreamFactoryFileInput(file) || isFileDescriptor(file))
|
|
297
|
+
return true;
|
|
298
|
+
return isReadStream(file) && typeof file.path === 'string';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function pathIsFile(filePath: string): Promise<boolean> {
|
|
302
|
+
try {
|
|
303
|
+
const { stat } = await import('node:fs/promises');
|
|
304
|
+
return (await stat(filePath)).isFile();
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
164
310
|
export interface MultipartParts {
|
|
165
311
|
/** FormData ready to send as the request body. */
|
|
166
312
|
formData: FormData;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client protocol for `presigned-multipart` file uploads.
|
|
3
|
+
* Part math matches the server session; ETags are never trusted locally.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const MULTIPART_UPLOAD_CONCURRENCY = 4;
|
|
7
|
+
export const MULTIPART_PART_MAX_RETRIES = 4;
|
|
8
|
+
export const MULTIPART_PART_RETRY_BASE_MS = 250;
|
|
9
|
+
|
|
10
|
+
export type ListedUploadPart = {
|
|
11
|
+
partNumber: number;
|
|
12
|
+
size?: number;
|
|
13
|
+
etag: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type PresignedUploadPart = {
|
|
17
|
+
url: string;
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
partSizeBytes: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function expectedPartByteLength(
|
|
23
|
+
totalSize: number,
|
|
24
|
+
partSizeBytes: number,
|
|
25
|
+
partNumber: number,
|
|
26
|
+
partCount: number
|
|
27
|
+
): number {
|
|
28
|
+
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > partCount) {
|
|
29
|
+
throw new Error(`partNumber must be an integer in [1, ${partCount}]`);
|
|
30
|
+
}
|
|
31
|
+
if (totalSize === 0) return 0;
|
|
32
|
+
if (partNumber < partCount) return partSizeBytes;
|
|
33
|
+
const remainder = totalSize % partSizeBytes;
|
|
34
|
+
return remainder === 0 ? partSizeBytes : remainder;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function partByteOffset(partSizeBytes: number, partNumber: number): number {
|
|
38
|
+
return (partNumber - 1) * partSizeBytes;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isTransientHttpStatus(status: number): boolean {
|
|
42
|
+
return status === 408 || status === 429 || (status >= 500 && status <= 599);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function filterStorageHeaders(
|
|
46
|
+
headers: Record<string, string> | undefined,
|
|
47
|
+
stripContentLength: boolean
|
|
48
|
+
): Record<string, string> {
|
|
49
|
+
return Object.fromEntries(
|
|
50
|
+
Object.entries(headers ?? {}).filter(([name]) =>
|
|
51
|
+
stripContentLength ? name.toLowerCase() !== 'content-length' : true
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function partIsAuthoritativelyComplete(
|
|
57
|
+
listed: ReadonlyArray<ListedUploadPart>,
|
|
58
|
+
partNumber: number,
|
|
59
|
+
expectedSize: number
|
|
60
|
+
): boolean {
|
|
61
|
+
const found = listed.find((part) => part.partNumber === partNumber);
|
|
62
|
+
if (!found) return false;
|
|
63
|
+
// Server complete rejects missing sizes. Treat them as incomplete so resume
|
|
64
|
+
// re-uploads instead of skipping a part the API cannot finalize.
|
|
65
|
+
return found.size === expectedSize;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class PartUploadHttpError extends Error {
|
|
69
|
+
constructor(readonly status: number) {
|
|
70
|
+
super(`Storage part upload failed (${status})`);
|
|
71
|
+
this.name = 'PartUploadHttpError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isAbortError(error: unknown): boolean {
|
|
76
|
+
return (
|
|
77
|
+
(error instanceof Error && error.name === 'AbortError') ||
|
|
78
|
+
(typeof DOMException !== 'undefined' &&
|
|
79
|
+
error instanceof DOMException &&
|
|
80
|
+
error.name === 'AbortError')
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Abort leftover MPU state only while parts are not yet authoritative.
|
|
86
|
+
*
|
|
87
|
+
* After ListParts shows every expected part, POST complete is idempotent.
|
|
88
|
+
* 409/429/timeout/lost responses must not abort — that would delete GiB of
|
|
89
|
+
* uploaded parts and can race a complete that already succeeded server-side.
|
|
90
|
+
* Caller cancellation and unrecoverable part PUT/API failures still abort
|
|
91
|
+
* because `partsReady` is false in those cases.
|
|
92
|
+
*/
|
|
93
|
+
export function shouldAbortMultipartUploadSession(options: { partsReady: boolean }): boolean {
|
|
94
|
+
return !options.partsReady;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Abort leftover presigned-PUT state only while storage PUT is not yet authoritative.
|
|
99
|
+
*
|
|
100
|
+
* After a successful storage PUT, POST complete is idempotent. 409/429/timeout/lost
|
|
101
|
+
* responses must not abort — that would delete a recoverable up-to-4-GiB object and
|
|
102
|
+
* can race a complete that already succeeded server-side. Caller cancellation and
|
|
103
|
+
* unrecoverable PUT failures still abort because `putReady` is false in those cases.
|
|
104
|
+
*/
|
|
105
|
+
export function shouldAbortPresignedPutUploadSession(options: { putReady: boolean }): boolean {
|
|
106
|
+
return !options.putReady;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function multipartCompleteRetryHint(uploadId: string): string {
|
|
110
|
+
return `Uploaded parts remain stored for ${uploadId}; retry complete and do not abort the session.`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function presignedPutCompleteRetryHint(uploadId: string): string {
|
|
114
|
+
return `Uploaded object remains stored for ${uploadId}; retry complete and do not abort the session.`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function annotateMultipartCompleteFailure(uploadId: string, error: unknown): unknown {
|
|
118
|
+
return annotateUploadCompleteFailure(multipartCompleteRetryHint(uploadId), error);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function annotatePresignedPutCompleteFailure(uploadId: string, error: unknown): unknown {
|
|
122
|
+
return annotateUploadCompleteFailure(presignedPutCompleteRetryHint(uploadId), error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function annotateUploadCompleteFailure(hint: string, error: unknown): unknown {
|
|
126
|
+
if (error instanceof Error) {
|
|
127
|
+
if (!error.message.includes('retry complete')) {
|
|
128
|
+
error.message = `${error.message} ${hint}`;
|
|
129
|
+
}
|
|
130
|
+
return error;
|
|
131
|
+
}
|
|
132
|
+
return new Error(`${String(error)} ${hint}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isRetryablePartError(error: unknown, signal?: AbortSignal): boolean {
|
|
136
|
+
if (signal?.aborted) return false;
|
|
137
|
+
if (isAbortError(error)) return false;
|
|
138
|
+
if (error instanceof PartUploadHttpError) return isTransientHttpStatus(error.status);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
143
|
+
if (signal?.aborted) throw abortError(signal);
|
|
144
|
+
await new Promise<void>((resolve, reject) => {
|
|
145
|
+
const timer = setTimeout(resolve, ms);
|
|
146
|
+
const onAbort = () => {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
reject(abortError(signal));
|
|
149
|
+
};
|
|
150
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function abortError(signal?: AbortSignal): Error {
|
|
155
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
156
|
+
return typeof DOMException !== 'undefined'
|
|
157
|
+
? new DOMException('Upload aborted', 'AbortError')
|
|
158
|
+
: Object.assign(new Error('Upload aborted'), { name: 'AbortError' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function mapPool<T>(
|
|
162
|
+
items: readonly T[],
|
|
163
|
+
concurrency: number,
|
|
164
|
+
signal: AbortSignal | undefined,
|
|
165
|
+
worker: (item: T) => Promise<void>
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
if (items.length === 0) return;
|
|
168
|
+
let cursor = 0;
|
|
169
|
+
let firstError: unknown;
|
|
170
|
+
await Promise.all(
|
|
171
|
+
Array.from({ length: Math.min(Math.max(1, concurrency), items.length) }, async () => {
|
|
172
|
+
while (firstError == null) {
|
|
173
|
+
if (signal?.aborted) throw abortError(signal);
|
|
174
|
+
const index = cursor++;
|
|
175
|
+
if (index >= items.length) return;
|
|
176
|
+
try {
|
|
177
|
+
await worker(items[index]!);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
firstError ??= error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
if (firstError) throw firstError;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function uploadPresignedMultipartParts(options: {
|
|
188
|
+
partCount: number;
|
|
189
|
+
partSizeBytes: number;
|
|
190
|
+
totalSize: number;
|
|
191
|
+
signal?: AbortSignal;
|
|
192
|
+
concurrency?: number;
|
|
193
|
+
onProgress?: (uploadedBytes: number, totalBytes: number) => void;
|
|
194
|
+
listParts: () => Promise<ReadonlyArray<ListedUploadPart>>;
|
|
195
|
+
presignPart: (partNumber: number) => Promise<PresignedUploadPart>;
|
|
196
|
+
putPart: (args: {
|
|
197
|
+
url: string;
|
|
198
|
+
headers: Record<string, string>;
|
|
199
|
+
partNumber: number;
|
|
200
|
+
start: number;
|
|
201
|
+
length: number;
|
|
202
|
+
}) => Promise<void>;
|
|
203
|
+
}): Promise<void> {
|
|
204
|
+
const concurrency = options.concurrency ?? MULTIPART_UPLOAD_CONCURRENCY;
|
|
205
|
+
let uploadedBytes = 0;
|
|
206
|
+
const credited = new Set<number>();
|
|
207
|
+
|
|
208
|
+
const credit = (partNumber: number, length: number) => {
|
|
209
|
+
if (credited.has(partNumber)) return;
|
|
210
|
+
credited.add(partNumber);
|
|
211
|
+
uploadedBytes += length;
|
|
212
|
+
options.onProgress?.(uploadedBytes, options.totalSize);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const pendingPartNumbers = async (): Promise<number[]> => {
|
|
216
|
+
const listed = await options.listParts();
|
|
217
|
+
const pending: number[] = [];
|
|
218
|
+
for (let partNumber = 1; partNumber <= options.partCount; partNumber++) {
|
|
219
|
+
const length = expectedPartByteLength(
|
|
220
|
+
options.totalSize,
|
|
221
|
+
options.partSizeBytes,
|
|
222
|
+
partNumber,
|
|
223
|
+
options.partCount
|
|
224
|
+
);
|
|
225
|
+
if (partIsAuthoritativelyComplete(listed, partNumber, length)) {
|
|
226
|
+
credit(partNumber, length);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
pending.push(partNumber);
|
|
230
|
+
}
|
|
231
|
+
return pending;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const uploadOne = async (partNumber: number): Promise<void> => {
|
|
235
|
+
const length = expectedPartByteLength(
|
|
236
|
+
options.totalSize,
|
|
237
|
+
options.partSizeBytes,
|
|
238
|
+
partNumber,
|
|
239
|
+
options.partCount
|
|
240
|
+
);
|
|
241
|
+
const start = partByteOffset(options.partSizeBytes, partNumber);
|
|
242
|
+
let lastError: unknown;
|
|
243
|
+
for (let attempt = 0; attempt <= MULTIPART_PART_MAX_RETRIES; attempt++) {
|
|
244
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
245
|
+
try {
|
|
246
|
+
const signed = await options.presignPart(partNumber);
|
|
247
|
+
const partLength = Number.isInteger(signed.partSizeBytes) ? signed.partSizeBytes : length;
|
|
248
|
+
await options.putPart({
|
|
249
|
+
url: signed.url,
|
|
250
|
+
headers: signed.headers ?? {},
|
|
251
|
+
partNumber,
|
|
252
|
+
start,
|
|
253
|
+
length: partLength,
|
|
254
|
+
});
|
|
255
|
+
credit(partNumber, length);
|
|
256
|
+
return;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
lastError = error;
|
|
259
|
+
if (
|
|
260
|
+
!isRetryablePartError(error, options.signal) ||
|
|
261
|
+
attempt === MULTIPART_PART_MAX_RETRIES
|
|
262
|
+
) {
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
await sleep(MULTIPART_PART_RETRY_BASE_MS * 2 ** attempt, options.signal);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
throw lastError;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
let pending = await pendingPartNumbers();
|
|
272
|
+
if (pending.length > 0) {
|
|
273
|
+
await mapPool(pending, concurrency, options.signal, uploadOne);
|
|
274
|
+
}
|
|
275
|
+
pending = await pendingPartNumbers();
|
|
276
|
+
if (pending.length > 0) {
|
|
277
|
+
await mapPool(pending, concurrency, options.signal, uploadOne);
|
|
278
|
+
pending = await pendingPartNumbers();
|
|
279
|
+
}
|
|
280
|
+
if (pending.length > 0) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Upload incomplete: ${pending.length} part(s) missing from storage before complete`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|