@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
|
@@ -1,107 +1,66 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// Firebase Storage. Token-free: it writes and copies no download token, so
|
|
11
|
-
// its objects are readable only through the app's own access path (Storage
|
|
12
|
-
// rules). Firebase can still mint a token later (a client getDownloadURL, the
|
|
13
|
-
// console); the app's read path must never do so.
|
|
14
|
-
// - Firebase Storage emulator for local dev/tests — the Firebase Storage store
|
|
15
|
-
// plus a FIXED caller-supplied download token on every object, so
|
|
16
|
-
// deterministic emulator URLs work. Production NEVER mints download tokens.
|
|
17
|
-
//
|
|
18
|
-
// Writes return `{ key }` — never URLs. Display URLs are built at render time
|
|
19
|
-
// from asset refs (see the consuming app's media-asset-url helper).
|
|
20
|
-
import { createReadStream, createWriteStream } from "node:fs";
|
|
21
|
-
import { stat } from "node:fs/promises";
|
|
22
|
-
import { Readable } from "node:stream";
|
|
23
|
-
import { pipeline } from "node:stream/promises";
|
|
24
|
-
import { AwsClient } from "aws4fetch";
|
|
25
|
-
import { NEUTRAL_CONTENT_TYPE } from "@ttt-productions/media-schemas";
|
|
26
|
-
import { isObjectNotFoundError } from "./gcs-errors.js";
|
|
27
|
-
import { defaultR2RetrySeams, fetchWithR2Retry, R2StorageError, } from "./r2-retry.js";
|
|
28
|
-
import { requireStorageObjectGeneration, UnreadableStorageObjectMetadataError } from "./staged-object.js";
|
|
29
|
-
// Re-export the exhaustion error so callers can recognize an exhausted R2
|
|
30
|
-
// operation. The rest of the retry helper stays private to this module.
|
|
31
|
-
export { R2StorageError };
|
|
32
|
-
/**
|
|
33
|
-
* A copy found an object at its destination key that is not this copy's result, and
|
|
34
|
-
* wrote nothing; that object is untouched. The key is a PROPERTY, never message text, so
|
|
35
|
-
* an escaping error never carries an object key to monitoring.
|
|
36
|
-
*/
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { AwsClient } from 'aws4fetch';
|
|
6
|
+
import { NEUTRAL_CONTENT_TYPE } from '@ttt-productions/media-schemas';
|
|
7
|
+
import { isObjectNotFoundError } from './gcs-errors.js';
|
|
8
|
+
import { defaultR2RetrySeams, fetchWithR2Retry, R2StorageError, } from './r2-retry.js';
|
|
9
|
+
import { requireStorageObjectGeneration, UnreadableStorageObjectMetadataError, } from './staged-object.js';
|
|
37
10
|
export class ObjectAlreadyExistsError extends Error {
|
|
38
11
|
key;
|
|
39
12
|
constructor(key) {
|
|
40
|
-
super(
|
|
13
|
+
super('copy destination already holds another object');
|
|
41
14
|
this.key = key;
|
|
42
|
-
this.name =
|
|
15
|
+
this.name = 'ObjectAlreadyExistsError';
|
|
43
16
|
}
|
|
44
17
|
}
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// URI-encoded because R2 carries custom metadata in HTTP headers, which must be ASCII.
|
|
49
|
-
const COPY_SOURCE_METADATA_KEY = "copy-source";
|
|
50
|
-
const COPY_SOURCE_VERSION_METADATA_KEY = "copy-source-version";
|
|
18
|
+
// URI-encoded: R2 carries custom metadata in HTTP headers, which must be ASCII.
|
|
19
|
+
const COPY_SOURCE_METADATA_KEY = 'copy-source';
|
|
20
|
+
const COPY_SOURCE_VERSION_METADATA_KEY = 'copy-source-version';
|
|
51
21
|
function copyProvenance(fromKey, sourceVersion) {
|
|
52
22
|
return {
|
|
53
23
|
[COPY_SOURCE_METADATA_KEY]: encodeURIComponent(fromKey),
|
|
54
24
|
[COPY_SOURCE_VERSION_METADATA_KEY]: encodeURIComponent(sourceVersion),
|
|
55
25
|
};
|
|
56
26
|
}
|
|
57
|
-
/** Whether an object's custom metadata, read through `readMetadata`, names exactly this provenance. */
|
|
58
27
|
function holdsProvenance(readMetadata, provenance) {
|
|
59
28
|
return Object.entries(provenance).every(([name, value]) => readMetadata(name) === value);
|
|
60
29
|
}
|
|
61
|
-
/**
|
|
62
|
-
* Whether an object's custom metadata records a copy of `fromKey`, of whichever source
|
|
63
|
-
* version: `deleteCopy` reads only the destination, so it cannot know the source's version.
|
|
64
|
-
* A record naming the source without a version is not provenance this package wrote.
|
|
65
|
-
*/
|
|
66
30
|
function recordsCopyOf(readMetadata, fromKey) {
|
|
67
31
|
const version = readMetadata(COPY_SOURCE_VERSION_METADATA_KEY);
|
|
68
32
|
return (readMetadata(COPY_SOURCE_METADATA_KEY) === encodeURIComponent(fromKey) &&
|
|
69
|
-
typeof version ===
|
|
70
|
-
version !==
|
|
33
|
+
typeof version === 'string' &&
|
|
34
|
+
version !== '');
|
|
71
35
|
}
|
|
72
|
-
/** The content headers a copy carries from its source: HTTP header name → Cloud Storage field. */
|
|
73
36
|
const CARRIED_CONTENT_HEADERS = {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
37
|
+
'content-type': 'contentType',
|
|
38
|
+
'cache-control': 'cacheControl',
|
|
39
|
+
'content-disposition': 'contentDisposition',
|
|
40
|
+
'content-encoding': 'contentEncoding',
|
|
78
41
|
};
|
|
79
|
-
const DEFAULT_CACHE_CONTROL =
|
|
42
|
+
const DEFAULT_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
80
43
|
export function createR2ObjectStore(args) {
|
|
81
|
-
const endpoint = (args.endpoint ?? `https://${args.accountId}.r2.cloudflarestorage.com`).replace(/\/$/,
|
|
44
|
+
const endpoint = (args.endpoint ?? `https://${args.accountId}.r2.cloudflarestorage.com`).replace(/\/$/, '');
|
|
82
45
|
const client = new AwsClient({
|
|
83
46
|
accessKeyId: args.accessKeyId,
|
|
84
47
|
secretAccessKey: args.secretAccessKey,
|
|
85
|
-
service:
|
|
86
|
-
region:
|
|
48
|
+
service: 's3',
|
|
49
|
+
region: 'auto',
|
|
87
50
|
});
|
|
88
51
|
const doFetch = args.fetchImpl ?? fetch;
|
|
89
|
-
// Retry seams: production defaults are internal; tests inject deterministic
|
|
90
|
-
// sleep/random/now. putFile/copy/readToFile ride the bounded retry helper;
|
|
91
|
-
// delete stays one-shot (never routes through it — see below).
|
|
92
52
|
const defaults = defaultR2RetrySeams();
|
|
93
53
|
const seams = {
|
|
94
54
|
sleep: args.sleepImpl ?? defaults.sleep,
|
|
95
55
|
random: args.randomImpl ?? defaults.random,
|
|
96
56
|
now: args.nowImpl ?? defaults.now,
|
|
97
57
|
};
|
|
98
|
-
const objectUrl = (key) => `${endpoint}/${args.bucket}/${key.split(
|
|
58
|
+
const objectUrl = (key) => `${endpoint}/${args.bucket}/${key.split('/').map(encodeURIComponent).join('/')}`;
|
|
99
59
|
async function signedFetch(url, init) {
|
|
100
60
|
const signed = await client.sign(url, { ...init, aws: { allHeaders: true } });
|
|
101
|
-
return doFetch(signed, { ...(init.body ? { duplex:
|
|
61
|
+
return doFetch(signed, { ...(init.body ? { duplex: 'half' } : {}) });
|
|
102
62
|
}
|
|
103
|
-
// A GET
|
|
104
|
-
// body, so the S3 error code that tells a missing key from a missing bucket would be lost.
|
|
63
|
+
// A GET, not a HEAD: a HEAD's error response has no body, so the S3 error code would be lost.
|
|
105
64
|
async function readObjectHeaders(key, operation) {
|
|
106
65
|
return fetchWithR2Retry({
|
|
107
66
|
operation,
|
|
@@ -109,74 +68,64 @@ export function createR2ObjectStore(args) {
|
|
|
109
68
|
key,
|
|
110
69
|
seams,
|
|
111
70
|
runAttempt: async () => {
|
|
112
|
-
const res = await signedFetch(objectUrl(key), { method:
|
|
71
|
+
const res = await signedFetch(objectUrl(key), { method: 'GET' });
|
|
113
72
|
if (!res.ok)
|
|
114
|
-
return { kind:
|
|
73
|
+
return { kind: 'response', response: res };
|
|
115
74
|
await res.body?.cancel();
|
|
116
|
-
return { kind:
|
|
75
|
+
return { kind: 'success', value: res.headers };
|
|
117
76
|
},
|
|
118
77
|
});
|
|
119
78
|
}
|
|
120
|
-
// R2 exposes no object version id, so a source version is its ETag, last-modified time,
|
|
121
|
-
// and size together: replacing the source changes at least one of them unless the new
|
|
122
|
-
// content has the same MD5-derived ETag and lands within the same second. A multipart
|
|
123
|
-
// upload's ETag is not the content's MD5, but it is the same kind of version token.
|
|
79
|
+
// R2 exposes no object version id, so a source version is its ETag, last-modified time, and size together.
|
|
124
80
|
function readSourceVersion(headers, key) {
|
|
125
|
-
const etag = headers.get(
|
|
81
|
+
const etag = headers.get('etag');
|
|
126
82
|
if (!etag)
|
|
127
|
-
throw new UnreadableStorageObjectMetadataError(args.bucket, key,
|
|
128
|
-
const lastModified = headers.get(
|
|
83
|
+
throw new UnreadableStorageObjectMetadataError(args.bucket, key, 'etag');
|
|
84
|
+
const lastModified = headers.get('last-modified');
|
|
129
85
|
if (!lastModified)
|
|
130
|
-
throw new UnreadableStorageObjectMetadataError(args.bucket, key,
|
|
131
|
-
const size = headers.get(
|
|
86
|
+
throw new UnreadableStorageObjectMetadataError(args.bucket, key, 'lastModified');
|
|
87
|
+
const size = headers.get('content-length');
|
|
132
88
|
if (!size)
|
|
133
|
-
throw new UnreadableStorageObjectMetadataError(args.bucket, key,
|
|
89
|
+
throw new UnreadableStorageObjectMetadataError(args.bucket, key, 'size');
|
|
134
90
|
return { etag, version: `${etag} ${lastModified} ${size}` };
|
|
135
91
|
}
|
|
136
92
|
return {
|
|
137
93
|
async putFile({ localPath, key, contentType }) {
|
|
138
|
-
// stat() once, outside the retry loop: a missing input file is a
|
|
139
|
-
// deterministic local error and must fail immediately, never retry.
|
|
140
94
|
const { size } = await stat(localPath);
|
|
141
95
|
const ct = contentType ?? getMimeFromExt(extractExt(localPath));
|
|
142
96
|
return fetchWithR2Retry({
|
|
143
|
-
operation:
|
|
97
|
+
operation: 'putFile',
|
|
144
98
|
bucket: args.bucket,
|
|
145
99
|
key,
|
|
146
100
|
seams,
|
|
147
|
-
//
|
|
148
|
-
// consumed stream is never reused.
|
|
101
|
+
// A fresh signed request and read stream on every attempt: a consumed stream cannot be re-read.
|
|
149
102
|
runAttempt: async () => {
|
|
150
103
|
const res = await signedFetch(objectUrl(key), {
|
|
151
|
-
method:
|
|
104
|
+
method: 'PUT',
|
|
152
105
|
headers: {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
106
|
+
'content-type': ct,
|
|
107
|
+
'content-length': String(size),
|
|
108
|
+
'cache-control': DEFAULT_CACHE_CONTROL,
|
|
156
109
|
},
|
|
157
110
|
body: createReadStream(localPath),
|
|
158
111
|
});
|
|
159
112
|
if (res.ok) {
|
|
160
|
-
return { kind:
|
|
113
|
+
return { kind: 'success', value: { key, sizeBytes: size, contentType: ct } };
|
|
161
114
|
}
|
|
162
|
-
return { kind:
|
|
115
|
+
return { kind: 'response', response: res };
|
|
163
116
|
},
|
|
164
117
|
});
|
|
165
118
|
},
|
|
166
119
|
async copy({ fromKey, toKey }) {
|
|
167
|
-
const source = await readObjectHeaders(fromKey,
|
|
120
|
+
const source = await readObjectHeaders(fromKey, 'copy');
|
|
168
121
|
const { etag, version } = readSourceVersion(source, fromKey);
|
|
169
122
|
const provenance = copyProvenance(fromKey, version);
|
|
170
123
|
const headers = {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"cf-copy-destination-if-none-match": "*",
|
|
177
|
-
// The destination takes exactly the headers below rather than the source's, so it
|
|
178
|
-
// commits fully formed in this one call.
|
|
179
|
-
"x-amz-metadata-directive": "REPLACE",
|
|
124
|
+
'x-amz-copy-source': `/${args.bucket}/${fromKey.split('/').map(encodeURIComponent).join('/')}`,
|
|
125
|
+
'x-amz-copy-source-if-match': etag,
|
|
126
|
+
// R2's destination-conditional CopyObject extension; S3's x-amz-copy-source-if-* headers condition only the source.
|
|
127
|
+
'cf-copy-destination-if-none-match': '*',
|
|
128
|
+
'x-amz-metadata-directive': 'REPLACE',
|
|
180
129
|
};
|
|
181
130
|
for (const name of Object.keys(CARRIED_CONTENT_HEADERS)) {
|
|
182
131
|
const value = source.get(name);
|
|
@@ -187,15 +136,15 @@ export function createR2ObjectStore(args) {
|
|
|
187
136
|
headers[`x-amz-meta-${name}`] = value;
|
|
188
137
|
try {
|
|
189
138
|
await fetchWithR2Retry({
|
|
190
|
-
operation:
|
|
139
|
+
operation: 'copy',
|
|
191
140
|
bucket: args.bucket,
|
|
192
141
|
key: toKey,
|
|
193
142
|
seams,
|
|
194
143
|
runAttempt: async () => {
|
|
195
|
-
const res = await signedFetch(objectUrl(toKey), { method:
|
|
144
|
+
const res = await signedFetch(objectUrl(toKey), { method: 'PUT', headers });
|
|
196
145
|
if (res.ok)
|
|
197
|
-
return { kind:
|
|
198
|
-
return { kind:
|
|
146
|
+
return { kind: 'success', value: undefined };
|
|
147
|
+
return { kind: 'response', response: res };
|
|
199
148
|
},
|
|
200
149
|
});
|
|
201
150
|
return { key: toKey, alreadyPresent: false };
|
|
@@ -203,11 +152,10 @@ export function createR2ObjectStore(args) {
|
|
|
203
152
|
catch (e) {
|
|
204
153
|
if (!(e instanceof R2StorageError && e.status === 412))
|
|
205
154
|
throw e;
|
|
206
|
-
// R2 answers 412 both for an existing destination and for a source that changed
|
|
207
|
-
// after it was read; only an existing destination can be this copy's result.
|
|
155
|
+
// R2 answers 412 both for an existing destination and for a source that changed after it was read.
|
|
208
156
|
let destination;
|
|
209
157
|
try {
|
|
210
|
-
destination = await readObjectHeaders(toKey,
|
|
158
|
+
destination = await readObjectHeaders(toKey, 'copy');
|
|
211
159
|
}
|
|
212
160
|
catch (readError) {
|
|
213
161
|
if (isObjectNotFoundError(readError))
|
|
@@ -222,35 +170,29 @@ export function createR2ObjectStore(args) {
|
|
|
222
170
|
},
|
|
223
171
|
async readToFile({ key, localPath }) {
|
|
224
172
|
return fetchWithR2Retry({
|
|
225
|
-
operation:
|
|
173
|
+
operation: 'readToFile',
|
|
226
174
|
bucket: args.bucket,
|
|
227
175
|
key,
|
|
228
176
|
seams,
|
|
229
177
|
runAttempt: async () => {
|
|
230
|
-
const res = await signedFetch(objectUrl(key), { method:
|
|
178
|
+
const res = await signedFetch(objectUrl(key), { method: 'GET' });
|
|
231
179
|
if (!res.ok || !res.body) {
|
|
232
|
-
return { kind:
|
|
180
|
+
return { kind: 'response', response: res };
|
|
233
181
|
}
|
|
234
|
-
const contentType = res.headers.get(
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
// prior attempt can never prefix/corrupt this one. res.body is a web
|
|
238
|
-
// ReadableStream; stream it to disk so large media never sits fully in
|
|
239
|
-
// memory. A transient body/network stream failure here throws and is
|
|
240
|
-
// retried by the helper; a deterministic local fs error is not.
|
|
241
|
-
await pipeline(Readable.fromWeb(res.body), createWriteStream(localPath, { flags: "w" }));
|
|
182
|
+
const contentType = res.headers.get('content-type') ?? NEUTRAL_CONTENT_TYPE;
|
|
183
|
+
// Truncating mode ('w') on every attempt, so a partial earlier download never prefixes this one.
|
|
184
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(localPath, { flags: 'w' }));
|
|
242
185
|
const { size } = await stat(localPath);
|
|
243
|
-
return { kind:
|
|
186
|
+
return { kind: 'success', value: { sizeBytes: size, contentType } };
|
|
244
187
|
},
|
|
245
188
|
});
|
|
246
189
|
},
|
|
247
190
|
async delete(key) {
|
|
248
|
-
|
|
249
|
-
const res = await signedFetch(objectUrl(key), { method: "DELETE" });
|
|
191
|
+
const res = await signedFetch(objectUrl(key), { method: 'DELETE' });
|
|
250
192
|
// S3 DELETE returns 204 even for missing keys; anything else non-ok is real.
|
|
251
193
|
if (!res.ok && res.status !== 404) {
|
|
252
194
|
throw new R2StorageError({
|
|
253
|
-
operation:
|
|
195
|
+
operation: 'delete',
|
|
254
196
|
bucket: args.bucket,
|
|
255
197
|
key,
|
|
256
198
|
attempts: 1,
|
|
@@ -262,23 +204,21 @@ export function createR2ObjectStore(args) {
|
|
|
262
204
|
async deleteCopy({ fromKey, toKey }) {
|
|
263
205
|
let destination;
|
|
264
206
|
try {
|
|
265
|
-
destination = await readObjectHeaders(toKey,
|
|
207
|
+
destination = await readObjectHeaders(toKey, 'deleteCopy');
|
|
266
208
|
}
|
|
267
209
|
catch (e) {
|
|
268
210
|
if (isObjectNotFoundError(e))
|
|
269
|
-
return
|
|
211
|
+
return 'absent';
|
|
270
212
|
throw e;
|
|
271
213
|
}
|
|
272
214
|
if (!recordsCopyOf((name) => destination.get(`x-amz-meta-${name}`), fromKey))
|
|
273
|
-
return
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
// states why the gap after the check is harmless. One-shot, like `delete`.
|
|
277
|
-
const res = await signedFetch(objectUrl(toKey), { method: "DELETE" });
|
|
215
|
+
return 'notThisCopy';
|
|
216
|
+
// R2's DeleteObject honors no precondition header, so none is sent.
|
|
217
|
+
const res = await signedFetch(objectUrl(toKey), { method: 'DELETE' });
|
|
278
218
|
if (res.ok)
|
|
279
|
-
return
|
|
219
|
+
return 'deleted';
|
|
280
220
|
const error = new R2StorageError({
|
|
281
|
-
operation:
|
|
221
|
+
operation: 'deleteCopy',
|
|
282
222
|
bucket: args.bucket,
|
|
283
223
|
key: toKey,
|
|
284
224
|
attempts: 1,
|
|
@@ -286,7 +226,7 @@ export function createR2ObjectStore(args) {
|
|
|
286
226
|
responseText: await safeText(res),
|
|
287
227
|
});
|
|
288
228
|
if (isObjectNotFoundError(error))
|
|
289
|
-
return
|
|
229
|
+
return 'absent';
|
|
290
230
|
throw error;
|
|
291
231
|
},
|
|
292
232
|
};
|
|
@@ -296,29 +236,17 @@ async function safeText(res) {
|
|
|
296
236
|
return (await res.text()).slice(0, 500);
|
|
297
237
|
}
|
|
298
238
|
catch {
|
|
299
|
-
return
|
|
239
|
+
return '<no body>';
|
|
300
240
|
}
|
|
301
241
|
}
|
|
302
|
-
|
|
303
|
-
// Firebase Storage stores — deployed (token-free) and emulator (fixed token)
|
|
304
|
-
// ---------------------------------------------------------------------------
|
|
305
|
-
/** Custom-metadata key Firebase Storage reads download tokens from. */
|
|
306
|
-
const DOWNLOAD_TOKENS_METADATA_KEY = "firebaseStorageDownloadTokens";
|
|
307
|
-
/**
|
|
308
|
-
* How many times `deleteCopy` checks a Firebase Storage destination before giving up. Keys
|
|
309
|
-
* are write-once, so one change between the check and the delete is already rare; an object
|
|
310
|
-
* still changing after that is left alone and the precondition failure is rethrown.
|
|
311
|
-
*/
|
|
242
|
+
const DOWNLOAD_TOKENS_METADATA_KEY = 'firebaseStorageDownloadTokens';
|
|
312
243
|
const DELETE_COPY_MAX_READS = 2;
|
|
313
|
-
/** Firebase Storage store for deployed environments. Token-free (see header). */
|
|
314
244
|
export function createFirebaseStorageObjectStore(args) {
|
|
315
245
|
return createFirebaseStore(args.bucket, null);
|
|
316
246
|
}
|
|
317
|
-
/** Local dev / tests only: the Firebase Storage store plus a fixed download token. */
|
|
318
247
|
export function createFirebaseEmulatorObjectStore(args) {
|
|
319
248
|
return createFirebaseStore(args.bucket, args.downloadToken);
|
|
320
249
|
}
|
|
321
|
-
/** `downloadToken` is the store's token policy: `null` = token-free, a string = that token on every object. */
|
|
322
250
|
function createFirebaseStore(bucket, downloadToken) {
|
|
323
251
|
async function deleteObject(key) {
|
|
324
252
|
try {
|
|
@@ -334,37 +262,38 @@ function createFirebaseStore(bucket, downloadToken) {
|
|
|
334
262
|
async putFile({ localPath, key, contentType }) {
|
|
335
263
|
const { size } = await stat(localPath);
|
|
336
264
|
const ct = contentType ?? getMimeFromExt(extractExt(localPath));
|
|
337
|
-
// An upload replaces the object and all of its metadata, so a token-free
|
|
338
|
-
// put carries no custom metadata at all.
|
|
339
265
|
await bucket.upload(localPath, {
|
|
340
266
|
destination: key,
|
|
341
267
|
metadata: {
|
|
342
268
|
contentType: ct,
|
|
343
269
|
cacheControl: DEFAULT_CACHE_CONTROL,
|
|
344
|
-
...(downloadToken === null
|
|
270
|
+
...(downloadToken === null
|
|
271
|
+
? {}
|
|
272
|
+
: { metadata: { [DOWNLOAD_TOKENS_METADATA_KEY]: downloadToken } }),
|
|
345
273
|
},
|
|
346
274
|
});
|
|
347
275
|
return { key, sizeBytes: size, contentType: ct };
|
|
348
276
|
},
|
|
349
277
|
async copy({ fromKey, toKey }) {
|
|
350
278
|
const [source] = await bucket.file(fromKey).getMetadata();
|
|
351
|
-
const generation = requireStorageObjectGeneration(source, {
|
|
279
|
+
const generation = requireStorageObjectGeneration(source, {
|
|
280
|
+
bucket: bucket.name,
|
|
281
|
+
key: fromKey,
|
|
282
|
+
});
|
|
352
283
|
const provenance = copyProvenance(fromKey, generation);
|
|
353
284
|
const contentHeaders = {};
|
|
354
285
|
for (const field of Object.values(CARRIED_CONTENT_HEADERS)) {
|
|
355
286
|
const value = source[field];
|
|
356
|
-
if (typeof value ===
|
|
287
|
+
if (typeof value === 'string' && value !== '')
|
|
357
288
|
contentHeaders[field] = value;
|
|
358
289
|
}
|
|
359
290
|
try {
|
|
360
|
-
// A rewrite's resource
|
|
361
|
-
// the source's, so this one call commits the destination fully formed: the source's
|
|
362
|
-
// content headers, the provenance, and this store's token policy — never a token the
|
|
363
|
-
// source carried. The source is pinned to the generation the provenance names, so a
|
|
364
|
-
// 412 can only mean the destination exists; a replaced source fails as not-found.
|
|
291
|
+
// A rewrite's resource replaces the destination's metadata rather than merging with the source's.
|
|
365
292
|
await bucket.file(fromKey, { generation }).copy(bucket.file(toKey), {
|
|
366
293
|
...contentHeaders,
|
|
367
|
-
metadata: downloadToken === null
|
|
294
|
+
metadata: downloadToken === null
|
|
295
|
+
? provenance
|
|
296
|
+
: { ...provenance, [DOWNLOAD_TOKENS_METADATA_KEY]: downloadToken },
|
|
368
297
|
preconditionOpts: { ifGenerationMatch: 0 },
|
|
369
298
|
});
|
|
370
299
|
return { key: toKey, alreadyPresent: false };
|
|
@@ -409,21 +338,19 @@ function createFirebaseStore(bucket, downloadToken) {
|
|
|
409
338
|
}
|
|
410
339
|
catch (e) {
|
|
411
340
|
if (isObjectNotFoundError(e))
|
|
412
|
-
return
|
|
341
|
+
return 'absent';
|
|
413
342
|
throw e;
|
|
414
343
|
}
|
|
415
344
|
if (!recordsCopyOf((name) => custom?.[name], fromKey))
|
|
416
|
-
return
|
|
417
|
-
// The delete names the generation just checked, so it can only remove that object.
|
|
345
|
+
return 'notThisCopy';
|
|
418
346
|
const checked = requireStorageObjectGeneration({ generation }, { bucket: bucket.name, key: toKey });
|
|
419
347
|
try {
|
|
420
348
|
await file.delete({ ifGenerationMatch: checked });
|
|
421
|
-
return
|
|
349
|
+
return 'deleted';
|
|
422
350
|
}
|
|
423
351
|
catch (e) {
|
|
424
352
|
if (isObjectNotFoundError(e))
|
|
425
|
-
return
|
|
426
|
-
// A 412 means the object changed after it was checked: check what is there now.
|
|
353
|
+
return 'absent';
|
|
427
354
|
if (!isPreconditionFailedError(e) || read >= DELETE_COPY_MAX_READS)
|
|
428
355
|
throw e;
|
|
429
356
|
}
|
|
@@ -431,33 +358,31 @@ function createFirebaseStore(bucket, downloadToken) {
|
|
|
431
358
|
},
|
|
432
359
|
};
|
|
433
360
|
}
|
|
434
|
-
/** The Cloud Storage Admin SDK's numeric `412`: a generation precondition did not hold. */
|
|
435
361
|
function isPreconditionFailedError(e) {
|
|
436
362
|
return e?.code === 412;
|
|
437
363
|
}
|
|
438
|
-
// ---------------------------------------------------------------------------
|
|
439
364
|
function extractExt(localPath) {
|
|
440
|
-
return localPath.split(
|
|
365
|
+
return localPath.split('.').pop() || '';
|
|
441
366
|
}
|
|
442
367
|
export function getMimeFromExt(ext) {
|
|
443
368
|
switch (ext.toLowerCase()) {
|
|
444
|
-
case
|
|
445
|
-
case
|
|
446
|
-
return
|
|
447
|
-
case
|
|
448
|
-
return
|
|
449
|
-
case
|
|
450
|
-
return
|
|
451
|
-
case
|
|
452
|
-
return
|
|
453
|
-
case
|
|
454
|
-
return
|
|
455
|
-
case
|
|
456
|
-
return
|
|
457
|
-
case
|
|
458
|
-
return
|
|
459
|
-
case
|
|
460
|
-
return
|
|
369
|
+
case 'jpg':
|
|
370
|
+
case 'jpeg':
|
|
371
|
+
return 'image/jpeg';
|
|
372
|
+
case 'png':
|
|
373
|
+
return 'image/png';
|
|
374
|
+
case 'webp':
|
|
375
|
+
return 'image/webp';
|
|
376
|
+
case 'mp4':
|
|
377
|
+
return 'video/mp4';
|
|
378
|
+
case 'webm':
|
|
379
|
+
return 'video/webm';
|
|
380
|
+
case 'm4a':
|
|
381
|
+
return 'audio/mp4';
|
|
382
|
+
case 'aac':
|
|
383
|
+
return 'audio/aac';
|
|
384
|
+
case 'mp3':
|
|
385
|
+
return 'audio/mpeg';
|
|
461
386
|
default:
|
|
462
387
|
return NEUTRAL_CONTENT_TYPE;
|
|
463
388
|
}
|