@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.
@@ -1,16 +1,18 @@
1
1
  /**
2
- * `uploadWithGrant` — send one file straight to Pipelex storage with an upload
3
- * grant (`PipelexApiClient.requestUploadGrant`, `POST /v1/upload/grant`). The
4
- * side that holds the credential requests the grant; the side that holds the
5
- * bytes, typically a browser page, sends them with this. See
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, and
12
- * `tests/upload-grant.test.ts` bundles it for the browser with esbuild to hold it
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
- * `PUT` a file to storage with an upload grant, and return the reference it now
35
- * carries. The file goes as the raw body with the grant's signed headers
36
- * unchanged; the body sets `Content-Length`, which the grant signed, so the file
37
- * must be exactly the size the grant was requested for.
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` when
43
- * the grant was already used (`grant_used`: a grant writes one object, once); a
44
- * `403` when the file's size, type or metadata differ from what the grant signed
45
- * (`signature_mismatch`), when the request carried a header the grant did not sign
46
- * (`unsigned_header`), or when the grant has expired (`grant_expired`); any other
47
- * `4xx` but a `408` or a `429` (`store_refused`). Each asks for a new grant, or for
48
- * the file the grant was requested for.
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 upload
53
- * with the same grant still in progress (`conflict`, a `409
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 absolute `http(s)` URL free
59
- * of user info is refused the same way, before anything is sent
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: `timeoutMs` when given, else 60 s plus
63
- * 1 s for every started 128 KiB of the file. A caller's `signal` can end it sooner,
64
- * and its abort propagates as the signal's own reason, unwrapped, even when the time
65
- * limit ran out too. Only the first 16 KiB of storage's error body are read, and when
66
- * the limit runs out while that body is still arriving, storage has already answered:
67
- * the call settles as that answer, classified from its status and whatever part of the
68
- * body arrived.
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 answers the `412` of a used grant, and then the
73
- * grant's `uri` already names the file. After a `408` or a `429` the same retry
74
- * applies, later, since storage refused the request for its timing. A `501` is the
75
- * exception: storage does not implement the request it was sent, stored nothing, and
76
- * answers a retry the same way, so its error is not retryable. The grant is a bearer
77
- * capability: nothing here logs it, and no error this throws carries its URL,
78
- * storage's error body or a runtime error that could hold either.
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 response;
104
- try {
105
- response = await fetch(grant.url, {
106
- method: "PUT",
107
- headers: grant.headers,
108
- body: file,
109
- // A presigned URL signs its host, so a redirect could never succeed; refusing
110
- // it keeps the file from being sent anywhere the grant did not name.
111
- redirect: "manual",
112
- signal: controller.signal,
113
- });
114
- }
115
- catch (error) {
116
- // The signals' outcome, not the runtime's error: some runtimes reject with a generic
117
- // AbortError instead of the reason. A caller's abort wins over the time limit, since
118
- // it is the one the caller can see and act on.
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
- if (timedOut)
122
- throw timeoutError(label, limitMs, grant);
123
- // Neither the runtime's message nor the error itself is kept: either can carry the
124
- // request URL, and with it the grant's credential (Bun puts it on the error's `path`).
125
- throw new UploadTransportError(`Upload of "${label}" could not reach storage at ${target.origin} ` +
126
- `(${describeNetworkFailure(error)}). In a browser, a refused cross-origin request ` +
127
- "looks the same: check that the page may connect to the storage origin (its CSP " +
128
- "connect-src). If the connection failed after the file went out, storage may have " +
129
- `stored it anyway. ${sameGrantRetry(grant)}`, { code: "unreachable" });
130
- }
131
- // A body is cancelled, never awaited: a stream's cancel can outlive any time limit.
132
- if (response.status >= 200 && response.status < 300) {
133
- void response.body?.cancel().catch(() => undefined);
134
- return { uri: grant.uri };
135
- }
136
- // A browser reports a refused redirect as an opaque response with status 0.
137
- if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
138
- void response.body?.cancel().catch(() => undefined);
139
- throw new UploadTransportError(`Upload of "${label}" was redirected by storage, and the redirect was refused: a ` +
140
- "presigned upload is valid only at the URL it was signed for.",
141
- // A refused redirect in a browser is opaque, with status 0: no status reached us.
142
- { status: response.status || undefined, code: "redirected" });
143
- }
144
- // Only the error's code and message are kept, never the body: S3 echoes the
145
- // canonical request on a signature mismatch, and with it the grant's credential.
146
- const body = await readErrorPrefix(response, ERROR_BODY_MAX_BYTES, controller.signal);
147
- // A caller's abort while the body streams stays the caller's, unwrapped. The time limit
148
- // running out then is no timeout: storage has answered, and its status says what
149
- // happened, read with whatever part of the body arrived.
150
- if (signal?.aborted)
151
- throw signal.reason;
152
- const refusal = parseStorageError(body);
153
- const status = response.status;
154
- if (status === 400 && refusal.code === "RequestTimeout") {
155
- throw new UploadTransportError(`Upload of "${label}" timed out at storage (${describeStatus(response, refusal)}): ` +
156
- "storage stopped waiting for the file's bytes and wrote nothing. Retry with the same " +
157
- `grant before it expires at ${grant.expires_at}, or with a new one.`, { status, code: "storage_timeout" });
158
- }
159
- // S3 answers this when two create-only PUTs for one key overlap, and documents it as
160
- // retryable: it says nothing about this file, so it is no refusal of it.
161
- if (status === 409 && refusal.code === "ConditionalRequestConflict") {
162
- throw new UploadTransportError(`Upload of "${label}" met another upload with the same grant still in progress at ` +
163
- `storage (${describeStatus(response, refusal)}), so whether the file was stored is ` +
164
- `unknown. Send a grant once at a time. ${sameGrantRetry(grant)}`, { status, code: "conflict" });
165
- }
166
- // A 408 or a 429 refuses the request for its timing, never the file: storage timed it out or
167
- // throttled it, so it falls through to the transport failure below, whose verdict reads its
168
- // status as retryable.
169
- if (status >= 400 && status < 500 && status !== 408 && status !== 429) {
170
- const { code, advice } = classifyRefusal(status, refusal, grant);
171
- throw new RejectedAssetError(`Storage refused the upload of "${label}" (${describeStatus(response, refusal)}): ${advice}`, label, status, { code });
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
- /** What to do when the upload may have been stored: the grant itself tells, on a retry. */
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 (`Retrying with the same grant before it expires at ${grant.expires_at} either stores ` +
213
- "the file or reports the grant as used, and then the grant's uri already names the file.");
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 none of it.
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 (target === undefined ||
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's url is not an absolute http(s) URL ` +
285
- "free of user info, so it is not the one requestUploadGrant returned. Pass the grant " +
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 network failure described by the names and codes along its cause chain:
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 describeNetworkFailure(error) {
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 writes one object, once. If an earlier attempt " +
341
- "with it failed at storage, that attempt may have stored the file under the grant's " +
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)) {