@ttt-productions/media-processing-core 0.8.16 → 0.8.18
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/dist/audio/audio-processor.d.ts +2 -2
- package/dist/audio/audio-processor.d.ts.map +1 -1
- package/dist/audio/audio-processor.js +63 -50
- package/dist/audio/audio-processor.js.map +1 -1
- package/dist/audio/probe.d.ts +1 -1
- package/dist/audio/probe.d.ts.map +1 -1
- package/dist/audio/probe.js +6 -5
- package/dist/audio/probe.js.map +1 -1
- package/dist/duration/media-duration.d.ts +2 -13
- package/dist/duration/media-duration.d.ts.map +1 -1
- package/dist/duration/media-duration.js +24 -49
- package/dist/duration/media-duration.js.map +1 -1
- package/dist/image/formats.d.ts +2 -13
- package/dist/image/formats.d.ts.map +1 -1
- package/dist/image/formats.js +25 -35
- package/dist/image/formats.js.map +1 -1
- package/dist/image/image-processor.d.ts +2 -2
- package/dist/image/image-processor.d.ts.map +1 -1
- package/dist/image/image-processor.js +48 -57
- package/dist/image/image-processor.js.map +1 -1
- package/dist/image/resize.d.ts +2 -16
- package/dist/image/resize.d.ts.map +1 -1
- package/dist/image/resize.js +13 -28
- package/dist/image/resize.js.map +1 -1
- package/dist/index.d.ts +19 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -16
- package/dist/index.js.map +1 -1
- package/dist/inspection/inspect-media.d.ts +13 -20
- package/dist/inspection/inspect-media.d.ts.map +1 -1
- package/dist/inspection/inspect-media.js +197 -201
- package/dist/inspection/inspect-media.js.map +1 -1
- package/dist/io/fs.d.ts +1 -1
- package/dist/io/fs.d.ts.map +1 -1
- package/dist/io/fs.js +4 -5
- package/dist/io/fs.js.map +1 -1
- package/dist/io/local-input.d.ts +1 -21
- package/dist/io/local-input.d.ts.map +1 -1
- package/dist/io/local-input.js +15 -35
- package/dist/io/local-input.js.map +1 -1
- package/dist/io/types.d.ts +0 -3
- package/dist/io/types.d.ts.map +1 -1
- package/dist/io/types.js.map +1 -1
- package/dist/moderation/merge.d.ts +1 -1
- package/dist/moderation/merge.d.ts.map +1 -1
- package/dist/moderation/merge.js +1 -2
- package/dist/moderation/merge.js.map +1 -1
- package/dist/moderation/types.d.ts +3 -5
- package/dist/moderation/types.d.ts.map +1 -1
- package/dist/moderation/types.js.map +1 -1
- package/dist/process-media.d.ts +2 -3
- package/dist/process-media.d.ts.map +1 -1
- package/dist/process-media.js +9 -9
- package/dist/process-media.js.map +1 -1
- package/dist/processing/errors.d.ts.map +1 -1
- package/dist/processing/errors.js +1 -1
- package/dist/processing/errors.js.map +1 -1
- package/dist/processing/result.d.ts +2 -2
- package/dist/processing/result.js +1 -1
- package/dist/processing/result.js.map +1 -1
- package/dist/run-pipeline.d.ts +5 -22
- package/dist/run-pipeline.d.ts.map +1 -1
- package/dist/run-pipeline.js +72 -81
- package/dist/run-pipeline.js.map +1 -1
- package/dist/server/download-token.d.ts +0 -6
- package/dist/server/download-token.d.ts.map +1 -1
- package/dist/server/download-token.js +3 -13
- package/dist/server/download-token.js.map +1 -1
- package/dist/server/firebase-media-io.d.ts +3 -13
- package/dist/server/firebase-media-io.d.ts.map +1 -1
- package/dist/server/firebase-media-io.js +1 -6
- package/dist/server/firebase-media-io.js.map +1 -1
- package/dist/server/gcs-errors.d.ts +0 -8
- package/dist/server/gcs-errors.d.ts.map +1 -1
- package/dist/server/gcs-errors.js +2 -10
- package/dist/server/gcs-errors.js.map +1 -1
- package/dist/server/index.d.ts +7 -6
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +7 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/pending-media.d.ts +3 -34
- package/dist/server/pending-media.d.ts.map +1 -1
- package/dist/server/pending-media.js +13 -30
- package/dist/server/pending-media.js.map +1 -1
- package/dist/server/r2-retry.d.ts +4 -52
- package/dist/server/r2-retry.d.ts.map +1 -1
- package/dist/server/r2-retry.js +49 -134
- package/dist/server/r2-retry.js.map +1 -1
- package/dist/server/staged-object.d.ts +2 -46
- package/dist/server/staged-object.d.ts.map +1 -1
- package/dist/server/staged-object.js +4 -48
- package/dist/server/staged-object.js.map +1 -1
- package/dist/server/storage-ops.d.ts +5 -67
- package/dist/server/storage-ops.d.ts.map +1 -1
- package/dist/server/storage-ops.js +110 -185
- package/dist/server/storage-ops.js.map +1 -1
- package/dist/types.d.ts +2 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/log.d.ts.map +1 -1
- package/dist/utils/log.js +3 -3
- package/dist/utils/log.js.map +1 -1
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +1 -4
- package/dist/utils/paths.js.map +1 -1
- package/dist/utils/safe-path.d.ts.map +1 -1
- package/dist/utils/safe-path.js +8 -9
- package/dist/utils/safe-path.js.map +1 -1
- package/dist/validation/validate-duration.d.ts.map +1 -1
- package/dist/validation/validate-duration.js.map +1 -1
- package/dist/validation/validate-mime.d.ts.map +1 -1
- package/dist/validation/validate-mime.js.map +1 -1
- package/dist/validation/validate-size.d.ts.map +1 -1
- package/dist/validation/validate-size.js.map +1 -1
- package/dist/video/ffmpeg.d.ts +1 -23
- package/dist/video/ffmpeg.d.ts.map +1 -1
- package/dist/video/ffmpeg.js +57 -64
- package/dist/video/ffmpeg.js.map +1 -1
- package/dist/video/probe.d.ts +1 -1
- package/dist/video/probe.d.ts.map +1 -1
- package/dist/video/probe.js +10 -8
- package/dist/video/probe.js.map +1 -1
- package/dist/video/video-processor.d.ts +2 -2
- package/dist/video/video-processor.d.ts.map +1 -1
- package/dist/video/video-processor.js +125 -96
- package/dist/video/video-processor.js.map +1 -1
- package/dist/workspace/temp.js +4 -4
- package/dist/workspace/temp.js.map +1 -1
- package/package.json +2 -2
- package/dist/audio/index.d.ts +0 -3
- package/dist/audio/index.d.ts.map +0 -1
- package/dist/audio/index.js +0 -3
- package/dist/audio/index.js.map +0 -1
- package/dist/image/index.d.ts +0 -4
- package/dist/image/index.d.ts.map +0 -1
- package/dist/image/index.js +0 -4
- package/dist/image/index.js.map +0 -1
- package/dist/video/index.d.ts +0 -4
- package/dist/video/index.d.ts.map +0 -1
- package/dist/video/index.js +0 -4
- package/dist/video/index.js.map +0 -1
package/dist/server/r2-retry.js
CHANGED
|
@@ -1,30 +1,8 @@
|
|
|
1
|
-
// Bounded transient-retry policy for the R2 object store.
|
|
2
|
-
//
|
|
3
|
-
// PRIVATE sibling of storage-ops.ts — imported ONLY by that module. It is
|
|
4
|
-
// deliberately NOT re-exported from the server barrel (`server/index.ts`); the
|
|
5
|
-
// only public surface is the `R2StorageError` type, which storage-ops re-exports
|
|
6
|
-
// so callers can recognize an exhausted storage operation.
|
|
7
|
-
//
|
|
8
|
-
// The policy applies to the idempotent-against-a-deterministic-key requests
|
|
9
|
-
// only (putFile, copy, readToFile, and deleteCopy's read of the destination).
|
|
10
|
-
// Every DELETE request is intentionally one-shot and never routes through here.
|
|
11
|
-
// Signing/config failures, local filesystem errors, and deterministic HTTP 4xx
|
|
12
|
-
// responses are never retried.
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Fixed internal policy — never exposed as tunables on the public factory.
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
/** TOTAL attempts (initial + retries) — three totals means at most TWO delays. */
|
|
17
1
|
export const R2_REQUEST_MAX_ATTEMPTS = 3;
|
|
18
|
-
/** Base delay for full-jitter exponential backoff. */
|
|
19
2
|
export const R2_RETRY_BASE_DELAY_MS = 250;
|
|
20
|
-
/** Ceiling on the exponential backoff (before jitter). */
|
|
21
3
|
export const R2_RETRY_MAX_BACKOFF_MS = 5_000;
|
|
22
|
-
/** Hard cap on an honored server `Retry-After` so one response can't eat the
|
|
23
|
-
* caller's whole timeout budget. */
|
|
24
4
|
export const R2_RETRY_AFTER_CAP_MS = 30_000;
|
|
25
|
-
/** HTTP statuses treated as transient and safe to retry. */
|
|
26
5
|
export const R2_RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
27
|
-
/** Production seam defaults. */
|
|
28
6
|
export function defaultR2RetrySeams() {
|
|
29
7
|
return {
|
|
30
8
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
@@ -32,124 +10,91 @@ export function defaultR2RetrySeams() {
|
|
|
32
10
|
now: Date.now,
|
|
33
11
|
};
|
|
34
12
|
}
|
|
35
|
-
//
|
|
36
|
-
// Transient-error classification
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
// Recognized transient transport failure codes (undici/fetch surface these on
|
|
39
|
-
// the error or its `.cause`). A broken connection to R2 lands here.
|
|
13
|
+
// undici/fetch surface these codes on the error or its `.cause`.
|
|
40
14
|
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
15
|
+
'ECONNRESET',
|
|
16
|
+
'ECONNREFUSED',
|
|
17
|
+
'ECONNABORTED',
|
|
18
|
+
'ETIMEDOUT',
|
|
19
|
+
'EPIPE',
|
|
20
|
+
'ENOTFOUND',
|
|
21
|
+
'EAI_AGAIN',
|
|
22
|
+
'ENETUNREACH',
|
|
23
|
+
'ENETDOWN',
|
|
24
|
+
'EHOSTUNREACH',
|
|
25
|
+
'EHOSTDOWN',
|
|
26
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
27
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
28
|
+
'UND_ERR_BODY_TIMEOUT',
|
|
29
|
+
'UND_ERR_SOCKET',
|
|
56
30
|
]);
|
|
57
|
-
// Deterministic local filesystem errors are NEVER retried (disk full, perms,
|
|
58
|
-
// missing dir, etc.). This guard wins over any transport classification so a
|
|
59
|
-
// local write failure during readToFile piping can't be mistaken for transient.
|
|
60
31
|
const LOCAL_FS_ERROR_CODES = new Set([
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
32
|
+
'ENOSPC',
|
|
33
|
+
'EACCES',
|
|
34
|
+
'EISDIR',
|
|
35
|
+
'ENOENT',
|
|
36
|
+
'EROFS',
|
|
37
|
+
'EMFILE',
|
|
38
|
+
'ENFILE',
|
|
39
|
+
'EBADF',
|
|
40
|
+
'EEXIST',
|
|
41
|
+
'EPERM',
|
|
42
|
+
'ENAMETOOLONG',
|
|
43
|
+
'ELOOP',
|
|
44
|
+
'ENOTDIR',
|
|
45
|
+
'EDQUOT',
|
|
75
46
|
]);
|
|
76
47
|
function codeOf(err) {
|
|
77
|
-
if (err && typeof err ===
|
|
48
|
+
if (err && typeof err === 'object') {
|
|
78
49
|
const c = err.code;
|
|
79
|
-
if (typeof c ===
|
|
50
|
+
if (typeof c === 'string')
|
|
80
51
|
return c;
|
|
81
52
|
}
|
|
82
53
|
return undefined;
|
|
83
54
|
}
|
|
84
|
-
/**
|
|
85
|
-
* True only for recognized transient transport failures. An explicit
|
|
86
|
-
* `AbortError` and deterministic local-filesystem errors always return false —
|
|
87
|
-
* an abort must never become another attempt.
|
|
88
|
-
*/
|
|
89
55
|
export function isRetryableTransportError(err) {
|
|
90
|
-
if (!err || typeof err !==
|
|
56
|
+
if (!err || typeof err !== 'object')
|
|
91
57
|
return false;
|
|
92
58
|
const e = err;
|
|
93
|
-
|
|
94
|
-
if (e.name === "AbortError")
|
|
59
|
+
if (e.name === 'AbortError')
|
|
95
60
|
return false;
|
|
96
61
|
const selfCode = codeOf(err);
|
|
97
62
|
const causeCode = codeOf(e.cause);
|
|
98
|
-
// Deterministic local filesystem errors are not transient — decided first so a
|
|
99
|
-
// local write error can never be reclassified as a transport failure.
|
|
100
63
|
if (selfCode && LOCAL_FS_ERROR_CODES.has(selfCode))
|
|
101
64
|
return false;
|
|
102
65
|
if (causeCode && LOCAL_FS_ERROR_CODES.has(causeCode))
|
|
103
66
|
return false;
|
|
104
|
-
// Recognized transient network codes on the error or its cause.
|
|
105
67
|
if (selfCode && RETRYABLE_NETWORK_ERROR_CODES.has(selfCode))
|
|
106
68
|
return true;
|
|
107
69
|
if (causeCode && RETRYABLE_NETWORK_ERROR_CODES.has(causeCode))
|
|
108
70
|
return true;
|
|
109
|
-
// fetch()/undici surface transport failures as `TypeError: fetch failed
|
|
110
|
-
|
|
111
|
-
if (err instanceof TypeError || e.name === "TypeError")
|
|
71
|
+
// fetch()/undici surface transport failures as `TypeError: fetch failed`, often with no recognizable cause.
|
|
72
|
+
if (err instanceof TypeError || e.name === 'TypeError')
|
|
112
73
|
return true;
|
|
113
74
|
return false;
|
|
114
75
|
}
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
// Retry-After parsing + delay computation
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
/**
|
|
119
|
-
* Parse a `Retry-After` header value into a non-negative millisecond delay.
|
|
120
|
-
* Supports both the delta-seconds form (`"120"`) and the HTTP-date form
|
|
121
|
-
* (`"Wed, 21 Oct 2015 07:28:00 GMT"`). Returns null when absent/unparseable.
|
|
122
|
-
* An HTTP-date in the past clamps to 0.
|
|
123
|
-
*/
|
|
124
76
|
export function parseRetryAfterMs(headerValue, nowMs) {
|
|
125
77
|
if (headerValue == null)
|
|
126
78
|
return null;
|
|
127
79
|
const trimmed = headerValue.trim();
|
|
128
|
-
if (trimmed ===
|
|
80
|
+
if (trimmed === '')
|
|
129
81
|
return null;
|
|
130
|
-
// delta-seconds form
|
|
131
82
|
if (/^\d+$/.test(trimmed)) {
|
|
132
83
|
const seconds = Number(trimmed);
|
|
133
84
|
if (!Number.isFinite(seconds) || seconds < 0)
|
|
134
85
|
return null;
|
|
135
86
|
return seconds * 1000;
|
|
136
87
|
}
|
|
137
|
-
// HTTP-date form
|
|
138
88
|
const dateMs = Date.parse(trimmed);
|
|
139
89
|
if (Number.isNaN(dateMs))
|
|
140
90
|
return null;
|
|
141
91
|
const delta = dateMs - nowMs;
|
|
142
92
|
return delta > 0 ? delta : 0;
|
|
143
93
|
}
|
|
144
|
-
/**
|
|
145
|
-
* Compute the pre-next-attempt delay. If the failed response carries a valid
|
|
146
|
-
* `Retry-After`, honor it (capped, no backoff added on top). Otherwise use
|
|
147
|
-
* full-jitter exponential backoff: `random(0, min(maxBackoff, base * 2^retryIndex))`.
|
|
148
|
-
*/
|
|
149
94
|
export function computeR2DelayMs(params) {
|
|
150
95
|
const { response, retryIndex, seams } = params;
|
|
151
96
|
if (response) {
|
|
152
|
-
const retryAfterMs = parseRetryAfterMs(response.headers.get(
|
|
97
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'), seams.now());
|
|
153
98
|
if (retryAfterMs !== null) {
|
|
154
99
|
return Math.min(retryAfterMs, R2_RETRY_AFTER_CAP_MS);
|
|
155
100
|
}
|
|
@@ -157,35 +102,21 @@ export function computeR2DelayMs(params) {
|
|
|
157
102
|
const ceiling = Math.min(R2_RETRY_MAX_BACKOFF_MS, R2_RETRY_BASE_DELAY_MS * 2 ** retryIndex);
|
|
158
103
|
return Math.floor(seams.random() * ceiling);
|
|
159
104
|
}
|
|
160
|
-
// ---------------------------------------------------------------------------
|
|
161
|
-
// Exhaustion error
|
|
162
|
-
// ---------------------------------------------------------------------------
|
|
163
|
-
/**
|
|
164
|
-
* Thrown when an R2 operation fails: a retried operation exhausts its attempts or
|
|
165
|
-
* hits a non-retryable response, or a one-shot delete fails. Carries
|
|
166
|
-
* operation/bucket/key/attempt/status context, the S3 error code parsed from the
|
|
167
|
-
* response body, and the bounded body itself as properties — never credentials,
|
|
168
|
-
* authorization headers, or full signed URLs. Callers classify it from those
|
|
169
|
-
* properties, never from the message.
|
|
170
|
-
*
|
|
171
|
-
* The message names only the operation, the attempts, the status, and the S3 error
|
|
172
|
-
* code. The bucket, the key, and the raw body — which can echo either — stay out of
|
|
173
|
-
* it, so an escaping error never carries an object key to monitoring.
|
|
174
|
-
*/
|
|
175
105
|
export class R2StorageError extends Error {
|
|
176
106
|
operation;
|
|
177
107
|
bucket;
|
|
178
108
|
key;
|
|
179
109
|
attempts;
|
|
180
110
|
status;
|
|
181
|
-
/** The S3 `<Code>` of the response body (`NoSuchKey`, `PreconditionFailed`, …), when it had one. */
|
|
182
111
|
s3ErrorCode;
|
|
183
112
|
responseText;
|
|
184
113
|
constructor(params) {
|
|
185
114
|
const code = parseS3ErrorCode(params.responseText);
|
|
186
|
-
const detail = [params.status !== undefined ? `status ${params.status}` : undefined, code]
|
|
187
|
-
|
|
188
|
-
|
|
115
|
+
const detail = [params.status !== undefined ? `status ${params.status}` : undefined, code]
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.join(' ');
|
|
118
|
+
super(`R2 ${params.operation} failed after ${params.attempts} attempt(s)${detail ? `: ${detail}` : ''}`, params.cause !== undefined ? { cause: params.cause } : undefined);
|
|
119
|
+
this.name = 'R2StorageError';
|
|
189
120
|
this.operation = params.operation;
|
|
190
121
|
this.bucket = params.bucket;
|
|
191
122
|
this.key = params.key;
|
|
@@ -195,38 +126,27 @@ export class R2StorageError extends Error {
|
|
|
195
126
|
this.responseText = params.responseText;
|
|
196
127
|
}
|
|
197
128
|
}
|
|
198
|
-
/** The `<Code>` of an S3 XML error body; only a plain alphanumeric token is taken. */
|
|
199
129
|
function parseS3ErrorCode(responseText) {
|
|
200
130
|
return responseText ? /<Code>([A-Za-z0-9]{1,64})<\/Code>/.exec(responseText)?.[1] : undefined;
|
|
201
131
|
}
|
|
202
|
-
//
|
|
203
|
-
// code-point filter rather than a control-character regex (which `no-control-regex`
|
|
204
|
-
// forbids and which would embed literal control bytes in source).
|
|
132
|
+
// A code-point filter, not a control-character regex, which `no-control-regex` forbids.
|
|
205
133
|
function sanitizeBodyText(text) {
|
|
206
|
-
let out =
|
|
134
|
+
let out = '';
|
|
207
135
|
for (const ch of text.slice(0, 1000)) {
|
|
208
136
|
const code = ch.codePointAt(0) ?? 0;
|
|
209
|
-
out += code < 0x20 || code === 0x7f ?
|
|
137
|
+
out += code < 0x20 || code === 0x7f ? ' ' : ch;
|
|
210
138
|
}
|
|
211
139
|
return out.trim().slice(0, 500);
|
|
212
140
|
}
|
|
213
|
-
//
|
|
214
|
-
// the underlying socket can be reused before the next attempt. R2 error bodies
|
|
215
|
-
// are short XML and never contain our credentials; sanitize defensively regardless.
|
|
141
|
+
// Draining the body lets the socket be reused before the next attempt.
|
|
216
142
|
async function safeReadBoundedBody(res) {
|
|
217
143
|
try {
|
|
218
144
|
return sanitizeBodyText(await res.text());
|
|
219
145
|
}
|
|
220
146
|
catch {
|
|
221
|
-
return
|
|
147
|
+
return '<no body>';
|
|
222
148
|
}
|
|
223
149
|
}
|
|
224
|
-
/**
|
|
225
|
-
* Drive up to `R2_REQUEST_MAX_ATTEMPTS` attempts of a single storage operation.
|
|
226
|
-
* `runAttempt` builds a FRESH signed request each call and returns either a
|
|
227
|
-
* success value or the raw `Response` for the driver to classify. Thrown errors
|
|
228
|
-
* are classified: only recognized transient transport failures are retried.
|
|
229
|
-
*/
|
|
230
150
|
export async function fetchWithR2Retry(params) {
|
|
231
151
|
const { operation, bucket, key, seams, runAttempt } = params;
|
|
232
152
|
for (let attempt = 1; attempt <= R2_REQUEST_MAX_ATTEMPTS; attempt++) {
|
|
@@ -235,8 +155,6 @@ export async function fetchWithR2Retry(params) {
|
|
|
235
155
|
result = await runAttempt(attempt);
|
|
236
156
|
}
|
|
237
157
|
catch (err) {
|
|
238
|
-
// Signing failures, explicit aborts, and deterministic local-fs errors are
|
|
239
|
-
// never retried; only recognized transient transport failures are.
|
|
240
158
|
if (!isRetryableTransportError(err))
|
|
241
159
|
throw err;
|
|
242
160
|
if (attempt >= R2_REQUEST_MAX_ATTEMPTS) {
|
|
@@ -245,20 +163,17 @@ export async function fetchWithR2Retry(params) {
|
|
|
245
163
|
await seams.sleep(computeR2DelayMs({ response: null, retryIndex: attempt - 1, seams }));
|
|
246
164
|
continue;
|
|
247
165
|
}
|
|
248
|
-
if (result.kind ===
|
|
166
|
+
if (result.kind === 'success')
|
|
249
167
|
return result.value;
|
|
250
168
|
const response = result.response;
|
|
251
169
|
const status = response.status;
|
|
252
|
-
// Consume the failed body before the next attempt.
|
|
253
170
|
const responseText = await safeReadBoundedBody(response);
|
|
254
171
|
if (R2_RETRYABLE_STATUSES.has(status) && attempt < R2_REQUEST_MAX_ATTEMPTS) {
|
|
255
172
|
await seams.sleep(computeR2DelayMs({ response, retryIndex: attempt - 1, seams }));
|
|
256
173
|
continue;
|
|
257
174
|
}
|
|
258
|
-
// Non-retryable status, or a retryable status with attempts exhausted.
|
|
259
175
|
throw new R2StorageError({ operation, bucket, key, attempts: attempt, status, responseText });
|
|
260
176
|
}
|
|
261
|
-
// Unreachable: the loop always returns or throws.
|
|
262
177
|
throw new R2StorageError({ operation, bucket, key, attempts: R2_REQUEST_MAX_ATTEMPTS });
|
|
263
178
|
}
|
|
264
179
|
//# sourceMappingURL=r2-retry.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"r2-retry.js","sourceRoot":"","sources":["../../src/server/r2-retry.ts"],"names":[],"mappings":"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","sourcesContent":["// Bounded transient-retry policy for the R2 object store.\n//\n// PRIVATE sibling of storage-ops.ts — imported ONLY by that module. It is\n// deliberately NOT re-exported from the server barrel (`server/index.ts`); the\n// only public surface is the `R2StorageError` type, which storage-ops re-exports\n// so callers can recognize an exhausted storage operation.\n//\n// The policy applies to the idempotent-against-a-deterministic-key requests\n// only (putFile, copy, readToFile, and deleteCopy's read of the destination).\n// Every DELETE request is intentionally one-shot and never routes through here.\n// Signing/config failures, local filesystem errors, and deterministic HTTP 4xx\n// responses are never retried.\n\n// ---------------------------------------------------------------------------\n// Fixed internal policy — never exposed as tunables on the public factory.\n// ---------------------------------------------------------------------------\n\n/** TOTAL attempts (initial + retries) — three totals means at most TWO delays. */\nexport const R2_REQUEST_MAX_ATTEMPTS = 3;\n/** Base delay for full-jitter exponential backoff. */\nexport const R2_RETRY_BASE_DELAY_MS = 250;\n/** Ceiling on the exponential backoff (before jitter). */\nexport const R2_RETRY_MAX_BACKOFF_MS = 5_000;\n/** Hard cap on an honored server `Retry-After` so one response can't eat the\n * caller's whole timeout budget. */\nexport const R2_RETRY_AFTER_CAP_MS = 30_000;\n\n/** HTTP statuses treated as transient and safe to retry. */\nexport const R2_RETRYABLE_STATUSES: ReadonlySet<number> = new Set([408, 429, 500, 502, 503, 504]);\n\nexport type R2RetryableOperation = \"putFile\" | \"copy\" | \"readToFile\" | \"deleteCopy\";\n\n/** Every R2 store operation an `R2StorageError` can name; `delete` never retries. */\nexport type R2Operation = R2RetryableOperation | \"delete\";\n\n/** Deterministic seams for tests; production defaults are internal (see below). */\nexport interface R2RetrySeams {\n /** Resolve after `ms` milliseconds. */\n sleep: (ms: number) => Promise<void>;\n /** Uniform random in [0, 1). */\n random: () => number;\n /** Epoch-millisecond clock — keeps HTTP-date `Retry-After` tests off wall-clock. */\n now: () => number;\n}\n\n/** Production seam defaults. */\nexport function defaultR2RetrySeams(): R2RetrySeams {\n return {\n sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),\n random: Math.random,\n now: Date.now,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Transient-error classification\n// ---------------------------------------------------------------------------\n\n// Recognized transient transport failure codes (undici/fetch surface these on\n// the error or its `.cause`). A broken connection to R2 lands here.\nconst RETRYABLE_NETWORK_ERROR_CODES: ReadonlySet<string> = new Set([\n \"ECONNRESET\",\n \"ECONNREFUSED\",\n \"ECONNABORTED\",\n \"ETIMEDOUT\",\n \"EPIPE\",\n \"ENOTFOUND\",\n \"EAI_AGAIN\",\n \"ENETUNREACH\",\n \"ENETDOWN\",\n \"EHOSTUNREACH\",\n \"EHOSTDOWN\",\n \"UND_ERR_CONNECT_TIMEOUT\",\n \"UND_ERR_HEADERS_TIMEOUT\",\n \"UND_ERR_BODY_TIMEOUT\",\n \"UND_ERR_SOCKET\",\n]);\n\n// Deterministic local filesystem errors are NEVER retried (disk full, perms,\n// missing dir, etc.). This guard wins over any transport classification so a\n// local write failure during readToFile piping can't be mistaken for transient.\nconst LOCAL_FS_ERROR_CODES: ReadonlySet<string> = new Set([\n \"ENOSPC\",\n \"EACCES\",\n \"EISDIR\",\n \"ENOENT\",\n \"EROFS\",\n \"EMFILE\",\n \"ENFILE\",\n \"EBADF\",\n \"EEXIST\",\n \"EPERM\",\n \"ENAMETOOLONG\",\n \"ELOOP\",\n \"ENOTDIR\",\n \"EDQUOT\",\n]);\n\nfunction codeOf(err: unknown): string | undefined {\n if (err && typeof err === \"object\") {\n const c = (err as { code?: unknown }).code;\n if (typeof c === \"string\") return c;\n }\n return undefined;\n}\n\n/**\n * True only for recognized transient transport failures. An explicit\n * `AbortError` and deterministic local-filesystem errors always return false —\n * an abort must never become another attempt.\n */\nexport function isRetryableTransportError(err: unknown): boolean {\n if (!err || typeof err !== \"object\") return false;\n const e = err as { name?: unknown; cause?: unknown };\n\n // NEVER convert an explicit abort into another attempt.\n if (e.name === \"AbortError\") return false;\n\n const selfCode = codeOf(err);\n const causeCode = codeOf(e.cause);\n\n // Deterministic local filesystem errors are not transient — decided first so a\n // local write error can never be reclassified as a transport failure.\n if (selfCode && LOCAL_FS_ERROR_CODES.has(selfCode)) return false;\n if (causeCode && LOCAL_FS_ERROR_CODES.has(causeCode)) return false;\n\n // Recognized transient network codes on the error or its cause.\n if (selfCode && RETRYABLE_NETWORK_ERROR_CODES.has(selfCode)) return true;\n if (causeCode && RETRYABLE_NETWORK_ERROR_CODES.has(causeCode)) return true;\n\n // fetch()/undici surface transport failures as `TypeError: fetch failed`\n // (often with an unrecognized or absent cause) — treat those as transient.\n if (err instanceof TypeError || e.name === \"TypeError\") return true;\n\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Retry-After parsing + delay computation\n// ---------------------------------------------------------------------------\n\n/**\n * Parse a `Retry-After` header value into a non-negative millisecond delay.\n * Supports both the delta-seconds form (`\"120\"`) and the HTTP-date form\n * (`\"Wed, 21 Oct 2015 07:28:00 GMT\"`). Returns null when absent/unparseable.\n * An HTTP-date in the past clamps to 0.\n */\nexport function parseRetryAfterMs(headerValue: string | null | undefined, nowMs: number): number | null {\n if (headerValue == null) return null;\n const trimmed = headerValue.trim();\n if (trimmed === \"\") return null;\n\n // delta-seconds form\n if (/^\\d+$/.test(trimmed)) {\n const seconds = Number(trimmed);\n if (!Number.isFinite(seconds) || seconds < 0) return null;\n return seconds * 1000;\n }\n\n // HTTP-date form\n const dateMs = Date.parse(trimmed);\n if (Number.isNaN(dateMs)) return null;\n const delta = dateMs - nowMs;\n return delta > 0 ? delta : 0;\n}\n\n/**\n * Compute the pre-next-attempt delay. If the failed response carries a valid\n * `Retry-After`, honor it (capped, no backoff added on top). Otherwise use\n * full-jitter exponential backoff: `random(0, min(maxBackoff, base * 2^retryIndex))`.\n */\nexport function computeR2DelayMs(params: {\n response: Response | null;\n /** 0-based index of the retry about to happen (0 for the first retry). */\n retryIndex: number;\n seams: R2RetrySeams;\n}): number {\n const { response, retryIndex, seams } = params;\n\n if (response) {\n const retryAfterMs = parseRetryAfterMs(response.headers.get(\"retry-after\"), seams.now());\n if (retryAfterMs !== null) {\n return Math.min(retryAfterMs, R2_RETRY_AFTER_CAP_MS);\n }\n }\n\n const ceiling = Math.min(R2_RETRY_MAX_BACKOFF_MS, R2_RETRY_BASE_DELAY_MS * 2 ** retryIndex);\n return Math.floor(seams.random() * ceiling);\n}\n\n// ---------------------------------------------------------------------------\n// Exhaustion error\n// ---------------------------------------------------------------------------\n\n/**\n * Thrown when an R2 operation fails: a retried operation exhausts its attempts or\n * hits a non-retryable response, or a one-shot delete fails. Carries\n * operation/bucket/key/attempt/status context, the S3 error code parsed from the\n * response body, and the bounded body itself as properties — never credentials,\n * authorization headers, or full signed URLs. Callers classify it from those\n * properties, never from the message.\n *\n * The message names only the operation, the attempts, the status, and the S3 error\n * code. The bucket, the key, and the raw body — which can echo either — stay out of\n * it, so an escaping error never carries an object key to monitoring.\n */\nexport class R2StorageError extends Error {\n readonly operation: R2Operation;\n readonly bucket: string;\n readonly key: string;\n readonly attempts: number;\n readonly status?: number;\n /** The S3 `<Code>` of the response body (`NoSuchKey`, `PreconditionFailed`, …), when it had one. */\n readonly s3ErrorCode?: string;\n readonly responseText?: string;\n\n constructor(params: {\n operation: R2Operation;\n bucket: string;\n key: string;\n attempts: number;\n status?: number;\n responseText?: string;\n cause?: unknown;\n }) {\n const code = parseS3ErrorCode(params.responseText);\n const detail = [params.status !== undefined ? `status ${params.status}` : undefined, code].filter(Boolean).join(\" \");\n super(\n `R2 ${params.operation} failed after ${params.attempts} attempt(s)${detail ? `: ${detail}` : \"\"}`,\n params.cause !== undefined ? { cause: params.cause } : undefined,\n );\n this.name = \"R2StorageError\";\n this.operation = params.operation;\n this.bucket = params.bucket;\n this.key = params.key;\n this.attempts = params.attempts;\n this.status = params.status;\n this.s3ErrorCode = code;\n this.responseText = params.responseText;\n }\n}\n\n/** The `<Code>` of an S3 XML error body; only a plain alphanumeric token is taken. */\nfunction parseS3ErrorCode(responseText: string | undefined): string | undefined {\n return responseText ? /<Code>([A-Za-z0-9]{1,64})<\\/Code>/.exec(responseText)?.[1] : undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Retry driver\n// ---------------------------------------------------------------------------\n\nexport type R2AttemptResult<T> =\n | { kind: \"success\"; value: T }\n | { kind: \"response\"; response: Response };\n\n// Strip ASCII control characters (0x00-0x1F, 0x7F) and bound length. Done with a\n// code-point filter rather than a control-character regex (which `no-control-regex`\n// forbids and which would embed literal control bytes in source).\nfunction sanitizeBodyText(text: string): string {\n let out = \"\";\n for (const ch of text.slice(0, 1000)) {\n const code = ch.codePointAt(0) ?? 0;\n out += code < 0x20 || code === 0x7f ? \" \" : ch;\n }\n return out.trim().slice(0, 500);\n}\n\n// Bounded, sanitized read of a failed response body. Also drains the body so\n// the underlying socket can be reused before the next attempt. R2 error bodies\n// are short XML and never contain our credentials; sanitize defensively regardless.\nasync function safeReadBoundedBody(res: Response): Promise<string> {\n try {\n return sanitizeBodyText(await res.text());\n } catch {\n return \"<no body>\";\n }\n}\n\n/**\n * Drive up to `R2_REQUEST_MAX_ATTEMPTS` attempts of a single storage operation.\n * `runAttempt` builds a FRESH signed request each call and returns either a\n * success value or the raw `Response` for the driver to classify. Thrown errors\n * are classified: only recognized transient transport failures are retried.\n */\nexport async function fetchWithR2Retry<T>(params: {\n operation: R2RetryableOperation;\n bucket: string;\n key: string;\n seams: R2RetrySeams;\n runAttempt: (attemptNumber: number) => Promise<R2AttemptResult<T>>;\n}): Promise<T> {\n const { operation, bucket, key, seams, runAttempt } = params;\n\n for (let attempt = 1; attempt <= R2_REQUEST_MAX_ATTEMPTS; attempt++) {\n let result: R2AttemptResult<T>;\n try {\n result = await runAttempt(attempt);\n } catch (err) {\n // Signing failures, explicit aborts, and deterministic local-fs errors are\n // never retried; only recognized transient transport failures are.\n if (!isRetryableTransportError(err)) throw err;\n if (attempt >= R2_REQUEST_MAX_ATTEMPTS) {\n throw new R2StorageError({ operation, bucket, key, attempts: attempt, cause: err });\n }\n await seams.sleep(computeR2DelayMs({ response: null, retryIndex: attempt - 1, seams }));\n continue;\n }\n\n if (result.kind === \"success\") return result.value;\n\n const response = result.response;\n const status = response.status;\n // Consume the failed body before the next attempt.\n const responseText = await safeReadBoundedBody(response);\n\n if (R2_RETRYABLE_STATUSES.has(status) && attempt < R2_REQUEST_MAX_ATTEMPTS) {\n await seams.sleep(computeR2DelayMs({ response, retryIndex: attempt - 1, seams }));\n continue;\n }\n\n // Non-retryable status, or a retryable status with attempts exhausted.\n throw new R2StorageError({ operation, bucket, key, attempts: attempt, status, responseText });\n }\n\n // Unreachable: the loop always returns or throws.\n throw new R2StorageError({ operation, bucket, key, attempts: R2_REQUEST_MAX_ATTEMPTS });\n}\n"]}
|
|
1
|
+
{"version":3,"file":"r2-retry.js","sourceRoot":"","sources":["../../src/server/r2-retry.ts"],"names":[],"mappings":"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","sourcesContent":["export const R2_REQUEST_MAX_ATTEMPTS = 3;\nexport const R2_RETRY_BASE_DELAY_MS = 250;\nexport const R2_RETRY_MAX_BACKOFF_MS = 5_000;\nexport const R2_RETRY_AFTER_CAP_MS = 30_000;\n\nexport const R2_RETRYABLE_STATUSES: ReadonlySet<number> = new Set([408, 429, 500, 502, 503, 504]);\n\nexport type R2RetryableOperation = 'putFile' | 'copy' | 'readToFile' | 'deleteCopy';\n\nexport type R2Operation = R2RetryableOperation | 'delete';\n\nexport interface R2RetrySeams {\n sleep: (ms: number) => Promise<void>;\n random: () => number;\n now: () => number;\n}\n\nexport function defaultR2RetrySeams(): R2RetrySeams {\n return {\n sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),\n random: Math.random,\n now: Date.now,\n };\n}\n\n// undici/fetch surface these codes on the error or its `.cause`.\nconst RETRYABLE_NETWORK_ERROR_CODES: ReadonlySet<string> = new Set([\n 'ECONNRESET',\n 'ECONNREFUSED',\n 'ECONNABORTED',\n 'ETIMEDOUT',\n 'EPIPE',\n 'ENOTFOUND',\n 'EAI_AGAIN',\n 'ENETUNREACH',\n 'ENETDOWN',\n 'EHOSTUNREACH',\n 'EHOSTDOWN',\n 'UND_ERR_CONNECT_TIMEOUT',\n 'UND_ERR_HEADERS_TIMEOUT',\n 'UND_ERR_BODY_TIMEOUT',\n 'UND_ERR_SOCKET',\n]);\n\nconst LOCAL_FS_ERROR_CODES: ReadonlySet<string> = new Set([\n 'ENOSPC',\n 'EACCES',\n 'EISDIR',\n 'ENOENT',\n 'EROFS',\n 'EMFILE',\n 'ENFILE',\n 'EBADF',\n 'EEXIST',\n 'EPERM',\n 'ENAMETOOLONG',\n 'ELOOP',\n 'ENOTDIR',\n 'EDQUOT',\n]);\n\nfunction codeOf(err: unknown): string | undefined {\n if (err && typeof err === 'object') {\n const c = (err as { code?: unknown }).code;\n if (typeof c === 'string') return c;\n }\n return undefined;\n}\n\nexport function isRetryableTransportError(err: unknown): boolean {\n if (!err || typeof err !== 'object') return false;\n const e = err as { name?: unknown; cause?: unknown };\n\n if (e.name === 'AbortError') return false;\n\n const selfCode = codeOf(err);\n const causeCode = codeOf(e.cause);\n\n if (selfCode && LOCAL_FS_ERROR_CODES.has(selfCode)) return false;\n if (causeCode && LOCAL_FS_ERROR_CODES.has(causeCode)) return false;\n\n if (selfCode && RETRYABLE_NETWORK_ERROR_CODES.has(selfCode)) return true;\n if (causeCode && RETRYABLE_NETWORK_ERROR_CODES.has(causeCode)) return true;\n\n // fetch()/undici surface transport failures as `TypeError: fetch failed`, often with no recognizable cause.\n if (err instanceof TypeError || e.name === 'TypeError') return true;\n\n return false;\n}\n\nexport function parseRetryAfterMs(\n headerValue: string | null | undefined,\n nowMs: number,\n): number | null {\n if (headerValue == null) return null;\n const trimmed = headerValue.trim();\n if (trimmed === '') return null;\n\n if (/^\\d+$/.test(trimmed)) {\n const seconds = Number(trimmed);\n if (!Number.isFinite(seconds) || seconds < 0) return null;\n return seconds * 1000;\n }\n\n const dateMs = Date.parse(trimmed);\n if (Number.isNaN(dateMs)) return null;\n const delta = dateMs - nowMs;\n return delta > 0 ? delta : 0;\n}\n\nexport function computeR2DelayMs(params: {\n response: Response | null;\n retryIndex: number;\n seams: R2RetrySeams;\n}): number {\n const { response, retryIndex, seams } = params;\n\n if (response) {\n const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'), seams.now());\n if (retryAfterMs !== null) {\n return Math.min(retryAfterMs, R2_RETRY_AFTER_CAP_MS);\n }\n }\n\n const ceiling = Math.min(R2_RETRY_MAX_BACKOFF_MS, R2_RETRY_BASE_DELAY_MS * 2 ** retryIndex);\n return Math.floor(seams.random() * ceiling);\n}\n\nexport class R2StorageError extends Error {\n readonly operation: R2Operation;\n readonly bucket: string;\n readonly key: string;\n readonly attempts: number;\n readonly status?: number;\n readonly s3ErrorCode?: string;\n readonly responseText?: string;\n\n constructor(params: {\n operation: R2Operation;\n bucket: string;\n key: string;\n attempts: number;\n status?: number;\n responseText?: string;\n cause?: unknown;\n }) {\n const code = parseS3ErrorCode(params.responseText);\n const detail = [params.status !== undefined ? `status ${params.status}` : undefined, code]\n .filter(Boolean)\n .join(' ');\n super(\n `R2 ${params.operation} failed after ${params.attempts} attempt(s)${detail ? `: ${detail}` : ''}`,\n params.cause !== undefined ? { cause: params.cause } : undefined,\n );\n this.name = 'R2StorageError';\n this.operation = params.operation;\n this.bucket = params.bucket;\n this.key = params.key;\n this.attempts = params.attempts;\n this.status = params.status;\n this.s3ErrorCode = code;\n this.responseText = params.responseText;\n }\n}\n\nfunction parseS3ErrorCode(responseText: string | undefined): string | undefined {\n return responseText ? /<Code>([A-Za-z0-9]{1,64})<\\/Code>/.exec(responseText)?.[1] : undefined;\n}\n\nexport type R2AttemptResult<T> =\n { kind: 'success'; value: T } | { kind: 'response'; response: Response };\n\n// A code-point filter, not a control-character regex, which `no-control-regex` forbids.\nfunction sanitizeBodyText(text: string): string {\n let out = '';\n for (const ch of text.slice(0, 1000)) {\n const code = ch.codePointAt(0) ?? 0;\n out += code < 0x20 || code === 0x7f ? ' ' : ch;\n }\n return out.trim().slice(0, 500);\n}\n\n// Draining the body lets the socket be reused before the next attempt.\nasync function safeReadBoundedBody(res: Response): Promise<string> {\n try {\n return sanitizeBodyText(await res.text());\n } catch {\n return '<no body>';\n }\n}\n\nexport async function fetchWithR2Retry<T>(params: {\n operation: R2RetryableOperation;\n bucket: string;\n key: string;\n seams: R2RetrySeams;\n runAttempt: (attemptNumber: number) => Promise<R2AttemptResult<T>>;\n}): Promise<T> {\n const { operation, bucket, key, seams, runAttempt } = params;\n\n for (let attempt = 1; attempt <= R2_REQUEST_MAX_ATTEMPTS; attempt++) {\n let result: R2AttemptResult<T>;\n try {\n result = await runAttempt(attempt);\n } catch (err) {\n if (!isRetryableTransportError(err)) throw err;\n if (attempt >= R2_REQUEST_MAX_ATTEMPTS) {\n throw new R2StorageError({ operation, bucket, key, attempts: attempt, cause: err });\n }\n await seams.sleep(computeR2DelayMs({ response: null, retryIndex: attempt - 1, seams }));\n continue;\n }\n\n if (result.kind === 'success') return result.value;\n\n const response = result.response;\n const status = response.status;\n const responseText = await safeReadBoundedBody(response);\n\n if (R2_RETRYABLE_STATUSES.has(status) && attempt < R2_REQUEST_MAX_ATTEMPTS) {\n await seams.sleep(computeR2DelayMs({ response, retryIndex: attempt - 1, seams }));\n continue;\n }\n\n throw new R2StorageError({ operation, bucket, key, attempts: attempt, status, responseText });\n }\n\n throw new R2StorageError({ operation, bucket, key, attempts: R2_REQUEST_MAX_ATTEMPTS });\n}\n"]}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type { Bucket } from
|
|
2
|
-
/** The stored-object metadata fields these reads use (the Admin SDK returns more). */
|
|
1
|
+
import type { Bucket } from './storage-ops.js';
|
|
3
2
|
export interface StagedObjectMetadata {
|
|
4
3
|
contentType?: string;
|
|
5
4
|
size?: string | number;
|
|
@@ -12,43 +11,17 @@ export type StagedUploadMetadataRead = {
|
|
|
12
11
|
} | {
|
|
13
12
|
found: false;
|
|
14
13
|
};
|
|
15
|
-
/**
|
|
16
|
-
* Read a staged object's stored metadata (`contentType`, `size`, `generation`, …) for
|
|
17
|
-
* `verifyStagedUploadMetadata`. A missing object is `{ found: false }`; any other read
|
|
18
|
-
* failure throws.
|
|
19
|
-
*/
|
|
20
14
|
export declare function readStagedUploadMetadata(bucket: Bucket, storagePath: string): Promise<StagedUploadMetadataRead>;
|
|
21
|
-
/**
|
|
22
|
-
* The staged object's generation from metadata the caller already fetched, as a string
|
|
23
|
-
* (GCS generations exceed 2^53) — the one normalization `readStagedObjectGeneration`
|
|
24
|
-
* applies, for a processor that already holds the object's metadata and must not read
|
|
25
|
-
* it again. `undefined` when the metadata carries no generation (the emulator may not):
|
|
26
|
-
* absent, `null`, or an empty string, which names no generation.
|
|
27
|
-
*/
|
|
28
15
|
export declare function stagedObjectGenerationFromMetadata(metadata: {
|
|
29
16
|
generation?: string | number | null;
|
|
30
17
|
}): string | undefined;
|
|
31
|
-
|
|
32
|
-
export type StorageObjectMetadataField = "generation" | "size" | "etag" | "lastModified";
|
|
33
|
-
/**
|
|
34
|
-
* A storage object whose metadata lacks a field its backend always reports: a Cloud Storage
|
|
35
|
-
* generation or byte size (GCS, the Storage emulator, and a Storage finalize event carry
|
|
36
|
-
* both), or an R2 object's ETag, last-modified time, or size. This is an anomaly to surface,
|
|
37
|
-
* never to paper over with a placeholder. The bucket and key are PROPERTIES, never message
|
|
38
|
-
* text: an escaping error reaches monitoring, and an object key must not.
|
|
39
|
-
*/
|
|
18
|
+
export type StorageObjectMetadataField = 'generation' | 'size' | 'etag' | 'lastModified';
|
|
40
19
|
export declare class UnreadableStorageObjectMetadataError extends Error {
|
|
41
20
|
readonly bucket: string;
|
|
42
21
|
readonly key: string;
|
|
43
22
|
readonly field: StorageObjectMetadataField;
|
|
44
23
|
constructor(bucket: string, key: string, field: StorageObjectMetadataField);
|
|
45
24
|
}
|
|
46
|
-
/**
|
|
47
|
-
* The object's generation from metadata the caller already holds, for a site that cannot
|
|
48
|
-
* proceed without it (a hold key, an evidence record, a pinned read). Throws
|
|
49
|
-
* `UnreadableStorageObjectMetadataError` (field `generation`) when the metadata names none —
|
|
50
|
-
* the same normalization as `stagedObjectGenerationFromMetadata`, with no fallback value.
|
|
51
|
-
*/
|
|
52
25
|
export declare function requireStorageObjectGeneration(metadata: {
|
|
53
26
|
generation?: string | number | null;
|
|
54
27
|
}, object: {
|
|
@@ -56,25 +29,8 @@ export declare function requireStorageObjectGeneration(metadata: {
|
|
|
56
29
|
key: string;
|
|
57
30
|
}): string;
|
|
58
31
|
export interface ReadStagedObjectGenerationOptions {
|
|
59
|
-
/** Called when the generation cannot be read (the caller logs it); reads then stay unpinned. */
|
|
60
32
|
onReadFailure?: (error: unknown) => void;
|
|
61
33
|
}
|
|
62
|
-
/**
|
|
63
|
-
* The staged object's current generation, as a string (GCS generations exceed 2^53).
|
|
64
|
-
*
|
|
65
|
-
* This is the one value that pins every later read of the staged bytes — the processing
|
|
66
|
-
* download, a moderation scan, a hash — to the SAME immutable object, so "processed
|
|
67
|
-
* bytes == scanned bytes == hashed bytes" holds even if the staging path were
|
|
68
|
-
* overwritten (a pinned read resolves exactly the captured generation). Returns
|
|
69
|
-
* `undefined` when no path is given, the object reports no generation (the emulator may
|
|
70
|
-
* not), or the read fails — best effort: callers fall back to unpinned reads, and a
|
|
71
|
-
* create-only staging rule stays the primary defense.
|
|
72
|
-
*/
|
|
73
34
|
export declare function readStagedObjectGeneration(bucket: Bucket, storagePath: string | undefined, options?: ReadStagedObjectGenerationOptions): Promise<string | undefined>;
|
|
74
|
-
/**
|
|
75
|
-
* A `gs://` URI pinned to a captured generation (`gs://bucket/path#<generation>`), so a
|
|
76
|
-
* service reading by URI (a moderation scan) reads the same bytes the processor did.
|
|
77
|
-
* Without a generation it is the bare URI.
|
|
78
|
-
*/
|
|
79
35
|
export declare function pinnedGcsUri(gcsUri: string, generation: string | undefined): string;
|
|
80
36
|
//# sourceMappingURL=staged-object.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"staged-object.d.ts","sourceRoot":"","sources":["../../src/server/staged-object.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"staged-object.d.ts","sourceRoot":"","sources":["../../src/server/staged-object.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE/C,MAAM,WAAW,oBAAoB;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC7B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,MAAM,MAAM,wBAAwB,GAClC;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,oBAAoB,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;AAErE,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,wBAAwB,CAAC,CAQnC;AAED,wBAAgB,kCAAkC,CAAC,QAAQ,EAAE;IAC3D,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACrC,GAAG,MAAM,GAAG,SAAS,CAKrB;AAED,MAAM,MAAM,0BAA0B,GAAG,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,cAAc,CAAC;AAEzF,qBAAa,oCAAqC,SAAQ,KAAK;aAE3C,MAAM,EAAE,MAAM;aACd,GAAG,EAAE,MAAM;aACX,KAAK,EAAE,0BAA0B;gBAFjC,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,0BAA0B;CAKpD;AAED,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE;IAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;CAAE,EACjD,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACtC,MAAM,CAMR;AAED,MAAM,WAAW,iCAAiC;IAChD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC1C;AAED,wBAAsB,0BAA0B,CAC9C,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,GAAE,iCAAsC,GAC9C,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAS7B;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAEnF"}
|
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// processor reads from). The accept-time verdict over the metadata is the pure
|
|
3
|
-
// `verifyStagedUploadMetadata` in `@ttt-productions/media-schemas`.
|
|
4
|
-
import { isObjectNotFoundError } from "./gcs-errors.js";
|
|
5
|
-
/**
|
|
6
|
-
* Read a staged object's stored metadata (`contentType`, `size`, `generation`, …) for
|
|
7
|
-
* `verifyStagedUploadMetadata`. A missing object is `{ found: false }`; any other read
|
|
8
|
-
* failure throws.
|
|
9
|
-
*/
|
|
1
|
+
import { isObjectNotFoundError } from './gcs-errors.js';
|
|
10
2
|
export async function readStagedUploadMetadata(bucket, storagePath) {
|
|
11
3
|
try {
|
|
12
4
|
const [metadata] = await bucket.file(storagePath).getMetadata();
|
|
@@ -18,27 +10,13 @@ export async function readStagedUploadMetadata(bucket, storagePath) {
|
|
|
18
10
|
throw e;
|
|
19
11
|
}
|
|
20
12
|
}
|
|
21
|
-
/**
|
|
22
|
-
* The staged object's generation from metadata the caller already fetched, as a string
|
|
23
|
-
* (GCS generations exceed 2^53) — the one normalization `readStagedObjectGeneration`
|
|
24
|
-
* applies, for a processor that already holds the object's metadata and must not read
|
|
25
|
-
* it again. `undefined` when the metadata carries no generation (the emulator may not):
|
|
26
|
-
* absent, `null`, or an empty string, which names no generation.
|
|
27
|
-
*/
|
|
28
13
|
export function stagedObjectGenerationFromMetadata(metadata) {
|
|
29
14
|
const { generation } = metadata;
|
|
30
15
|
if (generation === undefined || generation === null)
|
|
31
16
|
return undefined;
|
|
32
17
|
const value = String(generation);
|
|
33
|
-
return value ===
|
|
18
|
+
return value === '' ? undefined : value;
|
|
34
19
|
}
|
|
35
|
-
/**
|
|
36
|
-
* A storage object whose metadata lacks a field its backend always reports: a Cloud Storage
|
|
37
|
-
* generation or byte size (GCS, the Storage emulator, and a Storage finalize event carry
|
|
38
|
-
* both), or an R2 object's ETag, last-modified time, or size. This is an anomaly to surface,
|
|
39
|
-
* never to paper over with a placeholder. The bucket and key are PROPERTIES, never message
|
|
40
|
-
* text: an escaping error reaches monitoring, and an object key must not.
|
|
41
|
-
*/
|
|
42
20
|
export class UnreadableStorageObjectMetadataError extends Error {
|
|
43
21
|
bucket;
|
|
44
22
|
key;
|
|
@@ -48,33 +26,16 @@ export class UnreadableStorageObjectMetadataError extends Error {
|
|
|
48
26
|
this.bucket = bucket;
|
|
49
27
|
this.key = key;
|
|
50
28
|
this.field = field;
|
|
51
|
-
this.name =
|
|
29
|
+
this.name = 'UnreadableStorageObjectMetadataError';
|
|
52
30
|
}
|
|
53
31
|
}
|
|
54
|
-
/**
|
|
55
|
-
* The object's generation from metadata the caller already holds, for a site that cannot
|
|
56
|
-
* proceed without it (a hold key, an evidence record, a pinned read). Throws
|
|
57
|
-
* `UnreadableStorageObjectMetadataError` (field `generation`) when the metadata names none —
|
|
58
|
-
* the same normalization as `stagedObjectGenerationFromMetadata`, with no fallback value.
|
|
59
|
-
*/
|
|
60
32
|
export function requireStorageObjectGeneration(metadata, object) {
|
|
61
33
|
const generation = stagedObjectGenerationFromMetadata(metadata);
|
|
62
34
|
if (generation === undefined) {
|
|
63
|
-
throw new UnreadableStorageObjectMetadataError(object.bucket, object.key,
|
|
35
|
+
throw new UnreadableStorageObjectMetadataError(object.bucket, object.key, 'generation');
|
|
64
36
|
}
|
|
65
37
|
return generation;
|
|
66
38
|
}
|
|
67
|
-
/**
|
|
68
|
-
* The staged object's current generation, as a string (GCS generations exceed 2^53).
|
|
69
|
-
*
|
|
70
|
-
* This is the one value that pins every later read of the staged bytes — the processing
|
|
71
|
-
* download, a moderation scan, a hash — to the SAME immutable object, so "processed
|
|
72
|
-
* bytes == scanned bytes == hashed bytes" holds even if the staging path were
|
|
73
|
-
* overwritten (a pinned read resolves exactly the captured generation). Returns
|
|
74
|
-
* `undefined` when no path is given, the object reports no generation (the emulator may
|
|
75
|
-
* not), or the read fails — best effort: callers fall back to unpinned reads, and a
|
|
76
|
-
* create-only staging rule stays the primary defense.
|
|
77
|
-
*/
|
|
78
39
|
export async function readStagedObjectGeneration(bucket, storagePath, options = {}) {
|
|
79
40
|
if (!storagePath)
|
|
80
41
|
return undefined;
|
|
@@ -87,11 +48,6 @@ export async function readStagedObjectGeneration(bucket, storagePath, options =
|
|
|
87
48
|
return undefined;
|
|
88
49
|
}
|
|
89
50
|
}
|
|
90
|
-
/**
|
|
91
|
-
* A `gs://` URI pinned to a captured generation (`gs://bucket/path#<generation>`), so a
|
|
92
|
-
* service reading by URI (a moderation scan) reads the same bytes the processor did.
|
|
93
|
-
* Without a generation it is the bare URI.
|
|
94
|
-
*/
|
|
95
51
|
export function pinnedGcsUri(gcsUri, generation) {
|
|
96
52
|
return generation ? `${gcsUri}#${generation}` : gcsUri;
|
|
97
53
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"staged-object.js","sourceRoot":"","sources":["../../src/server/staged-object.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"staged-object.js","sourceRoot":"","sources":["../../src/server/staged-object.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAaxD,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAC5C,MAAc,EACd,WAAmB;IAEnB,IAAI,CAAC;QACH,MAAM,CAAC,QAAQ,CAAC,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,WAAW,EAAE,CAAC;QAChE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IACnC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,IAAI,qBAAqB,CAAC,CAAC,CAAC;YAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;QACtD,MAAM,CAAC,CAAC;IACV,CAAC;AACH,CAAC;AAED,MAAM,UAAU,kCAAkC,CAAC,QAElD;IACC,MAAM,EAAE,UAAU,EAAE,GAAG,QAAQ,CAAC;IAChC,IAAI,UAAU,KAAK,SAAS,IAAI,UAAU,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IACtE,MAAM,KAAK,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;IACjC,OAAO,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC;AAC1C,CAAC;AAID,MAAM,OAAO,oCAAqC,SAAQ,KAAK;IAE3C;IACA;IACA;IAHlB,YACkB,MAAc,EACd,GAAW,EACX,KAAiC;QAEjD,KAAK,CAAC,2CAA2C,KAAK,EAAE,CAAC,CAAC;QAJ1C,WAAM,GAAN,MAAM,CAAQ;QACd,QAAG,GAAH,GAAG,CAAQ;QACX,UAAK,GAAL,KAAK,CAA4B;QAGjD,IAAI,CAAC,IAAI,GAAG,sCAAsC,CAAC;IACrD,CAAC;CACF;AAED,MAAM,UAAU,8BAA8B,CAC5C,QAAiD,EACjD,MAAuC;IAEvC,MAAM,UAAU,GAAG,kCAAkC,CAAC,QAAQ,CAAC,CAAC;IAChE,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;QAC7B,MAAM,IAAI,oCAAoC,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC;IAC1F,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAMD,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAC9C,MAAc,EACd,WAA+B,EAC/B,UAA6C,EAAE;IAE/C,IAAI,CAAC,WAAW;QAAE,OAAO,SAAS,CAAC;IACnC,IAAI,CAAC;QACH,MAAM,CAAC,QAAQ,CAAC,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,WAAW,EAAE,CAAC;QAChE,OAAO,kCAAkC,CAAC,QAAQ,CAAC,CAAC;IACtD,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC,CAAC;QAC3B,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,MAAc,EAAE,UAA8B;IACzE,OAAO,UAAU,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,UAAU,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;AACzD,CAAC","sourcesContent":["import { isObjectNotFoundError } from './gcs-errors.js';\nimport type { Bucket } from './storage-ops.js';\n\nexport interface StagedObjectMetadata {\n contentType?: string;\n size?: string | number;\n generation?: string | number;\n [field: string]: unknown;\n}\n\nexport type StagedUploadMetadataRead =\n { found: true; metadata: StagedObjectMetadata } | { found: false };\n\nexport async function readStagedUploadMetadata(\n bucket: Bucket,\n storagePath: string,\n): Promise<StagedUploadMetadataRead> {\n try {\n const [metadata] = await bucket.file(storagePath).getMetadata();\n return { found: true, metadata };\n } catch (e) {\n if (isObjectNotFoundError(e)) return { found: false };\n throw e;\n }\n}\n\nexport function stagedObjectGenerationFromMetadata(metadata: {\n generation?: string | number | null;\n}): string | undefined {\n const { generation } = metadata;\n if (generation === undefined || generation === null) return undefined;\n const value = String(generation);\n return value === '' ? undefined : value;\n}\n\nexport type StorageObjectMetadataField = 'generation' | 'size' | 'etag' | 'lastModified';\n\nexport class UnreadableStorageObjectMetadataError extends Error {\n constructor(\n public readonly bucket: string,\n public readonly key: string,\n public readonly field: StorageObjectMetadataField,\n ) {\n super(`storage object metadata has no readable ${field}`);\n this.name = 'UnreadableStorageObjectMetadataError';\n }\n}\n\nexport function requireStorageObjectGeneration(\n metadata: { generation?: string | number | null },\n object: { bucket: string; key: string },\n): string {\n const generation = stagedObjectGenerationFromMetadata(metadata);\n if (generation === undefined) {\n throw new UnreadableStorageObjectMetadataError(object.bucket, object.key, 'generation');\n }\n return generation;\n}\n\nexport interface ReadStagedObjectGenerationOptions {\n onReadFailure?: (error: unknown) => void;\n}\n\nexport async function readStagedObjectGeneration(\n bucket: Bucket,\n storagePath: string | undefined,\n options: ReadStagedObjectGenerationOptions = {},\n): Promise<string | undefined> {\n if (!storagePath) return undefined;\n try {\n const [metadata] = await bucket.file(storagePath).getMetadata();\n return stagedObjectGenerationFromMetadata(metadata);\n } catch (e) {\n options.onReadFailure?.(e);\n return undefined;\n }\n}\n\nexport function pinnedGcsUri(gcsUri: string, generation: string | undefined): string {\n return generation ? `${gcsUri}#${generation}` : gcsUri;\n}\n"]}
|