@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.
Files changed (141) hide show
  1. package/dist/audio/audio-processor.d.ts +2 -2
  2. package/dist/audio/audio-processor.d.ts.map +1 -1
  3. package/dist/audio/audio-processor.js +63 -50
  4. package/dist/audio/audio-processor.js.map +1 -1
  5. package/dist/audio/probe.d.ts +1 -1
  6. package/dist/audio/probe.d.ts.map +1 -1
  7. package/dist/audio/probe.js +6 -5
  8. package/dist/audio/probe.js.map +1 -1
  9. package/dist/duration/media-duration.d.ts +2 -13
  10. package/dist/duration/media-duration.d.ts.map +1 -1
  11. package/dist/duration/media-duration.js +24 -49
  12. package/dist/duration/media-duration.js.map +1 -1
  13. package/dist/image/formats.d.ts +2 -13
  14. package/dist/image/formats.d.ts.map +1 -1
  15. package/dist/image/formats.js +25 -35
  16. package/dist/image/formats.js.map +1 -1
  17. package/dist/image/image-processor.d.ts +2 -2
  18. package/dist/image/image-processor.d.ts.map +1 -1
  19. package/dist/image/image-processor.js +48 -57
  20. package/dist/image/image-processor.js.map +1 -1
  21. package/dist/image/resize.d.ts +2 -16
  22. package/dist/image/resize.d.ts.map +1 -1
  23. package/dist/image/resize.js +13 -28
  24. package/dist/image/resize.js.map +1 -1
  25. package/dist/index.d.ts +19 -16
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +18 -16
  28. package/dist/index.js.map +1 -1
  29. package/dist/inspection/inspect-media.d.ts +13 -20
  30. package/dist/inspection/inspect-media.d.ts.map +1 -1
  31. package/dist/inspection/inspect-media.js +197 -201
  32. package/dist/inspection/inspect-media.js.map +1 -1
  33. package/dist/io/fs.d.ts +1 -1
  34. package/dist/io/fs.d.ts.map +1 -1
  35. package/dist/io/fs.js +4 -5
  36. package/dist/io/fs.js.map +1 -1
  37. package/dist/io/local-input.d.ts +1 -21
  38. package/dist/io/local-input.d.ts.map +1 -1
  39. package/dist/io/local-input.js +15 -35
  40. package/dist/io/local-input.js.map +1 -1
  41. package/dist/io/types.d.ts +0 -3
  42. package/dist/io/types.d.ts.map +1 -1
  43. package/dist/io/types.js.map +1 -1
  44. package/dist/moderation/merge.d.ts +1 -1
  45. package/dist/moderation/merge.d.ts.map +1 -1
  46. package/dist/moderation/merge.js +1 -2
  47. package/dist/moderation/merge.js.map +1 -1
  48. package/dist/moderation/types.d.ts +3 -5
  49. package/dist/moderation/types.d.ts.map +1 -1
  50. package/dist/moderation/types.js.map +1 -1
  51. package/dist/process-media.d.ts +2 -3
  52. package/dist/process-media.d.ts.map +1 -1
  53. package/dist/process-media.js +9 -9
  54. package/dist/process-media.js.map +1 -1
  55. package/dist/processing/errors.d.ts.map +1 -1
  56. package/dist/processing/errors.js +1 -1
  57. package/dist/processing/errors.js.map +1 -1
  58. package/dist/processing/result.d.ts +2 -2
  59. package/dist/processing/result.js +1 -1
  60. package/dist/processing/result.js.map +1 -1
  61. package/dist/run-pipeline.d.ts +5 -22
  62. package/dist/run-pipeline.d.ts.map +1 -1
  63. package/dist/run-pipeline.js +72 -81
  64. package/dist/run-pipeline.js.map +1 -1
  65. package/dist/server/download-token.d.ts +0 -6
  66. package/dist/server/download-token.d.ts.map +1 -1
  67. package/dist/server/download-token.js +3 -13
  68. package/dist/server/download-token.js.map +1 -1
  69. package/dist/server/firebase-media-io.d.ts +3 -13
  70. package/dist/server/firebase-media-io.d.ts.map +1 -1
  71. package/dist/server/firebase-media-io.js +1 -6
  72. package/dist/server/firebase-media-io.js.map +1 -1
  73. package/dist/server/gcs-errors.d.ts +0 -8
  74. package/dist/server/gcs-errors.d.ts.map +1 -1
  75. package/dist/server/gcs-errors.js +2 -10
  76. package/dist/server/gcs-errors.js.map +1 -1
  77. package/dist/server/index.d.ts +7 -6
  78. package/dist/server/index.d.ts.map +1 -1
  79. package/dist/server/index.js +7 -6
  80. package/dist/server/index.js.map +1 -1
  81. package/dist/server/pending-media.d.ts +3 -34
  82. package/dist/server/pending-media.d.ts.map +1 -1
  83. package/dist/server/pending-media.js +13 -30
  84. package/dist/server/pending-media.js.map +1 -1
  85. package/dist/server/r2-retry.d.ts +4 -52
  86. package/dist/server/r2-retry.d.ts.map +1 -1
  87. package/dist/server/r2-retry.js +49 -134
  88. package/dist/server/r2-retry.js.map +1 -1
  89. package/dist/server/staged-object.d.ts +2 -46
  90. package/dist/server/staged-object.d.ts.map +1 -1
  91. package/dist/server/staged-object.js +4 -48
  92. package/dist/server/staged-object.js.map +1 -1
  93. package/dist/server/storage-ops.d.ts +5 -67
  94. package/dist/server/storage-ops.d.ts.map +1 -1
  95. package/dist/server/storage-ops.js +110 -185
  96. package/dist/server/storage-ops.js.map +1 -1
  97. package/dist/types.d.ts +2 -4
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js.map +1 -1
  100. package/dist/utils/log.d.ts.map +1 -1
  101. package/dist/utils/log.js +3 -3
  102. package/dist/utils/log.js.map +1 -1
  103. package/dist/utils/paths.d.ts.map +1 -1
  104. package/dist/utils/paths.js +1 -4
  105. package/dist/utils/paths.js.map +1 -1
  106. package/dist/utils/safe-path.d.ts.map +1 -1
  107. package/dist/utils/safe-path.js +8 -9
  108. package/dist/utils/safe-path.js.map +1 -1
  109. package/dist/validation/validate-duration.d.ts.map +1 -1
  110. package/dist/validation/validate-duration.js.map +1 -1
  111. package/dist/validation/validate-mime.d.ts.map +1 -1
  112. package/dist/validation/validate-mime.js.map +1 -1
  113. package/dist/validation/validate-size.d.ts.map +1 -1
  114. package/dist/validation/validate-size.js.map +1 -1
  115. package/dist/video/ffmpeg.d.ts +1 -23
  116. package/dist/video/ffmpeg.d.ts.map +1 -1
  117. package/dist/video/ffmpeg.js +57 -64
  118. package/dist/video/ffmpeg.js.map +1 -1
  119. package/dist/video/probe.d.ts +1 -1
  120. package/dist/video/probe.d.ts.map +1 -1
  121. package/dist/video/probe.js +10 -8
  122. package/dist/video/probe.js.map +1 -1
  123. package/dist/video/video-processor.d.ts +2 -2
  124. package/dist/video/video-processor.d.ts.map +1 -1
  125. package/dist/video/video-processor.js +125 -96
  126. package/dist/video/video-processor.js.map +1 -1
  127. package/dist/workspace/temp.js +4 -4
  128. package/dist/workspace/temp.js.map +1 -1
  129. package/package.json +2 -2
  130. package/dist/audio/index.d.ts +0 -3
  131. package/dist/audio/index.d.ts.map +0 -1
  132. package/dist/audio/index.js +0 -3
  133. package/dist/audio/index.js.map +0 -1
  134. package/dist/image/index.d.ts +0 -4
  135. package/dist/image/index.d.ts.map +0 -1
  136. package/dist/image/index.js +0 -4
  137. package/dist/image/index.js.map +0 -1
  138. package/dist/video/index.d.ts +0 -4
  139. package/dist/video/index.d.ts.map +0 -1
  140. package/dist/video/index.js +0 -4
  141. package/dist/video/index.js.map +0 -1
