@taprootio/site-authoring 0.23.0 → 0.23.1
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/package.json +1 -1
- package/src/changelog.json +8 -0
- package/src/transport.js +55 -17
- package/src/verbs/media-upload.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
package/src/changelog.json
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"entries": [
|
|
3
|
+
{
|
|
4
|
+
"version": "0.23.1",
|
|
5
|
+
"note": "Image uploads support Taproot’s single-use upload gateway and send an operation key with each upload request.",
|
|
6
|
+
"behavior": [
|
|
7
|
+
"media upload sends an operation key when requesting an image upload. It accepts a single-use gateway capability only for the configured Taproot API origin and image-upload path, and never automatically retries that capability after a transport failure or retryable response. Direct signed object-store uploads retain their existing retry behavior."
|
|
8
|
+
],
|
|
9
|
+
"help": []
|
|
10
|
+
},
|
|
3
11
|
{
|
|
4
12
|
"version": "0.23.0",
|
|
5
13
|
"note": "theme resolve prints the colors Espalier compiles from the theme, per scheme and context, with their contrast ratios, offline; the help and the staging review result say how long a review lasts and what ends it; pages delete with a dry run; nav push fenced by a navigation revision.",
|
package/src/transport.js
CHANGED
|
@@ -51,6 +51,10 @@ const IDEMPOTENT_METHODS = new Set(["GET"]);
|
|
|
51
51
|
|
|
52
52
|
/** An exchanged credential is renewed once it is this close to expiring. */
|
|
53
53
|
const CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS = 5 * 60_000;
|
|
54
|
+
const GATEWAY_UPLOAD_ATTEMPTS = 4;
|
|
55
|
+
const GATEWAY_CAPACITY_WINDOW_MILLISECONDS = 15_000;
|
|
56
|
+
const UPLOAD_CAPACITY_HEADER = "x-taproot-upload-admission";
|
|
57
|
+
const UPLOAD_CAPACITY_REFUSAL = "capacity-before-claim";
|
|
54
58
|
|
|
55
59
|
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
56
60
|
const RETRYABLE_RESPONSE_ERRORS = new Set([
|
|
@@ -965,15 +969,16 @@ export class SiteApiClient {
|
|
|
965
969
|
}
|
|
966
970
|
|
|
967
971
|
/**
|
|
968
|
-
* Performs
|
|
972
|
+
* Performs a whole-object PUT. Signed S3 headers are echoed
|
|
969
973
|
* exactly as the server returned them and are never recomputed: the values
|
|
970
974
|
* are covered by the SigV4 signature, so a "helpfully" regenerated
|
|
971
975
|
* Content-Type or an added header turns into an opaque S3 403. The bearer is
|
|
972
976
|
* never forwarded — the signature is the authorization, and sending the
|
|
973
977
|
* site key to an object store would leak it outside the reviewed origin.
|
|
974
978
|
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
979
|
+
* S3 create-only uploads retain their existing retry contract. A gateway
|
|
980
|
+
* capability is consumed once before the body is read. Only an explicit
|
|
981
|
+
* capacity refusal before that claim permits a bounded retry.
|
|
977
982
|
*/
|
|
978
983
|
async upload({ url: uploadUrl, requiredHeaders } = {}, bodyBytes) {
|
|
979
984
|
// A video original is streamed from disk: `{ byteLength, stream, timeoutMilliseconds }`,
|
|
@@ -1040,7 +1045,8 @@ export class SiteApiClient {
|
|
|
1040
1045
|
names.add(normalizedName);
|
|
1041
1046
|
headers.set(name, value);
|
|
1042
1047
|
}
|
|
1043
|
-
|
|
1048
|
+
const gateway = headers.has("x-taproot-upload-capability");
|
|
1049
|
+
if (!headers.has("content-type") || (!gateway && !headers.has("content-length"))) {
|
|
1044
1050
|
throw new SiteAuthoringError(
|
|
1045
1051
|
"upload.headers_invalid",
|
|
1046
1052
|
"The signed upload capability is missing required content headers.",
|
|
@@ -1049,31 +1055,48 @@ export class SiteApiClient {
|
|
|
1049
1055
|
// The declared length is bound into the signature, so a body of any other
|
|
1050
1056
|
// size cannot succeed. Failing here names the real problem instead of
|
|
1051
1057
|
// surfacing an S3 signature error.
|
|
1052
|
-
if (
|
|
1058
|
+
if (gateway && (
|
|
1059
|
+
url.origin !== this.apiOrigin
|
|
1060
|
+
|| !url.pathname.startsWith(this.apiPathPrefix)
|
|
1061
|
+
|| !/^\/api\/v1\/images\/uploads\/[0-9a-f-]{36}\/content$/u.test(url.pathname)
|
|
1062
|
+
|| url.search !== ""
|
|
1063
|
+
)) {
|
|
1064
|
+
throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an invalid gateway URL.");
|
|
1065
|
+
}
|
|
1066
|
+
if (!gateway && headers.get("content-length") !== String(bodyBytes.byteLength)) {
|
|
1053
1067
|
throw new SiteAuthoringError(
|
|
1054
1068
|
"upload.content_length_invalid",
|
|
1055
1069
|
"The signed upload content length does not match the bytes being uploaded.",
|
|
1056
1070
|
);
|
|
1057
1071
|
}
|
|
1058
1072
|
|
|
1059
|
-
|
|
1073
|
+
const maximumAttempts = gateway ? GATEWAY_UPLOAD_ATTEMPTS : LIMITS.uploadAttempts;
|
|
1074
|
+
const startedAt = this.now();
|
|
1075
|
+
const uploadDeadline = startedAt + ((streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds);
|
|
1076
|
+
for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
|
|
1077
|
+
if (this.signal?.aborted) throw cancelledError(false);
|
|
1078
|
+
const remaining = uploadDeadline - this.now();
|
|
1079
|
+
if (gateway && remaining <= 0) {
|
|
1080
|
+
throw new SiteAuthoringError("upload.capacity_exhausted", "The image upload exhausted its attempt window.");
|
|
1081
|
+
}
|
|
1060
1082
|
let response;
|
|
1083
|
+
const attemptSignal = operationSignal(
|
|
1084
|
+
this.signal,
|
|
1085
|
+
gateway ? remaining : (streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
|
|
1086
|
+
this.timeoutSignal,
|
|
1087
|
+
);
|
|
1061
1088
|
try {
|
|
1062
|
-
response = await this.fetch(url, {
|
|
1089
|
+
response = await awaitWithSignal(this.fetch(url, {
|
|
1063
1090
|
method: "PUT",
|
|
1064
1091
|
redirect: "error",
|
|
1065
|
-
signal:
|
|
1066
|
-
this.signal,
|
|
1067
|
-
(streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
|
|
1068
|
-
this.timeoutSignal,
|
|
1069
|
-
),
|
|
1092
|
+
signal: attemptSignal,
|
|
1070
1093
|
headers,
|
|
1071
1094
|
...(streamed
|
|
1072
1095
|
? { body: Readable.toWeb(bodyBytes.stream()), duplex: "half" }
|
|
1073
1096
|
: { body: bodyBytes }),
|
|
1074
|
-
});
|
|
1097
|
+
}), attemptSignal);
|
|
1075
1098
|
} catch {
|
|
1076
|
-
if (attempt + 1 <
|
|
1099
|
+
if (!gateway && attempt + 1 < maximumAttempts && !this.signal?.aborted) {
|
|
1077
1100
|
await this.sleep(delayForAttempt(attempt), this.signal);
|
|
1078
1101
|
continue;
|
|
1079
1102
|
}
|
|
@@ -1082,14 +1105,29 @@ export class SiteApiClient {
|
|
|
1082
1105
|
"The whole-object upload ended without an authoritative response.",
|
|
1083
1106
|
);
|
|
1084
1107
|
}
|
|
1085
|
-
await cancelBody(response);
|
|
1108
|
+
await cancelBody(response, attemptSignal);
|
|
1086
1109
|
if (response.status >= 200 && response.status < 300 && !response.redirected) return;
|
|
1087
|
-
|
|
1110
|
+
const capacityRefused = gateway && !response.redirected && response.status === HTTP_TOO_MANY_REQUESTS
|
|
1111
|
+
&& response.headers.get(UPLOAD_CAPACITY_HEADER) === UPLOAD_CAPACITY_REFUSAL;
|
|
1112
|
+
const retryAfter = response.headers.get("retry-after") ?? "";
|
|
1113
|
+
if (capacityRefused && /^[1-5]$/u.test(retryAfter) && attempt + 1 < maximumAttempts) {
|
|
1114
|
+
const delay = Number(retryAfter) * 1000;
|
|
1115
|
+
const capacityRemaining = Math.min(uploadDeadline, startedAt + GATEWAY_CAPACITY_WINDOW_MILLISECONDS) - this.now();
|
|
1116
|
+
if (delay < capacityRemaining) {
|
|
1117
|
+
// TR01295: the server guarantees that this rejected PUT did not
|
|
1118
|
+
// claim the capability. Network failures and 5xx never retry it.
|
|
1119
|
+
await awaitWithSignal(this.sleep(delay, this.signal), operationSignal(
|
|
1120
|
+
this.signal, capacityRemaining, this.timeoutSignal,
|
|
1121
|
+
));
|
|
1122
|
+
if (this.now() - startedAt < GATEWAY_CAPACITY_WINDOW_MILLISECONDS) continue;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (!gateway && RETRYABLE_STATUS.has(response.status) && attempt + 1 < maximumAttempts) {
|
|
1088
1126
|
await this.sleep(delayForAttempt(attempt), this.signal);
|
|
1089
1127
|
continue;
|
|
1090
1128
|
}
|
|
1091
1129
|
throw new SiteAuthoringError(
|
|
1092
|
-
RETRYABLE_STATUS.has(response.status) ? "upload.ambiguous" : "upload.rejected",
|
|
1130
|
+
RETRYABLE_STATUS.has(response.status) && !capacityRefused ? "upload.ambiguous" : "upload.rejected",
|
|
1093
1131
|
`The whole-object upload was rejected with HTTP ${response.status}.`,
|
|
1094
1132
|
{ status: `http:${response.status}` },
|
|
1095
1133
|
);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { Readable } from "node:stream";
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -624,6 +625,7 @@ export async function mediaUpload(invocation) {
|
|
|
624
625
|
contentType,
|
|
625
626
|
fileSize: bytes.byteLength,
|
|
626
627
|
contentHash: hash,
|
|
628
|
+
operationKey: randomUUID(),
|
|
627
629
|
width,
|
|
628
630
|
height,
|
|
629
631
|
ownershipScope: IMAGE_OWNERSHIP_SCOPE_SITE,
|