@pipelex/sdk 0.48.1 → 0.50.0
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/README.md +29 -11
- package/dist/cli/script.d.ts +1 -1
- package/dist/client.d.ts +341 -18
- package/dist/client.js +615 -28
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +100 -12
- package/dist/errors.js +49 -7
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/prepare-inputs.d.ts +12 -1
- package/dist/prepare-inputs.js +26 -3
- package/dist/prepare-inputs.js.map +1 -1
- package/dist/product-models.d.ts +677 -22
- package/dist/product-models.js +2 -2
- package/dist/upload-grant.d.ts +134 -40
- package/dist/upload-grant.js +328 -136
- package/dist/upload-grant.js.map +1 -1
- package/dist/upload.d.ts +39 -11
- package/dist/upload.js +115 -8
- package/dist/upload.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/architecture.md +7 -5
- package/docs/errors.md +12 -3
- package/docs/files.md +258 -0
- package/docs/input-preparation.md +57 -23
- package/docs/method-examples.md +36 -3
- package/docs/organization-branding.md +102 -0
- package/package.json +1 -1
package/dist/upload-grant.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `uploadWithGrant` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* `describeUpload` and `uploadWithGrant` — the two halves of an upload grant
|
|
3
|
+
* (`PipelexApiClient.requestUploadGrant`, `POST /v1/upload/grant`) that the side
|
|
4
|
+
* holding the bytes runs, typically a browser page. That side describes the file,
|
|
5
|
+
* its SHA-256 included, with `describeUpload`; the side that holds the credential
|
|
6
|
+
* requests the grant for that description; and the bytes go to storage with
|
|
7
|
+
* `uploadWithGrant`, unless the grant says the organization already holds them. See
|
|
6
8
|
* `docs/input-preparation.md`.
|
|
7
9
|
*
|
|
8
10
|
* **This module is the browser-safe entry, `@pipelex/sdk/upload`.** Its runtime
|
|
9
11
|
* import graph must never reach a Node builtin, `undici` or the client: a page
|
|
10
12
|
* bundles it as is, with nothing marked external. It imports the error classes,
|
|
11
|
-
* the timer bound in `timers.ts` and the wire types only,
|
|
12
|
-
* `tests/upload-grant.test.ts` bundles it for the browser with esbuild to hold
|
|
13
|
-
* to that.
|
|
13
|
+
* the timer bound in `timers.ts` and the wire types only, hashes with Web Crypto,
|
|
14
|
+
* and `tests/upload-grant.test.ts` bundles it for the browser with esbuild to hold
|
|
15
|
+
* it to that.
|
|
14
16
|
*/
|
|
15
17
|
import { InputPreparationError, RejectedAssetError, UploadTransportError } from "./errors.js";
|
|
16
18
|
import { MAX_TIMER_DELAY_MS, isTimerDelay } from "./timers.js";
|
|
@@ -31,51 +33,157 @@ const DEFAULT_TIMEOUT_BYTES_PER_SECOND = 128 * 1024;
|
|
|
31
33
|
*/
|
|
32
34
|
const ERROR_BODY_MAX_BYTES = 16 * 1024;
|
|
33
35
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
36
|
+
* The pauses before each new `PUT` after storage's `409 ConditionalRequestConflict`,
|
|
37
|
+
* which says another write of the key was in flight and documents a retry: four more
|
|
38
|
+
* attempts, within the call's one time limit, the platform's own create-only write
|
|
39
|
+
* pausing about as long in all.
|
|
40
|
+
*/
|
|
41
|
+
const CONFLICT_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000];
|
|
42
|
+
/** The signed header that holds the body to the SHA-256 the grant was requested with. */
|
|
43
|
+
const CHECKSUM_HEADER = "x-amz-checksum-sha256";
|
|
44
|
+
/**
|
|
45
|
+
* The largest file `describeUpload` hashes by default: 50 MiB, the platform's own default
|
|
46
|
+
* limit on one upload, which a grant reports as `max_bytes`.
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_DESCRIBE_MAX_BYTES = 52_428_800;
|
|
49
|
+
/**
|
|
50
|
+
* Describe a file for `requestUploadGrant`: its name, its type, its size and the
|
|
51
|
+
* SHA-256 of its bytes, computed with Web Crypto. A grant asked for with the hash
|
|
52
|
+
* names the object after the bytes, so a file the organization already holds is
|
|
53
|
+
* answered `stored: true` and never sent again, and storage refuses a body whose
|
|
54
|
+
* bytes differ from the ones described.
|
|
55
|
+
*
|
|
56
|
+
* The side holding the bytes runs this, so a page describes the file and hands the
|
|
57
|
+
* description to the server that holds the key, which passes it to
|
|
58
|
+
* `requestUploadGrant` unchanged; the bytes never cross that server. A nameless
|
|
59
|
+
* `Blob` takes its name from `options.filename`, which also replaces a `File`'s own.
|
|
60
|
+
*
|
|
61
|
+
* The whole file is read into memory once to hash it, so only a file no larger than
|
|
62
|
+
* `options.maxBytes` is, 50 MiB by default: a larger one, such as a video of several
|
|
63
|
+
* gigabytes picked by mistake, would freeze or crash a browser tab before the grant
|
|
64
|
+
* route could refuse its size. It is described without `sha256` and never read. Where
|
|
65
|
+
* the deployment's limit is no larger than `maxBytes`, as with the default on a
|
|
66
|
+
* deployment at the platform's default limit, the grant route's `413` refuses it with its
|
|
67
|
+
* own message. On a deployment whose grants report a larger `max_bytes`, a file between
|
|
68
|
+
* the two is granted without the hash and uploaded under a fresh name, with no
|
|
69
|
+
* deduplication and no checksum for storage to hold the body to: the absent `sha256` on
|
|
70
|
+
* the description is how the caller sees it, and passing that `max_bytes` as `maxBytes`
|
|
71
|
+
* hashes up to it. Where the runtime offers no Web
|
|
72
|
+
* Crypto digest, the file is described without `sha256` too, and the upload goes on
|
|
73
|
+
* without the hash.
|
|
74
|
+
*
|
|
75
|
+
* A file that cannot be read, such as one changed on disk since it was picked, throws an
|
|
76
|
+
* `InputPreparationError` whose `cause` is the runtime's error; a file with no name and a
|
|
77
|
+
* `maxBytes` that is not a positive number throw one before anything is read.
|
|
78
|
+
*/
|
|
79
|
+
export async function describeUpload(file, options = {}) {
|
|
80
|
+
const filename = options.filename ?? ownName(file);
|
|
81
|
+
if (filename === undefined || filename.length === 0) {
|
|
82
|
+
throw new InputPreparationError("The file to describe has no name: pass a File, or give a Blob its name as the " +
|
|
83
|
+
"filename option.");
|
|
84
|
+
}
|
|
85
|
+
const maxBytes = options.maxBytes ?? DEFAULT_DESCRIBE_MAX_BYTES;
|
|
86
|
+
if (!(typeof maxBytes === "number" && Number.isFinite(maxBytes) && maxBytes > 0)) {
|
|
87
|
+
throw new InputPreparationError(`"maxBytes" must be a positive number, got ${String(options.maxBytes)}.`);
|
|
88
|
+
}
|
|
89
|
+
const description = { filename, content_type: file.type, size: file.size };
|
|
90
|
+
// A file past the bound is never read and goes without its hash: the grant route refuses
|
|
91
|
+
// its size when the deployment's limit is no larger, and otherwise grants it hashless.
|
|
92
|
+
if (file.size > maxBytes)
|
|
93
|
+
return description;
|
|
94
|
+
const digest = webCryptoDigest();
|
|
95
|
+
if (digest === undefined)
|
|
96
|
+
return description;
|
|
97
|
+
let bytes;
|
|
98
|
+
try {
|
|
99
|
+
bytes = await file.arrayBuffer();
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
throw new InputPreparationError(`"${filename}" could not be read to compute its SHA-256 (${describeFailureChain(error)}). ` +
|
|
103
|
+
"Pick the file again.", { cause: error });
|
|
104
|
+
}
|
|
105
|
+
return { ...description, sha256: hex(await digest("SHA-256", bytes)) };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The runtime's Web Crypto digest, or undefined where it offers none: a browser gives
|
|
109
|
+
* `crypto.subtle` to a secure context only, a page served over `https` or from the
|
|
110
|
+
* local host.
|
|
111
|
+
*/
|
|
112
|
+
function webCryptoDigest() {
|
|
113
|
+
const subtle = globalThis.crypto?.subtle;
|
|
114
|
+
return typeof subtle?.digest === "function" ? subtle.digest.bind(subtle) : undefined;
|
|
115
|
+
}
|
|
116
|
+
function hex(digest) {
|
|
117
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
118
|
+
}
|
|
119
|
+
/** A `File`'s own name, or undefined for a `Blob`, which has none. */
|
|
120
|
+
function ownName(file) {
|
|
121
|
+
const name = file.name;
|
|
122
|
+
return typeof name === "string" ? name : undefined;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Store a file with an upload grant, and return the reference it now carries.
|
|
126
|
+
*
|
|
127
|
+
* **A grant that says `stored: true` sends nothing**: the organization already holds
|
|
128
|
+
* these bytes, so the call returns the grant's `uri` and `file_id` at once, without
|
|
129
|
+
* reading the file or reaching storage. Otherwise the file goes to the grant's `url` as
|
|
130
|
+
* the raw body of a `PUT`, with the grant's signed headers unchanged; the body sets
|
|
131
|
+
* `Content-Length`, which the grant signed, so the file must be exactly the size the
|
|
132
|
+
* grant was requested for, and when the grant was requested with the file's SHA-256,
|
|
133
|
+
* storage holds the body to it.
|
|
134
|
+
*
|
|
135
|
+
* Two answers from storage are no failure. A `412` refuses the create-only `PUT`
|
|
136
|
+
* because the object already exists: when the grant names the file by its bytes (it
|
|
137
|
+
* signs `x-amz-checksum-sha256`), that object can only hold those bytes, stored
|
|
138
|
+
* meanwhile by another upload of them or by the platform's own copy of a run's file
|
|
139
|
+
* that holds them, so the call succeeds with the grant's `uri` and `file_id`, the answer
|
|
140
|
+
* a `stored: true` grant gives. A `409 ConditionalRequestConflict`
|
|
141
|
+
* says another write of the object was in flight, so the `PUT` is sent again after a
|
|
142
|
+
* short pause, a few times at most.
|
|
38
143
|
*
|
|
39
144
|
* Storage's refusals map onto the input-preparation errors, so a caller that
|
|
40
145
|
* already handles `uploadFile` needs no new case:
|
|
41
146
|
*
|
|
42
|
-
* - `RejectedAssetError` (`status` is storage's, `code` says why) — a `412`
|
|
43
|
-
* the grant was already used (`grant_used`:
|
|
44
|
-
* `403` when the file's size, type or metadata
|
|
45
|
-
* (`signature_mismatch`), when the request carried
|
|
46
|
-
* (`unsigned_header`), or when the grant has expired
|
|
47
|
-
* `4xx` but a `408` or a `429` (`store_refused`)
|
|
48
|
-
*
|
|
147
|
+
* - `RejectedAssetError` (`status` is storage's, `code` says why) — a `412` for a
|
|
148
|
+
* grant that names no bytes, when the grant was already used (`grant_used`: such a
|
|
149
|
+
* grant writes one object, once); a `403` when the file's size, type or metadata
|
|
150
|
+
* differ from what the grant signed (`signature_mismatch`), when the request carried
|
|
151
|
+
* a header the grant did not sign (`unsigned_header`), or when the grant has expired
|
|
152
|
+
* (`grant_expired`); any other `4xx` but a `408` or a `429` (`store_refused`), a
|
|
153
|
+
* `400 BadDigest` among them, for bytes other than the ones whose SHA-256 the grant
|
|
154
|
+
* was requested with. Each asks for a new grant, or for the file the grant was
|
|
155
|
+
* requested for.
|
|
49
156
|
* - `UploadTransportError` (`status` is storage's when it answered, `code` says
|
|
50
157
|
* which) — the time limit running out (`timeout`), storage unreachable
|
|
51
158
|
* (`unreachable`), a `5xx` (`server_error`), storage timing out on the body
|
|
52
|
-
* (`storage_timeout`, a `400 RequestTimeout`, which wrote nothing), another
|
|
53
|
-
*
|
|
159
|
+
* (`storage_timeout`, a `400 RequestTimeout`, which wrote nothing), another write of
|
|
160
|
+
* the object still in progress after every attempt (`conflict`, a `409
|
|
54
161
|
* ConditionalRequestConflict`), storage timing out or throttling the request
|
|
55
162
|
* (`unexpected`, a `408` or a `429`, refused for its timing rather than for the
|
|
56
163
|
* file), or a redirect, which is refused rather than followed (`redirected`). In a
|
|
57
164
|
* browser a refused cross-origin request looks like an unreachable host, so the
|
|
58
|
-
* message names that too. A grant whose `url` is not an
|
|
59
|
-
* of user info is refused the same way, before anything
|
|
60
|
-
* (`invalid_grant_url`).
|
|
165
|
+
* message names that too. A grant that is not stored and whose `url` is not an
|
|
166
|
+
* absolute `http(s)` URL free of user info is refused the same way, before anything
|
|
167
|
+
* is sent (`invalid_grant_url`).
|
|
61
168
|
*
|
|
62
|
-
* The whole exchange runs under a time limit
|
|
63
|
-
* 1 s for every started 128 KiB of the file. A
|
|
64
|
-
* and its abort propagates as the signal's own
|
|
65
|
-
* limit ran out too. Only the first 16 KiB of
|
|
66
|
-
* the limit runs out while that body is still
|
|
67
|
-
*
|
|
68
|
-
* body
|
|
169
|
+
* The whole exchange runs under a time limit, the pauses after a conflict included:
|
|
170
|
+
* `timeoutMs` when given, else 60 s plus 1 s for every started 128 KiB of the file. A
|
|
171
|
+
* caller's `signal` can end it sooner, and its abort propagates as the signal's own
|
|
172
|
+
* reason, unwrapped, even when the time limit ran out too. Only the first 16 KiB of
|
|
173
|
+
* storage's error body are read, and when the limit runs out while that body is still
|
|
174
|
+
* arriving, or during the pause after a conflict, storage has already answered: the
|
|
175
|
+
* call settles as that answer, classified from its status and whatever part of the body
|
|
176
|
+
* arrived.
|
|
69
177
|
*
|
|
70
178
|
* A timeout, a `5xx`, a conflict and a connection lost after the file went out leave
|
|
71
179
|
* it unknown whether the object was written: retrying with the same grant before it
|
|
72
|
-
* expires either stores it or
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
180
|
+
* expires either stores it or finds it stored, and then the grant's `uri` names the
|
|
181
|
+
* file. After a `408` or a `429` the same retry applies, later, since storage refused
|
|
182
|
+
* the request for its timing. A `501` is the exception: storage does not implement the
|
|
183
|
+
* request it was sent, stored nothing, and answers a retry the same way, so its error
|
|
184
|
+
* is not retryable. The grant is a bearer capability: nothing here logs it, and no
|
|
185
|
+
* error this throws carries its URL, storage's error body or a runtime error that
|
|
186
|
+
* could hold either.
|
|
79
187
|
*/
|
|
80
188
|
export async function uploadWithGrant(grant, file, options = {}) {
|
|
81
189
|
const label = fileLabel(grant, file);
|
|
@@ -86,11 +194,14 @@ export async function uploadWithGrant(grant, file, options = {}) {
|
|
|
86
194
|
throw signal.reason;
|
|
87
195
|
if (timeoutMs !== undefined)
|
|
88
196
|
requireTimeout(timeoutMs);
|
|
197
|
+
// The organization already holds the bytes: the grant is the whole answer.
|
|
198
|
+
if (grant.stored === true)
|
|
199
|
+
return grantedUpload(grant);
|
|
89
200
|
const limitMs = timeoutMs ?? defaultTimeoutMs(file.size);
|
|
90
201
|
const target = storageTarget(grant, label);
|
|
91
202
|
// The call's own controller carries both ways the exchange can be cut short: the
|
|
92
203
|
// caller's signal, forwarded into it, and the time limit, which also covers reading
|
|
93
|
-
// storage's error body.
|
|
204
|
+
// storage's error body and the pauses between attempts.
|
|
94
205
|
const controller = new AbortController();
|
|
95
206
|
let timedOut = false;
|
|
96
207
|
const timer = setTimeout(() => {
|
|
@@ -100,96 +211,155 @@ export async function uploadWithGrant(grant, file, options = {}) {
|
|
|
100
211
|
const onCallerAbort = () => controller.abort(signal?.reason);
|
|
101
212
|
signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
102
213
|
try {
|
|
103
|
-
let
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
214
|
+
for (let attempt = 0;; attempt += 1) {
|
|
215
|
+
let response;
|
|
216
|
+
try {
|
|
217
|
+
response = await fetch(target.href, {
|
|
218
|
+
method: "PUT",
|
|
219
|
+
headers: grant.headers ?? undefined,
|
|
220
|
+
body: file,
|
|
221
|
+
// A presigned URL signs its host, so a redirect could never succeed; refusing
|
|
222
|
+
// it keeps the file from being sent anywhere the grant did not name.
|
|
223
|
+
redirect: "manual",
|
|
224
|
+
signal: controller.signal,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
// The signals' outcome, not the runtime's error: some runtimes reject with a generic
|
|
229
|
+
// AbortError instead of the reason. A caller's abort wins over the time limit, since
|
|
230
|
+
// it is the one the caller can see and act on.
|
|
231
|
+
if (signal?.aborted)
|
|
232
|
+
throw signal.reason;
|
|
233
|
+
if (timedOut)
|
|
234
|
+
throw timeoutError(label, limitMs, grant);
|
|
235
|
+
// Neither the runtime's message nor the error itself is kept: either can carry the
|
|
236
|
+
// request URL, and with it the grant's credential (Bun puts it on the error's `path`).
|
|
237
|
+
throw new UploadTransportError(`Upload of "${label}" could not reach storage at ${target.origin} ` +
|
|
238
|
+
`(${describeFailureChain(error)}). In a browser, a refused cross-origin request ` +
|
|
239
|
+
"looks the same: check that the page may connect to the storage origin (its CSP " +
|
|
240
|
+
"connect-src). If the connection failed after the file went out, storage may have " +
|
|
241
|
+
`stored it anyway. ${sameGrantRetry(grant)}`, { code: "unreachable" });
|
|
242
|
+
}
|
|
243
|
+
// A body is cancelled, never awaited: a stream's cancel can outlive any time limit.
|
|
244
|
+
if (response.status >= 200 && response.status < 300) {
|
|
245
|
+
void response.body?.cancel().catch(() => undefined);
|
|
246
|
+
return grantedUpload(grant);
|
|
247
|
+
}
|
|
248
|
+
// The create-only PUT found the object there. A grant that names its bytes signs the
|
|
249
|
+
// key they are named by, which can hold no other bytes: they were stored meanwhile.
|
|
250
|
+
if (response.status === 412 && namesItsBytes(grant)) {
|
|
251
|
+
void response.body?.cancel().catch(() => undefined);
|
|
252
|
+
return grantedUpload(grant);
|
|
253
|
+
}
|
|
254
|
+
// A browser reports a refused redirect as an opaque response with status 0.
|
|
255
|
+
if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
|
|
256
|
+
void response.body?.cancel().catch(() => undefined);
|
|
257
|
+
throw new UploadTransportError(`Upload of "${label}" was redirected by storage, and the redirect was refused: a ` +
|
|
258
|
+
"presigned upload is valid only at the URL it was signed for.",
|
|
259
|
+
// A refused redirect in a browser is opaque, with status 0: no status reached us.
|
|
260
|
+
{ status: response.status || undefined, code: "redirected" });
|
|
261
|
+
}
|
|
262
|
+
// Only the error's code and message are kept, never the body: S3 echoes the
|
|
263
|
+
// canonical request on a signature mismatch, and with it the grant's credential.
|
|
264
|
+
const body = await readErrorPrefix(response, ERROR_BODY_MAX_BYTES, controller.signal);
|
|
265
|
+
// A caller's abort while the body streams stays the caller's, unwrapped. The time limit
|
|
266
|
+
// running out then is no timeout: storage has answered, and its status says what
|
|
267
|
+
// happened, read with whatever part of the body arrived.
|
|
119
268
|
if (signal?.aborted)
|
|
120
269
|
throw signal.reason;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
throw new
|
|
270
|
+
const refusal = parseStorageError(body);
|
|
271
|
+
const status = response.status;
|
|
272
|
+
if (status === 400 && refusal.code === "RequestTimeout") {
|
|
273
|
+
throw new UploadTransportError(`Upload of "${label}" timed out at storage (${describeStatus(response, refusal)}): ` +
|
|
274
|
+
"storage stopped waiting for the file's bytes and wrote nothing. Retry with the " +
|
|
275
|
+
`same grant${beforeExpiry(grant)}, or with a new one.`, { status, code: "storage_timeout" });
|
|
276
|
+
}
|
|
277
|
+
// S3 answers this while another write or a delete of the key is in flight, and documents
|
|
278
|
+
// it as retryable: it says nothing about this file, so it is no refusal of it, and the
|
|
279
|
+
// same PUT is sent again once the other write has had a moment to finish. When the time
|
|
280
|
+
// limit runs out first, the call settles as this answer, which is storage's last word.
|
|
281
|
+
if (status === 409 && refusal.code === "ConditionalRequestConflict") {
|
|
282
|
+
const delay = CONFLICT_RETRY_DELAYS_MS[attempt];
|
|
283
|
+
if (delay !== undefined && !timedOut) {
|
|
284
|
+
try {
|
|
285
|
+
await pause(delay, controller.signal);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
if (signal?.aborted)
|
|
290
|
+
throw signal.reason;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const attempts = attempt + 1;
|
|
294
|
+
throw new UploadTransportError(`Upload of "${label}" met another write of the same object still in progress at ` +
|
|
295
|
+
`storage (${describeStatus(response, refusal)}) ` +
|
|
296
|
+
(attempts === 1 ? "on its one attempt" : `on each of its ${attempts} attempts`) +
|
|
297
|
+
(timedOut ? ` before the ${formatSeconds(limitMs)} s allowed ran out` : "") +
|
|
298
|
+
`, so whether the file was stored is unknown. ${sameGrantRetry(grant)}`, { status, code: "conflict" });
|
|
299
|
+
}
|
|
300
|
+
// A 408 or a 429 refuses the request for its timing, never the file: storage timed it out
|
|
301
|
+
// or throttled it, so it falls through to the transport failure below, whose verdict reads
|
|
302
|
+
// its status as retryable.
|
|
303
|
+
if (status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
304
|
+
const { code, advice } = classifyRefusal(status, refusal, grant);
|
|
305
|
+
throw new RejectedAssetError(`Storage refused the upload of "${label}" (${describeStatus(response, refusal)}): ${advice}`, label, status, { code });
|
|
306
|
+
}
|
|
307
|
+
const failure = `Upload of "${label}" failed at storage (${describeStatus(response, refusal)})` +
|
|
308
|
+
(refusal.message ? `: ${withoutFinalPeriod(refusal.message)}` : "");
|
|
309
|
+
// Storage answers a 501 for a request it does not implement, such as a header it does
|
|
310
|
+
// not support: it stored nothing, and the same request meets the same answer, so neither
|
|
311
|
+
// the same grant nor a new one is worth a retry, and the error's verdict says so too.
|
|
312
|
+
if (status === 501) {
|
|
313
|
+
throw new UploadTransportError(`${failure}. Storage does not implement the request it was sent, so it stored ` +
|
|
314
|
+
"nothing, and sending the file again will meet the same answer.", { status, code: "server_error" });
|
|
315
|
+
}
|
|
316
|
+
if (status === 408 || status === 429) {
|
|
317
|
+
throw new UploadTransportError(`${failure}. Storage ${status === 429 ? "throttled" : "timed out"} the request rather ` +
|
|
318
|
+
`than refusing the file, so a later attempt can succeed. ${sameGrantRetry(grant)}`, { status, code: "unexpected" });
|
|
319
|
+
}
|
|
320
|
+
throw new UploadTransportError(`${failure}. Whether the file was stored is unknown. ${sameGrantRetry(grant)}`, { status, code: status >= 500 ? "server_error" : "unexpected" });
|
|
172
321
|
}
|
|
173
|
-
const failure = `Upload of "${label}" failed at storage (${describeStatus(response, refusal)})` +
|
|
174
|
-
(refusal.message ? `: ${withoutFinalPeriod(refusal.message)}` : "");
|
|
175
|
-
// Storage answers a 501 for a request it does not implement, such as a header it does not
|
|
176
|
-
// support: it stored nothing, and the same request meets the same answer, so neither the
|
|
177
|
-
// same grant nor a new one is worth a retry, and the error's verdict says so too.
|
|
178
|
-
if (status === 501) {
|
|
179
|
-
throw new UploadTransportError(`${failure}. Storage does not implement the request it was sent, so it stored nothing, ` +
|
|
180
|
-
"and sending the file again will meet the same answer.", { status, code: "server_error" });
|
|
181
|
-
}
|
|
182
|
-
if (status === 408 || status === 429) {
|
|
183
|
-
throw new UploadTransportError(`${failure}. Storage ${status === 429 ? "throttled" : "timed out"} the request rather ` +
|
|
184
|
-
`than refusing the file, so a later attempt can succeed. ${sameGrantRetry(grant)}`, { status, code: "unexpected" });
|
|
185
|
-
}
|
|
186
|
-
throw new UploadTransportError(`${failure}. Whether the file was stored is unknown. ${sameGrantRetry(grant)}`, { status, code: status >= 500 ? "server_error" : "unexpected" });
|
|
187
322
|
}
|
|
188
323
|
finally {
|
|
189
324
|
clearTimeout(timer);
|
|
190
325
|
signal?.removeEventListener("abort", onCallerAbort);
|
|
191
326
|
}
|
|
192
327
|
}
|
|
328
|
+
function grantedUpload(grant) {
|
|
329
|
+
return grant.file_id === undefined
|
|
330
|
+
? { uri: grant.uri }
|
|
331
|
+
: { uri: grant.uri, file_id: grant.file_id };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Whether the grant names its object by the file's bytes: it signs the SHA-256 they were
|
|
335
|
+
* described with, so the object it creates can hold those bytes and no others.
|
|
336
|
+
*
|
|
337
|
+
* The signed checksum is the platform's own mark of such a grant: it signs
|
|
338
|
+
* `x-amz-checksum-sha256` on every grant asked for with a hash, whose key is named after
|
|
339
|
+
* the bytes, and on no grant asked for without one, whose key is a fresh name
|
|
340
|
+
* (`platform/docs/uploads.md` in `pipelex-server`). The key's shape is never read.
|
|
341
|
+
*/
|
|
342
|
+
function namesItsBytes(grant) {
|
|
343
|
+
return Object.keys(grant.headers ?? {}).some((name) => name.toLowerCase() === CHECKSUM_HEADER);
|
|
344
|
+
}
|
|
345
|
+
/** Wait `ms`, or reject with `abort`'s reason as soon as it fires. */
|
|
346
|
+
function pause(ms, abort) {
|
|
347
|
+
return new Promise((resolve, reject) => {
|
|
348
|
+
if (abort.aborted) {
|
|
349
|
+
reject(abort.reason);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const onAbort = () => {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
reject(abort.reason);
|
|
355
|
+
};
|
|
356
|
+
const timer = setTimeout(() => {
|
|
357
|
+
abort.removeEventListener("abort", onAbort);
|
|
358
|
+
resolve();
|
|
359
|
+
}, ms);
|
|
360
|
+
abort.addEventListener("abort", onAbort, { once: true });
|
|
361
|
+
});
|
|
362
|
+
}
|
|
193
363
|
/** The default time limit for a file of `size` bytes, capped at what a timer honours. */
|
|
194
364
|
function defaultTimeoutMs(size) {
|
|
195
365
|
const seconds = Math.ceil(size / DEFAULT_TIMEOUT_BYTES_PER_SECOND);
|
|
@@ -207,10 +377,20 @@ function timeoutError(label, limitMs, grant) {
|
|
|
207
377
|
"whether storage stored the file is unknown. On a slow link, pass a longer timeoutMs. " +
|
|
208
378
|
sameGrantRetry(grant), { code: "timeout" });
|
|
209
379
|
}
|
|
210
|
-
/**
|
|
380
|
+
/**
|
|
381
|
+
* What to do when the upload may have been stored: the grant itself tells, on a retry. A
|
|
382
|
+
* grant that names its bytes finds them stored; any other reports itself used.
|
|
383
|
+
*/
|
|
211
384
|
function sameGrantRetry(grant) {
|
|
212
|
-
return (
|
|
213
|
-
|
|
385
|
+
return namesItsBytes(grant)
|
|
386
|
+
? `Retrying with the same grant${beforeExpiry(grant)} stores the file or finds it ` +
|
|
387
|
+
"already stored, and either way the grant's uri then names the file."
|
|
388
|
+
: `Retrying with the same grant${beforeExpiry(grant)} either stores the file or reports ` +
|
|
389
|
+
"the grant as used, and then the grant's uri already names the file.";
|
|
390
|
+
}
|
|
391
|
+
/** " before it expires at …", or nothing for a grant that names no expiry. */
|
|
392
|
+
function beforeExpiry(grant) {
|
|
393
|
+
return grant.expires_at ? ` before it expires at ${grant.expires_at}` : "";
|
|
214
394
|
}
|
|
215
395
|
function formatSeconds(ms) {
|
|
216
396
|
return String(Number((ms / 1_000).toFixed(3)));
|
|
@@ -267,32 +447,35 @@ async function readErrorPrefix(response, maxBytes, abort) {
|
|
|
267
447
|
* from a URL names the whole URL in its error — Node and Chrome both do, for one that does
|
|
268
448
|
* not parse or that carries user info — and a browser resolves a relative one against the
|
|
269
449
|
* page, which would send the credential to the page's own origin. So only an absolute
|
|
270
|
-
* `http(s)` URL with no user info is sent, and the refusal names
|
|
450
|
+
* `http(s)` URL with no user info is sent, as the grant gave it, and the refusal names
|
|
451
|
+
* none of it. A grant that is not stored and carries no `url` at all is refused the same
|
|
452
|
+
* way.
|
|
271
453
|
*/
|
|
272
454
|
function storageTarget(grant, label) {
|
|
273
455
|
let target;
|
|
274
456
|
try {
|
|
275
|
-
target = new URL(grant.url);
|
|
457
|
+
target = typeof grant.url === "string" ? new URL(grant.url) : undefined;
|
|
276
458
|
}
|
|
277
459
|
catch {
|
|
278
460
|
target = undefined;
|
|
279
461
|
}
|
|
280
|
-
if (
|
|
462
|
+
if (typeof grant.url !== "string" ||
|
|
463
|
+
target === undefined ||
|
|
281
464
|
(target.protocol !== "https:" && target.protocol !== "http:") ||
|
|
282
465
|
target.username !== "" ||
|
|
283
466
|
target.password !== "") {
|
|
284
|
-
throw new UploadTransportError(`Upload of "${label}" was not sent: the grant
|
|
285
|
-
"free of user info, so it is not the one
|
|
286
|
-
"on unchanged.", { code: "invalid_grant_url" });
|
|
467
|
+
throw new UploadTransportError(`Upload of "${label}" was not sent: the grant says the file is not stored yet, and its ` +
|
|
468
|
+
"url is not an absolute http(s) URL free of user info, so it is not the one " +
|
|
469
|
+
"requestUploadGrant returned. Pass the grant on unchanged.", { code: "invalid_grant_url" });
|
|
287
470
|
}
|
|
288
|
-
return target;
|
|
471
|
+
return { href: grant.url, origin: target.origin };
|
|
289
472
|
}
|
|
290
473
|
/**
|
|
291
|
-
* A
|
|
292
|
-
* "TypeError, caused by Error ENOTFOUND". Both are mutable, so each is kept only when it
|
|
474
|
+
* A failure described by the names and codes along its cause chain, such as a network
|
|
475
|
+
* failure's "TypeError, caused by Error ENOTFOUND". Both are mutable, so each is kept only when it
|
|
293
476
|
* is a bare identifier, which cannot hold a URL.
|
|
294
477
|
*/
|
|
295
|
-
function
|
|
478
|
+
function describeFailureChain(error) {
|
|
296
479
|
const parts = [];
|
|
297
480
|
let current = error;
|
|
298
481
|
for (let depth = 0; depth < 5 && current instanceof Error; depth++) {
|
|
@@ -334,12 +517,21 @@ function describeStatus(response, refusal) {
|
|
|
334
517
|
* answer `403 AccessDenied`, so only S3's message tells them apart.
|
|
335
518
|
*/
|
|
336
519
|
function classifyRefusal(status, refusal, grant) {
|
|
520
|
+
// A grant that names its bytes never gets here with a 412: the object it names is the file.
|
|
337
521
|
if (status === 412) {
|
|
338
522
|
return {
|
|
339
523
|
code: "grant_used",
|
|
340
|
-
advice: "this grant was already used. A grant
|
|
341
|
-
"
|
|
342
|
-
"URI; otherwise request a new grant.",
|
|
524
|
+
advice: "this grant was already used. A grant that does not name the file by its bytes " +
|
|
525
|
+
"writes one object, once. If an earlier attempt with it failed at storage, that " +
|
|
526
|
+
"attempt may have stored the file under the grant's URI; otherwise request a new grant.",
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
if (refusal.code === "BadDigest") {
|
|
530
|
+
return {
|
|
531
|
+
code: "store_refused",
|
|
532
|
+
advice: "the file's bytes differ from the ones whose SHA-256 the grant was requested with. " +
|
|
533
|
+
"Send exactly the file the grant was requested for, or describe the file again and " +
|
|
534
|
+
"request a new grant.",
|
|
343
535
|
};
|
|
344
536
|
}
|
|
345
537
|
if (refusal.code === "SignatureDoesNotMatch") {
|
|
@@ -352,7 +544,7 @@ function classifyRefusal(status, refusal, grant) {
|
|
|
352
544
|
if (refusal.message && /expired/i.test(refusal.message)) {
|
|
353
545
|
return {
|
|
354
546
|
code: "grant_expired",
|
|
355
|
-
advice: `the grant expired at ${grant.expires_at}. Request a new grant.`,
|
|
547
|
+
advice: `the grant expired${grant.expires_at ? ` at ${grant.expires_at}` : ""}. Request a new grant.`,
|
|
356
548
|
};
|
|
357
549
|
}
|
|
358
550
|
if (refusal.message && /not signed/i.test(refusal.message)) {
|