@@ -1,107 +1,66 @@
1
- // Final-media object-store chokepoint — every final-media write/copy/delete in
2
- // the system goes through a MediaObjectStore. Inline storage-provider calls for
3
- // FINAL media are forbidden outside this module. (Upload STAGING stays plain
4
- // Firebase Storage and is not this module's concern.)
5
- //
6
- // Implementations (the consuming app picks one per environment):
7
- // - R2 (S3-compatible) for deployed environments whose final media lives in
8
- // R2 and is served by an edge gateway.
9
- // - Firebase Storage for deployed environments whose final media lives in
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("copy destination already holds another object");
13
+ super('copy destination already holds another object');
41
14
  this.key = key;
42
- this.name = "ObjectAlreadyExistsError";
15
+ this.name = 'ObjectAlreadyExistsError';
43
16
  }
44
17
  }
45
- // Every copy records on its destination which source version it holds. A copy whose
46
- // destination already exists compares this record with its own, so it recognizes its
47
- // own earlier result and never mistakes another writer's object for it. The values are
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 === "string" &&
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
- "content-type": "contentType",
75
- "cache-control": "cacheControl",
76
- "content-disposition": "contentDisposition",
77
- "content-encoding": "contentEncoding",
37
+ 'content-type': 'contentType',
38
+ 'cache-control': 'cacheControl',
39
+ 'content-disposition': 'contentDisposition',
40
+ 'content-encoding': 'contentEncoding',
78
41
  };
