@veryfront/ext-blob-s3 0.1.1184
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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +139 -0
- package/esm/_dnt.polyfills.d.ts +12 -0
- package/esm/_dnt.polyfills.d.ts.map +1 -0
- package/esm/_dnt.polyfills.js +15 -0
- package/esm/_dnt.shims.d.ts +11 -0
- package/esm/_dnt.shims.d.ts.map +1 -0
- package/esm/_dnt.shims.js +68 -0
- package/esm/index.d.ts +15 -0
- package/esm/index.d.ts.map +1 -0
- package/esm/index.js +99 -0
- package/esm/multipart-upload.d.ts +23 -0
- package/esm/multipart-upload.d.ts.map +1 -0
- package/esm/multipart-upload.js +159 -0
- package/esm/package.json +3 -0
- package/esm/s3-storage.d.ts +82 -0
- package/esm/s3-storage.d.ts.map +1 -0
- package/esm/s3-storage.js +694 -0
- package/package.json +86 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import * as dntShim from "./_dnt.shims.js";
|
|
2
|
+
import { CreateBucketCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { API_ERROR, CONFIG_INVALID, INITIALIZATION_ERROR, INVALID_ARGUMENT, } from "veryfront/errors";
|
|
5
|
+
import { assertSafeBlobId, } from "veryfront/workflow/blob";
|
|
6
|
+
import { createS3BlobStorageStreamUploader, DEFAULT_MULTIPART_PART_SIZE, DEFAULT_MULTIPART_QUEUE_SIZE, MAX_MULTIPART_PART_SIZE, MAX_MULTIPART_QUEUE_SIZE, MIN_MULTIPART_PART_SIZE, } from "./multipart-upload.js";
|
|
7
|
+
const MAX_OBJECT_KEY_BYTES = 1_024;
|
|
8
|
+
const MAX_METADATA_ENTRIES = 100;
|
|
9
|
+
const MAX_METADATA_BYTES = 2 * 1_024;
|
|
10
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
11
|
+
function linkAbortSignal(upstream, controller, fallbackMessage) {
|
|
12
|
+
if (!upstream)
|
|
13
|
+
return () => { };
|
|
14
|
+
const forward = () => {
|
|
15
|
+
if (!controller.signal.aborted) {
|
|
16
|
+
controller.abort(upstream.reason ?? new DOMException(fallbackMessage, "AbortError"));
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
if (upstream.aborted)
|
|
20
|
+
forward();
|
|
21
|
+
else
|
|
22
|
+
upstream.addEventListener("abort", forward, { once: true });
|
|
23
|
+
return () => upstream.removeEventListener("abort", forward);
|
|
24
|
+
}
|
|
25
|
+
function configError(detail, cause) {
|
|
26
|
+
return CONFIG_INVALID.create({ detail, cause });
|
|
27
|
+
}
|
|
28
|
+
function invalidArgument(detail, cause) {
|
|
29
|
+
return INVALID_ARGUMENT.create({ detail, cause });
|
|
30
|
+
}
|
|
31
|
+
function requireNonEmptyString(value, field) {
|
|
32
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
33
|
+
throw configError(`S3BlobStorage: ${field} must be a non-empty string`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
function hasAsciiControlCharacter(value) {
|
|
38
|
+
for (let index = 0; index < value.length; index++) {
|
|
39
|
+
const code = value.charCodeAt(index);
|
|
40
|
+
if (code <= 0x1f || code === 0x7f)
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
function isLoopbackHost(hostname) {
|
|
46
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") ||
|
|
47
|
+
hostname === "::1" || hostname === "[::1]" || /^127(?:\.[0-9]{1,3}){3}$/.test(hostname);
|
|
48
|
+
}
|
|
49
|
+
function validateOptionalUrl(value, field) {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
53
|
+
throw configError(`S3BlobStorage: ${field} must be a non-empty HTTP(S) URL`);
|
|
54
|
+
}
|
|
55
|
+
let url;
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(value);
|
|
58
|
+
}
|
|
59
|
+
catch (cause) {
|
|
60
|
+
throw configError(`S3BlobStorage: ${field} must be a valid HTTP(S) URL`, cause);
|
|
61
|
+
}
|
|
62
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
63
|
+
throw configError(`S3BlobStorage: ${field} must use HTTP or HTTPS`);
|
|
64
|
+
}
|
|
65
|
+
if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
|
|
66
|
+
throw configError(`S3BlobStorage: ${field} must use HTTPS unless its host is localhost or a loopback IP`);
|
|
67
|
+
}
|
|
68
|
+
if (url.username || url.password) {
|
|
69
|
+
throw configError(`S3BlobStorage: ${field} must not contain credentials`);
|
|
70
|
+
}
|
|
71
|
+
if (url.search || url.hash) {
|
|
72
|
+
throw configError(`S3BlobStorage: ${field} must not contain a query or fragment`);
|
|
73
|
+
}
|
|
74
|
+
return url;
|
|
75
|
+
}
|
|
76
|
+
function validateTtl(value, field, kind) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
80
|
+
const detail = `S3BlobStorage: ${field} must be a finite non-negative number`;
|
|
81
|
+
throw kind === "config" ? configError(detail) : invalidArgument(detail);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
function normalizeConfig(config) {
|
|
86
|
+
if (!config || typeof config !== "object") {
|
|
87
|
+
throw configError("S3BlobStorage: configuration is required");
|
|
88
|
+
}
|
|
89
|
+
const region = requireNonEmptyString(config.region, "region");
|
|
90
|
+
const bucket = requireNonEmptyString(config.bucket, "bucket");
|
|
91
|
+
const accessKeyId = requireNonEmptyString(config.accessKeyId, "accessKeyId");
|
|
92
|
+
const secretAccessKey = requireNonEmptyString(config.secretAccessKey, "secretAccessKey");
|
|
93
|
+
const endpointObject = validateOptionalUrl(config.endpoint, "endpoint");
|
|
94
|
+
const baseUrlObject = validateOptionalUrl(config.baseUrl, "baseUrl");
|
|
95
|
+
const defaultTtl = validateTtl(config.defaultTtl, "defaultTtl", "config");
|
|
96
|
+
if (config.sessionToken !== undefined &&
|
|
97
|
+
(typeof config.sessionToken !== "string" || config.sessionToken.length === 0)) {
|
|
98
|
+
throw configError("S3BlobStorage: sessionToken must be a non-empty string");
|
|
99
|
+
}
|
|
100
|
+
if (config.prefix !== undefined && typeof config.prefix !== "string") {
|
|
101
|
+
throw configError("S3BlobStorage: prefix must be a string");
|
|
102
|
+
}
|
|
103
|
+
if (config.forcePathStyle !== undefined && typeof config.forcePathStyle !== "boolean") {
|
|
104
|
+
throw configError("S3BlobStorage: forcePathStyle must be a boolean");
|
|
105
|
+
}
|
|
106
|
+
if (config.maxAttempts !== undefined &&
|
|
107
|
+
(!Number.isSafeInteger(config.maxAttempts) || config.maxAttempts < 1 || config.maxAttempts > 20)) {
|
|
108
|
+
throw configError("S3BlobStorage: maxAttempts must be an integer between 1 and 20");
|
|
109
|
+
}
|
|
110
|
+
if (config.retryMode !== undefined && config.retryMode !== "standard" &&
|
|
111
|
+
config.retryMode !== "adaptive") {
|
|
112
|
+
throw configError("S3BlobStorage: retryMode must be standard or adaptive");
|
|
113
|
+
}
|
|
114
|
+
for (const [field, value] of [
|
|
115
|
+
["useDualstackEndpoint", config.useDualstackEndpoint],
|
|
116
|
+
["useFipsEndpoint", config.useFipsEndpoint],
|
|
117
|
+
["useArnRegion", config.useArnRegion],
|
|
118
|
+
["disableS3ExpressSessionAuth", config.disableS3ExpressSessionAuth],
|
|
119
|
+
]) {
|
|
120
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
121
|
+
throw configError(`S3BlobStorage: ${field} must be a boolean`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const checksumPolicies = ["WHEN_SUPPORTED", "WHEN_REQUIRED"];
|
|
125
|
+
if (config.requestChecksumCalculation !== undefined &&
|
|
126
|
+
!checksumPolicies.includes(config.requestChecksumCalculation)) {
|
|
127
|
+
throw configError("S3BlobStorage: requestChecksumCalculation must be WHEN_SUPPORTED or WHEN_REQUIRED");
|
|
128
|
+
}
|
|
129
|
+
if (config.responseChecksumValidation !== undefined &&
|
|
130
|
+
!checksumPolicies.includes(config.responseChecksumValidation)) {
|
|
131
|
+
throw configError("S3BlobStorage: responseChecksumValidation must be WHEN_SUPPORTED or WHEN_REQUIRED");
|
|
132
|
+
}
|
|
133
|
+
if (config.autoCreateBucket !== undefined && typeof config.autoCreateBucket !== "boolean") {
|
|
134
|
+
throw configError("S3BlobStorage: autoCreateBucket must be a boolean");
|
|
135
|
+
}
|
|
136
|
+
if (config.multipartPartSize !== undefined &&
|
|
137
|
+
(!Number.isSafeInteger(config.multipartPartSize) ||
|
|
138
|
+
config.multipartPartSize < MIN_MULTIPART_PART_SIZE ||
|
|
139
|
+
config.multipartPartSize > MAX_MULTIPART_PART_SIZE)) {
|
|
140
|
+
throw configError(`S3BlobStorage: multipartPartSize must be an integer between ${MIN_MULTIPART_PART_SIZE} and ${MAX_MULTIPART_PART_SIZE} bytes`);
|
|
141
|
+
}
|
|
142
|
+
if (config.multipartQueueSize !== undefined &&
|
|
143
|
+
(!Number.isSafeInteger(config.multipartQueueSize) || config.multipartQueueSize < 1 ||
|
|
144
|
+
config.multipartQueueSize > MAX_MULTIPART_QUEUE_SIZE)) {
|
|
145
|
+
throw configError(`S3BlobStorage: multipartQueueSize must be an integer between 1 and ${MAX_MULTIPART_QUEUE_SIZE}`);
|
|
146
|
+
}
|
|
147
|
+
if (config.signal !== undefined && !(config.signal instanceof AbortSignal)) {
|
|
148
|
+
throw configError("S3BlobStorage: signal must be an AbortSignal");
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
region,
|
|
152
|
+
bucket,
|
|
153
|
+
accessKeyId,
|
|
154
|
+
secretAccessKey,
|
|
155
|
+
...(config.sessionToken === undefined ? {} : { sessionToken: config.sessionToken }),
|
|
156
|
+
...(endpointObject === undefined ? {} : { endpoint: endpointObject.href }),
|
|
157
|
+
...(config.forcePathStyle === undefined ? {} : { forcePathStyle: config.forcePathStyle }),
|
|
158
|
+
maxAttempts: config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
159
|
+
retryMode: config.retryMode ?? "standard",
|
|
160
|
+
useDualstackEndpoint: config.useDualstackEndpoint ?? false,
|
|
161
|
+
useFipsEndpoint: config.useFipsEndpoint ?? false,
|
|
162
|
+
useArnRegion: config.useArnRegion ?? false,
|
|
163
|
+
requestChecksumCalculation: config.requestChecksumCalculation ?? "WHEN_SUPPORTED",
|
|
164
|
+
responseChecksumValidation: config.responseChecksumValidation ?? "WHEN_SUPPORTED",
|
|
165
|
+
disableS3ExpressSessionAuth: config.disableS3ExpressSessionAuth ?? false,
|
|
166
|
+
multipartPartSize: config.multipartPartSize ?? DEFAULT_MULTIPART_PART_SIZE,
|
|
167
|
+
multipartQueueSize: config.multipartQueueSize ?? DEFAULT_MULTIPART_QUEUE_SIZE,
|
|
168
|
+
...(config.prefix === undefined ? {} : { prefix: config.prefix }),
|
|
169
|
+
...(baseUrlObject === undefined ? {} : { baseUrl: baseUrlObject.href, baseUrlObject }),
|
|
170
|
+
...(defaultTtl === undefined ? {} : { defaultTtl }),
|
|
171
|
+
...(config.autoCreateBucket === undefined ? {} : { autoCreateBucket: config.autoCreateBucket }),
|
|
172
|
+
...(config.signal === undefined ? {} : { signal: config.signal }),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function validateDependencies(dependencies) {
|
|
176
|
+
if (!dependencies || typeof dependencies !== "object") {
|
|
177
|
+
throw invalidArgument("S3BlobStorage: dependencies must be an object");
|
|
178
|
+
}
|
|
179
|
+
if (dependencies.client !== undefined &&
|
|
180
|
+
(!dependencies.client || typeof dependencies.client !== "object" ||
|
|
181
|
+
typeof dependencies.client.send !== "function" ||
|
|
182
|
+
(dependencies.client.destroy !== undefined &&
|
|
183
|
+
typeof dependencies.client.destroy !== "function"))) {
|
|
184
|
+
throw invalidArgument("S3BlobStorage: dependencies.client must implement send()");
|
|
185
|
+
}
|
|
186
|
+
if (dependencies.streamUploader !== undefined &&
|
|
187
|
+
(!dependencies.streamUploader || typeof dependencies.streamUploader !== "object" ||
|
|
188
|
+
typeof dependencies.streamUploader.upload !== "function")) {
|
|
189
|
+
throw invalidArgument("S3BlobStorage: dependencies.streamUploader must implement upload()");
|
|
190
|
+
}
|
|
191
|
+
return dependencies;
|
|
192
|
+
}
|
|
193
|
+
function createDefaultResources(config) {
|
|
194
|
+
const client = new S3Client({
|
|
195
|
+
// Populate every SDK setting that otherwise resolves from process env or
|
|
196
|
+
// shared AWS config files. This extension accepts only explicit config.
|
|
197
|
+
authSchemePreference: [],
|
|
198
|
+
defaultsMode: "standard",
|
|
199
|
+
defaultUserAgentProvider: async () => [
|
|
200
|
+
["aws-sdk-js"],
|
|
201
|
+
["lang/js"],
|
|
202
|
+
["app/veryfront-ext-blob-s3", "0.1.0"],
|
|
203
|
+
],
|
|
204
|
+
disableClockSkewCorrection: false,
|
|
205
|
+
disableS3ExpressSessionAuth: config.disableS3ExpressSessionAuth,
|
|
206
|
+
region: config.region,
|
|
207
|
+
credentials: {
|
|
208
|
+
accessKeyId: config.accessKeyId,
|
|
209
|
+
secretAccessKey: config.secretAccessKey,
|
|
210
|
+
...(config.sessionToken === undefined ? {} : { sessionToken: config.sessionToken }),
|
|
211
|
+
},
|
|
212
|
+
endpoint: config.endpoint,
|
|
213
|
+
forcePathStyle: config.forcePathStyle,
|
|
214
|
+
maxAttempts: config.maxAttempts,
|
|
215
|
+
requestChecksumCalculation: config.requestChecksumCalculation,
|
|
216
|
+
responseChecksumValidation: config.responseChecksumValidation,
|
|
217
|
+
retryMode: config.retryMode,
|
|
218
|
+
sigv4aSigningRegionSet: [config.region],
|
|
219
|
+
useArnRegion: config.useArnRegion,
|
|
220
|
+
useDualstackEndpoint: config.useDualstackEndpoint,
|
|
221
|
+
useFipsEndpoint: config.useFipsEndpoint,
|
|
222
|
+
userAgentAppId: "veryfront-ext-blob-s3",
|
|
223
|
+
});
|
|
224
|
+
const send = client.send.bind(client);
|
|
225
|
+
return {
|
|
226
|
+
client: {
|
|
227
|
+
send,
|
|
228
|
+
destroy: () => client.destroy(),
|
|
229
|
+
},
|
|
230
|
+
streamUploader: createS3BlobStorageStreamUploader(client),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function errorName(error) {
|
|
234
|
+
try {
|
|
235
|
+
if (!error || typeof error !== "object")
|
|
236
|
+
return undefined;
|
|
237
|
+
const name = error.name;
|
|
238
|
+
return typeof name === "string" ? name : undefined;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function errorStatus(error) {
|
|
245
|
+
try {
|
|
246
|
+
if (!error || typeof error !== "object")
|
|
247
|
+
return undefined;
|
|
248
|
+
const metadata = error.$metadata;
|
|
249
|
+
if (!metadata || typeof metadata !== "object")
|
|
250
|
+
return undefined;
|
|
251
|
+
const status = metadata.httpStatusCode;
|
|
252
|
+
return typeof status === "number" ? status : undefined;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function isBucketNotFound(error) {
|
|
259
|
+
const name = errorName(error);
|
|
260
|
+
return name === "NoSuchBucket" || name === "NotFound" ||
|
|
261
|
+
errorStatus(error) === 404;
|
|
262
|
+
}
|
|
263
|
+
function isObjectNotFound(error) {
|
|
264
|
+
return errorName(error) === "NoSuchKey";
|
|
265
|
+
}
|
|
266
|
+
function isAmbiguousObjectNotFound(error) {
|
|
267
|
+
const name = errorName(error);
|
|
268
|
+
return name === "NotFound" || errorStatus(error) === 404;
|
|
269
|
+
}
|
|
270
|
+
function isOwnedBucket(error) {
|
|
271
|
+
return errorName(error) === "BucketAlreadyOwnedByYou";
|
|
272
|
+
}
|
|
273
|
+
function validateObjectKey(key) {
|
|
274
|
+
const byteLength = new TextEncoder().encode(key).byteLength;
|
|
275
|
+
if (byteLength === 0 || byteLength > MAX_OBJECT_KEY_BYTES) {
|
|
276
|
+
throw invalidArgument(`S3BlobStorage: object key must contain between 1 and ${MAX_OBJECT_KEY_BYTES} UTF-8 bytes`);
|
|
277
|
+
}
|
|
278
|
+
if (key === "." || key === "..") {
|
|
279
|
+
throw invalidArgument("S3BlobStorage: dot-only object keys cannot produce a stable public URL");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function snapshotMetadata(value) {
|
|
283
|
+
if (value === undefined)
|
|
284
|
+
return undefined;
|
|
285
|
+
let entries;
|
|
286
|
+
try {
|
|
287
|
+
entries = Object.entries(value);
|
|
288
|
+
}
|
|
289
|
+
catch (cause) {
|
|
290
|
+
throw invalidArgument("S3BlobStorage: metadata must be a readable string record", cause);
|
|
291
|
+
}
|
|
292
|
+
if (entries.length > MAX_METADATA_ENTRIES) {
|
|
293
|
+
throw invalidArgument(`S3BlobStorage: metadata must contain at most ${MAX_METADATA_ENTRIES} entries`);
|
|
294
|
+
}
|
|
295
|
+
const metadata = Object.create(null);
|
|
296
|
+
const normalizedKeys = new Set();
|
|
297
|
+
let bytes = 0;
|
|
298
|
+
for (const [key, entry] of entries) {
|
|
299
|
+
const normalizedKey = key.toLowerCase();
|
|
300
|
+
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(normalizedKey)) {
|
|
301
|
+
throw invalidArgument(`S3BlobStorage: metadata key ${JSON.stringify(key)} is not HTTP-header safe`);
|
|
302
|
+
}
|
|
303
|
+
if (normalizedKeys.has(normalizedKey)) {
|
|
304
|
+
throw invalidArgument(`S3BlobStorage: metadata keys must be unique ignoring case (${JSON.stringify(key)})`);
|
|
305
|
+
}
|
|
306
|
+
normalizedKeys.add(normalizedKey);
|
|
307
|
+
if (typeof entry !== "string" || /[\r\n\0]/.test(entry)) {
|
|
308
|
+
throw invalidArgument(`S3BlobStorage: metadata value for ${JSON.stringify(key)} must be a header-safe string`);
|
|
309
|
+
}
|
|
310
|
+
bytes += new TextEncoder().encode(normalizedKey).byteLength +
|
|
311
|
+
new TextEncoder().encode(entry).byteLength;
|
|
312
|
+
if (bytes > MAX_METADATA_BYTES) {
|
|
313
|
+
throw invalidArgument(`S3BlobStorage: metadata must not exceed ${MAX_METADATA_BYTES} UTF-8 bytes`);
|
|
314
|
+
}
|
|
315
|
+
metadata[normalizedKey] = entry;
|
|
316
|
+
}
|
|
317
|
+
return metadata;
|
|
318
|
+
}
|
|
319
|
+
function readProviderMetadata(value) {
|
|
320
|
+
if (value === undefined)
|
|
321
|
+
return Object.create(null);
|
|
322
|
+
let entries;
|
|
323
|
+
try {
|
|
324
|
+
entries = Object.entries(value);
|
|
325
|
+
}
|
|
326
|
+
catch (cause) {
|
|
327
|
+
throw API_ERROR.create({
|
|
328
|
+
detail: "S3BlobStorage: provider returned unreadable metadata",
|
|
329
|
+
cause,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (entries.length > MAX_METADATA_ENTRIES) {
|
|
333
|
+
throw API_ERROR.create({
|
|
334
|
+
detail: "S3BlobStorage: provider returned too many metadata entries",
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
const metadata = Object.create(null);
|
|
338
|
+
let bytes = 0;
|
|
339
|
+
for (const [key, entry] of entries) {
|
|
340
|
+
const normalizedKey = key.toLowerCase();
|
|
341
|
+
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(normalizedKey) ||
|
|
342
|
+
typeof entry !== "string" || /[\r\n\0]/.test(entry)) {
|
|
343
|
+
throw API_ERROR.create({ detail: "S3BlobStorage: provider returned invalid metadata" });
|
|
344
|
+
}
|
|
345
|
+
if (Object.hasOwn(metadata, normalizedKey)) {
|
|
346
|
+
throw API_ERROR.create({
|
|
347
|
+
detail: "S3BlobStorage: provider returned colliding metadata keys",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
bytes += new TextEncoder().encode(normalizedKey).byteLength +
|
|
351
|
+
new TextEncoder().encode(entry).byteLength;
|
|
352
|
+
if (bytes > MAX_METADATA_BYTES) {
|
|
353
|
+
throw API_ERROR.create({ detail: "S3BlobStorage: provider returned oversized metadata" });
|
|
354
|
+
}
|
|
355
|
+
metadata[normalizedKey] = entry;
|
|
356
|
+
}
|
|
357
|
+
return metadata;
|
|
358
|
+
}
|
|
359
|
+
function validateMimeType(value) {
|
|
360
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 1_024 ||
|
|
361
|
+
hasAsciiControlCharacter(value)) {
|
|
362
|
+
throw invalidArgument("S3BlobStorage: mimeType must be a non-empty header-safe string");
|
|
363
|
+
}
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
function expiresAt(createdAt, ttl) {
|
|
367
|
+
if (ttl === undefined || ttl === 0)
|
|
368
|
+
return undefined;
|
|
369
|
+
const milliseconds = createdAt.getTime() + ttl * 1_000;
|
|
370
|
+
if (!Number.isFinite(milliseconds)) {
|
|
371
|
+
throw invalidArgument("S3BlobStorage: ttl produces an invalid expiry time");
|
|
372
|
+
}
|
|
373
|
+
return new Date(milliseconds);
|
|
374
|
+
}
|
|
375
|
+
function publicObjectUrl(baseUrl, key) {
|
|
376
|
+
if (!baseUrl)
|
|
377
|
+
return undefined;
|
|
378
|
+
const base = new URL(baseUrl.href);
|
|
379
|
+
if (!base.pathname.endsWith("/"))
|
|
380
|
+
base.pathname += "/";
|
|
381
|
+
return `${base.href}${encodeURIComponent(key)}`;
|
|
382
|
+
}
|
|
383
|
+
function toNodeReadable(stream) {
|
|
384
|
+
// The npm AWS SDK selects its Node transport in Deno, Bun, and Node. Convert
|
|
385
|
+
// the framework's web-stream contract at this extension boundary.
|
|
386
|
+
return Readable.fromWeb(stream, { objectMode: false });
|
|
387
|
+
}
|
|
388
|
+
/** AWS S3 implementation of the framework-owned `BlobStorage` interface. */
|
|
389
|
+
export class S3BlobStorage {
|
|
390
|
+
#config;
|
|
391
|
+
#client;
|
|
392
|
+
#streamUploader;
|
|
393
|
+
#lifecycleController = new AbortController();
|
|
394
|
+
#detachConfigAbort;
|
|
395
|
+
#bucketReady;
|
|
396
|
+
#clientDestroyed = false;
|
|
397
|
+
#closed = false;
|
|
398
|
+
constructor(config, dependencies = {}) {
|
|
399
|
+
const normalized = normalizeConfig(config);
|
|
400
|
+
const validatedDependencies = validateDependencies(dependencies);
|
|
401
|
+
const defaults = validatedDependencies.client === undefined
|
|
402
|
+
? createDefaultResources(normalized)
|
|
403
|
+
: undefined;
|
|
404
|
+
this.#client = validatedDependencies.client ?? defaults.client;
|
|
405
|
+
this.#streamUploader = validatedDependencies.streamUploader ?? defaults?.streamUploader;
|
|
406
|
+
this.#detachConfigAbort = linkAbortSignal(normalized.signal, this.#lifecycleController, "The S3 blob-storage configuration was revoked.");
|
|
407
|
+
this.#config = {
|
|
408
|
+
region: normalized.region,
|
|
409
|
+
bucket: normalized.bucket,
|
|
410
|
+
...(normalized.prefix === undefined ? {} : { prefix: normalized.prefix }),
|
|
411
|
+
...(normalized.baseUrlObject === undefined
|
|
412
|
+
? {}
|
|
413
|
+
: { baseUrlObject: normalized.baseUrlObject }),
|
|
414
|
+
...(normalized.defaultTtl === undefined ? {} : { defaultTtl: normalized.defaultTtl }),
|
|
415
|
+
...(normalized.autoCreateBucket === undefined
|
|
416
|
+
? {}
|
|
417
|
+
: { autoCreateBucket: normalized.autoCreateBucket }),
|
|
418
|
+
multipartPartSize: normalized.multipartPartSize,
|
|
419
|
+
multipartQueueSize: normalized.multipartQueueSize,
|
|
420
|
+
signal: this.#lifecycleController.signal,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
close() {
|
|
424
|
+
if (!this.#closed) {
|
|
425
|
+
this.#closed = true;
|
|
426
|
+
this.#detachConfigAbort();
|
|
427
|
+
if (!this.#lifecycleController.signal.aborted) {
|
|
428
|
+
this.#lifecycleController.abort(new DOMException("S3 blob storage is closing.", "AbortError"));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (this.#clientDestroyed)
|
|
432
|
+
return;
|
|
433
|
+
this.#client.destroy?.();
|
|
434
|
+
this.#clientDestroyed = true;
|
|
435
|
+
}
|
|
436
|
+
#assertOpen() {
|
|
437
|
+
if (this.#closed) {
|
|
438
|
+
throw INITIALIZATION_ERROR.create({ detail: "S3BlobStorage is closed" });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
#send(command) {
|
|
442
|
+
this.#assertOpen();
|
|
443
|
+
this.#config.signal?.throwIfAborted();
|
|
444
|
+
return this.#client.send(command, this.#config.signal ? { abortSignal: this.#config.signal } : undefined);
|
|
445
|
+
}
|
|
446
|
+
#key(id) {
|
|
447
|
+
this.#assertOpen();
|
|
448
|
+
assertSafeBlobId(id);
|
|
449
|
+
const key = `${this.#config.prefix ?? ""}${id}`;
|
|
450
|
+
validateObjectKey(key);
|
|
451
|
+
return key;
|
|
452
|
+
}
|
|
453
|
+
async #isMissingObject(error) {
|
|
454
|
+
if (isObjectNotFound(error))
|
|
455
|
+
return true;
|
|
456
|
+
if (errorName(error) === "NoSuchBucket")
|
|
457
|
+
throw error;
|
|
458
|
+
if (!isAmbiguousObjectNotFound(error))
|
|
459
|
+
return false;
|
|
460
|
+
// S3 uses an ambiguous 404/NotFound for HeadObject. Verify that the bucket
|
|
461
|
+
// still exists before reducing the failure to the BlobStorage null/false
|
|
462
|
+
// contract; otherwise a configuration or availability failure is hidden.
|
|
463
|
+
await this.#send(new HeadBucketCommand({ Bucket: this.#config.bucket }));
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
async #initializeBucket() {
|
|
467
|
+
try {
|
|
468
|
+
await this.#send(new HeadBucketCommand({ Bucket: this.#config.bucket }));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (!isBucketNotFound(error))
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
await this.#send(new CreateBucketCommand({
|
|
477
|
+
Bucket: this.#config.bucket,
|
|
478
|
+
CreateBucketConfiguration: this.#config.region === "us-east-1" ? undefined : {
|
|
479
|
+
LocationConstraint: this.#config.region,
|
|
480
|
+
},
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (!isOwnedBucket(error))
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
async #ensureBucket() {
|
|
489
|
+
if (!this.#config.autoCreateBucket)
|
|
490
|
+
return;
|
|
491
|
+
if (!this.#bucketReady) {
|
|
492
|
+
const pending = this.#initializeBucket();
|
|
493
|
+
this.#bucketReady = pending;
|
|
494
|
+
try {
|
|
495
|
+
await pending;
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (this.#bucketReady === pending)
|
|
499
|
+
this.#bucketReady = undefined;
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
await this.#bucketReady;
|
|
505
|
+
}
|
|
506
|
+
async put(data, options = {}) {
|
|
507
|
+
const id = options.id ?? dntShim.crypto.randomUUID();
|
|
508
|
+
const key = this.#key(id);
|
|
509
|
+
const mimeType = validateMimeType(options.mimeType ?? "application/octet-stream");
|
|
510
|
+
const metadata = snapshotMetadata(options.metadata);
|
|
511
|
+
const ttl = validateTtl(options.ttl ?? this.#config.defaultTtl, "ttl", "argument");
|
|
512
|
+
const createdAt = new Date();
|
|
513
|
+
const expiry = expiresAt(createdAt, ttl);
|
|
514
|
+
let body;
|
|
515
|
+
let stream;
|
|
516
|
+
let contentLength;
|
|
517
|
+
if (typeof data === "string") {
|
|
518
|
+
body = new TextEncoder().encode(data);
|
|
519
|
+
contentLength = body.byteLength;
|
|
520
|
+
}
|
|
521
|
+
else if (data instanceof Uint8Array) {
|
|
522
|
+
body = data;
|
|
523
|
+
contentLength = data.byteLength;
|
|
524
|
+
}
|
|
525
|
+
else if (data instanceof Blob) {
|
|
526
|
+
body = toNodeReadable(data.stream());
|
|
527
|
+
contentLength = data.size;
|
|
528
|
+
}
|
|
529
|
+
else if (data instanceof ReadableStream) {
|
|
530
|
+
if (data.locked) {
|
|
531
|
+
throw invalidArgument("S3BlobStorage: upload stream must be unlocked");
|
|
532
|
+
}
|
|
533
|
+
stream = data;
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
throw invalidArgument("Unsupported data type for S3BlobStorage");
|
|
537
|
+
}
|
|
538
|
+
const streamUploader = stream === undefined ? undefined : this.#streamUploader;
|
|
539
|
+
if (stream !== undefined && streamUploader === undefined) {
|
|
540
|
+
throw INITIALIZATION_ERROR.create({
|
|
541
|
+
detail: "S3BlobStorage: streamUploader is required for ReadableStream uploads when using a custom client",
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
await this.#ensureBucket();
|
|
545
|
+
let size;
|
|
546
|
+
if (stream !== undefined) {
|
|
547
|
+
size = await streamUploader.upload({
|
|
548
|
+
bucket: this.#config.bucket,
|
|
549
|
+
key,
|
|
550
|
+
stream,
|
|
551
|
+
mimeType,
|
|
552
|
+
expiresAt: expiry,
|
|
553
|
+
metadata,
|
|
554
|
+
partSize: this.#config.multipartPartSize,
|
|
555
|
+
queueSize: this.#config.multipartQueueSize,
|
|
556
|
+
signal: this.#config.signal,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
const input = {
|
|
561
|
+
Bucket: this.#config.bucket,
|
|
562
|
+
Key: key,
|
|
563
|
+
Body: body,
|
|
564
|
+
ContentType: mimeType,
|
|
565
|
+
ContentLength: contentLength,
|
|
566
|
+
Expires: expiry,
|
|
567
|
+
Metadata: metadata,
|
|
568
|
+
};
|
|
569
|
+
await this.#send(new PutObjectCommand(input));
|
|
570
|
+
size = contentLength;
|
|
571
|
+
}
|
|
572
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
573
|
+
throw API_ERROR.create({
|
|
574
|
+
detail: "S3BlobStorage: stream uploader returned an invalid object size",
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
__kind: "blob",
|
|
579
|
+
id,
|
|
580
|
+
size,
|
|
581
|
+
mimeType,
|
|
582
|
+
createdAt,
|
|
583
|
+
expiresAt: expiry,
|
|
584
|
+
metadata,
|
|
585
|
+
url: publicObjectUrl(this.#config.baseUrlObject, key),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
async getStream(id) {
|
|
589
|
+
const key = this.#key(id);
|
|
590
|
+
let output;
|
|
591
|
+
try {
|
|
592
|
+
output = await this.#send(new GetObjectCommand({
|
|
593
|
+
Bucket: this.#config.bucket,
|
|
594
|
+
Key: key,
|
|
595
|
+
}));
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (await this.#isMissingObject(error))
|
|
599
|
+
return null;
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
if (!output.Body) {
|
|
603
|
+
throw API_ERROR.create({ detail: "S3BlobStorage: successful download returned no body" });
|
|
604
|
+
}
|
|
605
|
+
if (typeof output.Body.transformToWebStream !== "function") {
|
|
606
|
+
throw API_ERROR.create({
|
|
607
|
+
detail: "S3BlobStorage: provider returned an invalid download body",
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
const stream = output.Body.transformToWebStream();
|
|
611
|
+
if (!(stream instanceof ReadableStream)) {
|
|
612
|
+
throw API_ERROR.create({
|
|
613
|
+
detail: "S3BlobStorage: provider returned an invalid download stream",
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return stream;
|
|
617
|
+
}
|
|
618
|
+
async getText(id) {
|
|
619
|
+
const stream = await this.getStream(id);
|
|
620
|
+
return stream ? await new Response(stream).text() : null;
|
|
621
|
+
}
|
|
622
|
+
async getBytes(id) {
|
|
623
|
+
const stream = await this.getStream(id);
|
|
624
|
+
if (!stream)
|
|
625
|
+
return null;
|
|
626
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
627
|
+
}
|
|
628
|
+
async delete(id) {
|
|
629
|
+
await this.#send(new DeleteObjectCommand({
|
|
630
|
+
Bucket: this.#config.bucket,
|
|
631
|
+
Key: this.#key(id),
|
|
632
|
+
}));
|
|
633
|
+
}
|
|
634
|
+
async exists(id) {
|
|
635
|
+
try {
|
|
636
|
+
await this.#send(new HeadObjectCommand({
|
|
637
|
+
Bucket: this.#config.bucket,
|
|
638
|
+
Key: this.#key(id),
|
|
639
|
+
}));
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
if (await this.#isMissingObject(error))
|
|
644
|
+
return false;
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async stat(id) {
|
|
649
|
+
const key = this.#key(id);
|
|
650
|
+
let output;
|
|
651
|
+
try {
|
|
652
|
+
output = await this.#send(new HeadObjectCommand({
|
|
653
|
+
Bucket: this.#config.bucket,
|
|
654
|
+
Key: key,
|
|
655
|
+
}));
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
if (await this.#isMissingObject(error))
|
|
659
|
+
return null;
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
if (!(output.LastModified instanceof Date) || !Number.isFinite(output.LastModified.getTime())) {
|
|
663
|
+
throw API_ERROR.create({
|
|
664
|
+
detail: "S3BlobStorage: successful metadata response omitted LastModified",
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
const metadata = readProviderMetadata(output.Metadata);
|
|
668
|
+
const expiry = output.Expires === undefined ? undefined : new Date(output.Expires);
|
|
669
|
+
if (expiry && !Number.isFinite(expiry.getTime())) {
|
|
670
|
+
throw API_ERROR.create({ detail: "S3BlobStorage: provider returned an invalid expiry" });
|
|
671
|
+
}
|
|
672
|
+
const size = output.ContentLength;
|
|
673
|
+
if (size === undefined || !Number.isSafeInteger(size) || size < 0) {
|
|
674
|
+
throw API_ERROR.create({ detail: "S3BlobStorage: provider returned an invalid object size" });
|
|
675
|
+
}
|
|
676
|
+
const mimeType = output.ContentType ?? "application/octet-stream";
|
|
677
|
+
if (typeof mimeType !== "string" || mimeType.length === 0 || mimeType.length > 1_024 ||
|
|
678
|
+
hasAsciiControlCharacter(mimeType)) {
|
|
679
|
+
throw API_ERROR.create({
|
|
680
|
+
detail: "S3BlobStorage: provider returned an invalid content type",
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
__kind: "blob",
|
|
685
|
+
id,
|
|
686
|
+
size,
|
|
687
|
+
mimeType,
|
|
688
|
+
createdAt: output.LastModified,
|
|
689
|
+
expiresAt: expiry,
|
|
690
|
+
metadata,
|
|
691
|
+
url: publicObjectUrl(this.#config.baseUrlObject, key),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
}
|