79
- const DEFAULT_CACHE_CONTROL = "public, max-age=31536000, immutable";
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: "s3",
86
- region: "auto",
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("/").map(encodeURIComponent).join("/")}`;
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: "half" } : {}) });
61
+ return doFetch(signed, { ...(init.body ? { duplex: 'half' } : {}) });
102
62
  }
103
- // A GET whose body is discarded unread, not a HEAD: a HEAD's error response carries no
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: "GET" });
71
+ const res = await signedFetch(objectUrl(key), { method: 'GET' });
113
72
  if (!res.ok)
114
- return { kind: "response", response: res };
73
+ return { kind: 'response', response: res };
115
74
  await res.body?.cancel();
116
- return { kind: "success", value: res.headers };
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("etag");
81
+ const etag = headers.get('etag');
126
82
  if (!etag)
127
- throw new UnreadableStorageObjectMetadataError(args.bucket, key, "etag");
128
- const lastModified = headers.get("last-modified");
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, "lastModified");
131
- const size = headers.get("content-length");
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, "size");
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: "putFile",
97
+ operation: 'putFile',
144
98
  bucket: args.bucket,
145
99
  key,
146
100
  seams,
147
- // Fresh signed request AND a fresh read stream on every attempt — a
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: "PUT",
104
+ method: 'PUT',
152
105
  headers: {
153
- "content-type": ct,
154
- "content-length": String(size),
155
- "cache-control": DEFAULT_CACHE_CONTROL,
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: "success", value: { key, sizeBytes: size, contentType: ct } };
113
+ return { kind: 'success', value: { key, sizeBytes: size, contentType: ct } };
161
114
  }
162
- return { kind: "response", response: res };
115
+ return { kind: 'response', response: res };
163
116
  },
164
117
  });
165
118
  },
166
119
  async copy({ fromKey, toKey }) {
167
- const source = await readObjectHeaders(fromKey, "copy");
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
- "x-amz-copy-source": `/${args.bucket}/${fromKey.split("/").map(encodeURIComponent).join("/")}`,
172
- // Pins the copy to the source version the provenance names.
173
- "x-amz-copy-source-if-match": etag,
174
- // R2's destination-conditional CopyObject extension, checked when the copy commits;
175
- // S3's x-amz-copy-source-if-* headers condition only the source.
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: "copy",
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: "PUT", headers });
144
+ const res = await signedFetch(objectUrl(toKey), { method: 'PUT', headers });
196
145
  if (res.ok)
197
- return { kind: "success", value: undefined };
198
- return { kind: "response", response: res };
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, "copy");
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: "readToFile",
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: "GET" });
178
+ const res = await signedFetch(objectUrl(key), { method: 'GET' });
231
179
  if (!res.ok || !res.body) {
232
- return { kind: "response", response: res };
180
+ return { kind: 'response', response: res };
233
181
  }
234
- const contentType = res.headers.get("content-type") ?? NEUTRAL_CONTENT_TYPE;
235
- // Pipe ONLY after a successful response, and open the destination in
236
- // truncating mode ('w') on every attempt so a partial download from a
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: "success", value: { sizeBytes: size, contentType } };
186
+ return { kind: 'success', value: { sizeBytes: size, contentType } };
244
187
  },
245
188
  });
246
189
  },
247
190
  async delete(key) {
248
- // Intentionally one-shot: delete is never retried (see r2-retry.ts header).
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: "delete",
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, "deleteCopy");
207
+ destination = await readObjectHeaders(toKey, 'deleteCopy');
266
208
  }
267
209
  catch (e) {
268
210
  if (isObjectNotFoundError(e))
269
- return "absent";
211
+ return 'absent';
270
212
  throw e;
271
213
  }
272
214
  if (!recordsCopyOf((name) => destination.get(`x-amz-meta-${name}`), fromKey))
273
- return "notThisCopy";
274
- // Unconditioned: R2's DeleteObject honors no precondition header, so none is sent — one
275
- // it silently ignored would only look like protection. `MediaObjectStore.deleteCopy`
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 "deleted";
219
+ return 'deleted';
280
220
  const error = new R2StorageError({
281
- operation: "deleteCopy",
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 "absent";
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 "<no body>";
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 ? {} : { metadata: { [DOWNLOAD_TOKENS_METADATA_KEY]: downloadToken } }),
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, { bucket: bucket.name, key: fromKey });
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 === "string" && value !== "")
287
+ if (typeof value === 'string' && value !== '')
357
288
  contentHeaders[field] = value;
358
289
  }
359
290
  try {
360
- // A rewrite's resource REPLACES the destination's metadata rather than merging with
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 ? provenance : { ...provenance, [DOWNLOAD_TOKENS_METADATA_KEY]: downloadToken },
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 "absent";
341
+ return 'absent';
413
342
  throw e;
414
343
  }
415
344
  if (!recordsCopyOf((name) => custom?.[name], fromKey))
416
- return "notThisCopy";
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 "deleted";
349
+ return 'deleted';
422
350
  }
423
351
  catch (e) {
424
352
  if (isObjectNotFoundError(e))
425
- return "absent";
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(".").pop() || "";
365
+ return localPath.split('.').pop() || '';
441
366
  }
442
367
  export function getMimeFromExt(ext) {
443
368
  switch (ext.toLowerCase()) {
444
- case "jpg":
445
- case "jpeg":
446
- return "image/jpeg";
447
- case "png":
448
- return "image/png";
449
- case "webp":
450
- return "image/webp";
451
- case "mp4":
452
- return "video/mp4";
453
- case "webm":
454
- return "video/webm";
455
- case "m4a":
456
- return "audio/mp4";
457
- case "aac":
458
- return "audio/aac";
459
- case "mp3":
460
- return "audio/mpeg";
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
  }