@contract-kit/provider-storage-s3 1.0.0

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/src/index.ts ADDED
@@ -0,0 +1,906 @@
1
+ import { Readable } from "node:stream";
2
+ import {
3
+ DeleteObjectCommand,
4
+ GetObjectCommand,
5
+ HeadObjectCommand,
6
+ PutObjectCommand,
7
+ S3Client,
8
+ type S3ClientConfig,
9
+ } from "@aws-sdk/client-s3";
10
+ import { createProviderDevtools } from "@contract-kit/devtools";
11
+ import {
12
+ createProvider,
13
+ type StorageBody,
14
+ type StorageMetadata,
15
+ type StorageObject,
16
+ type StorageObjectBody,
17
+ type StoragePort,
18
+ type StorageVisibility,
19
+ } from "@contract-kit/ports";
20
+ import { z } from "zod";
21
+
22
+ export type {
23
+ StorageBody,
24
+ StorageMetadata,
25
+ StorageObject,
26
+ StorageObjectBody,
27
+ StoragePort,
28
+ StorageVisibility,
29
+ } from "@contract-kit/ports";
30
+
31
+ const visibilityMetadataKey = "ck-visibility";
32
+
33
+ const BooleanString = z
34
+ .enum(["true", "false"])
35
+ .transform((value) => value === "true");
36
+
37
+ const S3StorageConfigSchema = z.object({
38
+ BUCKET: z.string().min(1),
39
+ REGION: z.string().min(1).default("us-east-1"),
40
+ ENDPOINT: z.string().url().optional(),
41
+ ACCESS_KEY_ID: z.string().min(1).optional(),
42
+ SECRET_ACCESS_KEY: z.string().min(1).optional(),
43
+ SESSION_TOKEN: z.string().min(1).optional(),
44
+ PUBLIC_BASE_URL: z.string().min(1).optional(),
45
+ FORCE_PATH_STYLE: BooleanString.optional(),
46
+ KEY_PREFIX: z.string().optional(),
47
+ });
48
+
49
+ export type S3StorageConfig = z.infer<typeof S3StorageConfigSchema>;
50
+
51
+ export type S3StorageClient = Pick<S3Client, "send">;
52
+
53
+ export interface S3StorageOptions {
54
+ /**
55
+ * S3 bucket name.
56
+ */
57
+ bucket: string;
58
+
59
+ /**
60
+ * S3-compatible client. Omit to create an AWS SDK S3Client from config.
61
+ */
62
+ client?: S3StorageClient;
63
+
64
+ /**
65
+ * Region used when creating the default S3Client.
66
+ *
67
+ * @default "us-east-1"
68
+ */
69
+ region?: string;
70
+
71
+ /**
72
+ * S3-compatible endpoint. Required for R2, MinIO, Spaces, B2, and most
73
+ * non-AWS object stores.
74
+ */
75
+ endpoint?: string;
76
+
77
+ /**
78
+ * Static credentials used when creating the default S3Client.
79
+ */
80
+ credentials?: {
81
+ accessKeyId: string;
82
+ secretAccessKey: string;
83
+ sessionToken?: string;
84
+ };
85
+
86
+ /**
87
+ * Use path-style bucket addressing when required by the object store.
88
+ */
89
+ forcePathStyle?: boolean;
90
+
91
+ /**
92
+ * Prefix all object keys before sending them to S3.
93
+ */
94
+ keyPrefix?: string;
95
+
96
+ /**
97
+ * Base URL used by `publicUrl(...)` for public objects.
98
+ */
99
+ publicBaseUrl?: string;
100
+
101
+ /**
102
+ * Additional AWS SDK S3Client config.
103
+ */
104
+ clientConfig?: Omit<
105
+ S3ClientConfig,
106
+ "region" | "endpoint" | "credentials" | "forcePathStyle"
107
+ >;
108
+
109
+ /**
110
+ * Optional devtools target. The provider passes existing app ports here
111
+ * automatically; direct factory users can pass a devtools port explicitly.
112
+ */
113
+ devtools?: unknown;
114
+ }
115
+
116
+ export interface S3StorageProviderOptions
117
+ extends Omit<S3StorageOptions, "bucket" | "client" | "devtools"> {
118
+ /**
119
+ * Provider name. Defaults to "storage-s3".
120
+ */
121
+ name?: string;
122
+
123
+ /**
124
+ * Default bucket used when STORAGE_S3_BUCKET is not set.
125
+ */
126
+ bucket?: string;
127
+
128
+ /**
129
+ * Optional client factory for tests or custom S3 clients.
130
+ */
131
+ createClient?: (config: S3StorageConfig) => S3StorageClient;
132
+ }
133
+
134
+ type StorageEvent = {
135
+ operation: string;
136
+ key: string;
137
+ summary: string;
138
+ details?: Record<string, unknown>;
139
+ };
140
+
141
+ type NodeWebReadableStream = Parameters<typeof Readable.fromWeb>[0];
142
+
143
+ type TransformableBody = {
144
+ transformToByteArray?: () => Promise<Uint8Array>;
145
+ transformToString?: () => Promise<string>;
146
+ transformToWebStream?: () => ReadableStream<Uint8Array>;
147
+ };
148
+
149
+ type BodyWithAsyncIterator = AsyncIterable<Uint8Array>;
150
+
151
+ function copyBytes(bytes: Uint8Array): Uint8Array {
152
+ return new Uint8Array(bytes);
153
+ }
154
+
155
+ function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
156
+ const buffer = new ArrayBuffer(bytes.byteLength);
157
+ new Uint8Array(buffer).set(bytes);
158
+ return buffer;
159
+ }
160
+
161
+ function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
162
+ const copy = copyBytes(bytes);
163
+ return new ReadableStream<Uint8Array>({
164
+ start(controller) {
165
+ controller.enqueue(copy);
166
+ controller.close();
167
+ },
168
+ });
169
+ }
170
+
171
+ function hasControlCharacter(value: string): boolean {
172
+ for (const char of value) {
173
+ const code = char.charCodeAt(0);
174
+ if (code <= 31 || code === 127) return true;
175
+ }
176
+
177
+ return false;
178
+ }
179
+
180
+ function validateStorageKey(key: string): void {
181
+ if (key.length === 0) {
182
+ throw new Error("Storage key must not be empty.");
183
+ }
184
+
185
+ if (hasControlCharacter(key)) {
186
+ throw new Error("Storage key must not include control characters.");
187
+ }
188
+
189
+ if (key.startsWith("/")) {
190
+ throw new Error("Storage key must not start with '/'.");
191
+ }
192
+
193
+ if (key.endsWith("/")) {
194
+ throw new Error("Storage key must not end with '/'.");
195
+ }
196
+
197
+ if (key.includes("\\")) {
198
+ throw new Error("Storage key must use '/' separators, not '\\'.");
199
+ }
200
+
201
+ const segments = key.split("/");
202
+
203
+ if (segments.some((segment) => segment === "")) {
204
+ throw new Error("Storage key must not include empty path segments.");
205
+ }
206
+
207
+ if (segments.some((segment) => segment === "." || segment === "..")) {
208
+ throw new Error("Storage key must not include '.' or '..' segments.");
209
+ }
210
+ }
211
+
212
+ function normalizeKeyPrefix(prefix: string | undefined): string {
213
+ if (!prefix) return "";
214
+ const normalized = prefix.replace(/^\/+|\/+$/g, "");
215
+ if (!normalized) return "";
216
+ validateStorageKey(normalized);
217
+ return normalized;
218
+ }
219
+
220
+ function objectKey(prefix: string, key: string): string {
221
+ validateStorageKey(key);
222
+ return prefix ? `${prefix}/${key}` : key;
223
+ }
224
+
225
+ function objectPrefix(keyPrefix: string, prefix: string): string {
226
+ if (prefix.length === 0) {
227
+ return keyPrefix ? `${keyPrefix}/` : "";
228
+ }
229
+
230
+ const normalized = prefix.replace(/\/+$/g, "");
231
+ validateStorageKey(normalized);
232
+ return `${objectKey(keyPrefix, normalized)}/`;
233
+ }
234
+
235
+ function joinPublicUrl(baseUrl: string, key: string): string {
236
+ const base = baseUrl.replace(/\/+$/, "");
237
+ const encodedKey = key
238
+ .split("/")
239
+ .map((part) => encodeURIComponent(part))
240
+ .join("/");
241
+
242
+ return `${base}/${encodedKey}`;
243
+ }
244
+
245
+ function toPutBody(body: StorageBody): string | Uint8Array | Readable {
246
+ if (typeof body === "string") return body;
247
+ if (body instanceof Uint8Array) return copyBytes(body);
248
+ if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0));
249
+ if (body instanceof Blob) {
250
+ return Readable.fromWeb(body.stream() as unknown as NodeWebReadableStream);
251
+ }
252
+
253
+ return Readable.fromWeb(body as unknown as NodeWebReadableStream);
254
+ }
255
+
256
+ async function streamToBytes(
257
+ stream: ReadableStream<Uint8Array>,
258
+ ): Promise<Uint8Array> {
259
+ const reader = stream.getReader();
260
+ const chunks: Uint8Array[] = [];
261
+ let size = 0;
262
+
263
+ try {
264
+ while (true) {
265
+ const result = await reader.read();
266
+ if (result.done) break;
267
+ chunks.push(result.value);
268
+ size += result.value.byteLength;
269
+ }
270
+ } finally {
271
+ reader.releaseLock();
272
+ }
273
+
274
+ const bytes = new Uint8Array(size);
275
+ let offset = 0;
276
+ for (const chunk of chunks) {
277
+ bytes.set(chunk, offset);
278
+ offset += chunk.byteLength;
279
+ }
280
+
281
+ return bytes;
282
+ }
283
+
284
+ async function asyncIterableToBytes(
285
+ iterable: BodyWithAsyncIterator,
286
+ ): Promise<Uint8Array> {
287
+ const chunks: Uint8Array[] = [];
288
+ let size = 0;
289
+
290
+ for await (const chunk of iterable) {
291
+ chunks.push(chunk);
292
+ size += chunk.byteLength;
293
+ }
294
+
295
+ const bytes = new Uint8Array(size);
296
+ let offset = 0;
297
+ for (const chunk of chunks) {
298
+ bytes.set(chunk, offset);
299
+ offset += chunk.byteLength;
300
+ }
301
+
302
+ return bytes;
303
+ }
304
+
305
+ function isAsyncIterable(value: unknown): value is BodyWithAsyncIterator {
306
+ return (
307
+ typeof value === "object" && value !== null && Symbol.asyncIterator in value
308
+ );
309
+ }
310
+
311
+ function bodyToStream(body: unknown): ReadableStream<Uint8Array> {
312
+ if (body instanceof ReadableStream) return body;
313
+ if (body instanceof Uint8Array) return bytesToStream(body);
314
+ if (typeof body === "string") {
315
+ return bytesToStream(new TextEncoder().encode(body));
316
+ }
317
+ if (body instanceof Blob) {
318
+ return body.stream() as unknown as ReadableStream<Uint8Array>;
319
+ }
320
+
321
+ const transformable = body as TransformableBody | null;
322
+ if (typeof transformable?.transformToWebStream === "function") {
323
+ return transformable.transformToWebStream();
324
+ }
325
+
326
+ if (body instanceof Readable) {
327
+ return Readable.toWeb(body) as unknown as ReadableStream<Uint8Array>;
328
+ }
329
+
330
+ throw new Error("S3 object body is not readable.");
331
+ }
332
+
333
+ async function bodyToBytes(body: unknown): Promise<Uint8Array> {
334
+ if (body instanceof Uint8Array) return copyBytes(body);
335
+ if (typeof body === "string") return new TextEncoder().encode(body);
336
+ if (body instanceof Blob) return new Uint8Array(await body.arrayBuffer());
337
+
338
+ const transformable = body as TransformableBody | null;
339
+ if (typeof transformable?.transformToByteArray === "function") {
340
+ return copyBytes(await transformable.transformToByteArray());
341
+ }
342
+
343
+ if (typeof transformable?.transformToString === "function") {
344
+ return new TextEncoder().encode(await transformable.transformToString());
345
+ }
346
+
347
+ if (isAsyncIterable(body)) {
348
+ return asyncIterableToBytes(body);
349
+ }
350
+
351
+ return streamToBytes(bodyToStream(body));
352
+ }
353
+
354
+ function createObjectBody(
355
+ object: StorageObject,
356
+ body: unknown,
357
+ ): StorageObjectBody {
358
+ let bodyUsed = false;
359
+
360
+ function markConsumed(): void {
361
+ if (bodyUsed) {
362
+ throw new Error("Storage object body has already been consumed.");
363
+ }
364
+
365
+ bodyUsed = true;
366
+ }
367
+
368
+ return {
369
+ ...object,
370
+ get bodyUsed() {
371
+ return bodyUsed;
372
+ },
373
+ stream() {
374
+ markConsumed();
375
+ return bodyToStream(body);
376
+ },
377
+ async bytes() {
378
+ markConsumed();
379
+ return bodyToBytes(body);
380
+ },
381
+ async arrayBuffer() {
382
+ markConsumed();
383
+ return bytesToArrayBuffer(await bodyToBytes(body));
384
+ },
385
+ async text() {
386
+ markConsumed();
387
+ if (typeof body === "string") return body;
388
+
389
+ const transformable = body as TransformableBody | null;
390
+ if (typeof transformable?.transformToString === "function") {
391
+ return transformable.transformToString();
392
+ }
393
+
394
+ return new TextDecoder().decode(await bodyToBytes(body));
395
+ },
396
+ };
397
+ }
398
+
399
+ function metadataWithoutReserved(
400
+ metadata: Record<string, string> | undefined,
401
+ ): StorageMetadata {
402
+ const result: StorageMetadata = {};
403
+
404
+ for (const [key, value] of Object.entries(metadata ?? {})) {
405
+ if (key.toLowerCase() !== visibilityMetadataKey) {
406
+ result[key] = value;
407
+ }
408
+ }
409
+
410
+ return result;
411
+ }
412
+
413
+ function visibilityFromMetadata(
414
+ metadata: Record<string, string> | undefined,
415
+ ): StorageVisibility {
416
+ const visibility = metadata?.[visibilityMetadataKey];
417
+ return visibility === "public" ? "public" : "private";
418
+ }
419
+
420
+ function metadataForPut(
421
+ metadata: StorageMetadata | undefined,
422
+ visibility: StorageVisibility,
423
+ ): StorageMetadata {
424
+ return {
425
+ ...(metadata ?? {}),
426
+ [visibilityMetadataKey]: visibility,
427
+ };
428
+ }
429
+
430
+ function objectFromHead(
431
+ key: string,
432
+ output: {
433
+ ContentLength?: number;
434
+ ContentType?: string;
435
+ CacheControl?: string;
436
+ Metadata?: Record<string, string>;
437
+ LastModified?: Date;
438
+ },
439
+ ): StorageObject {
440
+ return {
441
+ key,
442
+ size: output.ContentLength ?? 0,
443
+ ...(output.ContentType !== undefined
444
+ ? { contentType: output.ContentType }
445
+ : {}),
446
+ ...(output.CacheControl !== undefined
447
+ ? { cacheControl: output.CacheControl }
448
+ : {}),
449
+ metadata: metadataWithoutReserved(output.Metadata),
450
+ visibility: visibilityFromMetadata(output.Metadata),
451
+ lastModified: output.LastModified ?? new Date(),
452
+ };
453
+ }
454
+
455
+ function isNotFoundError(error: unknown): boolean {
456
+ if (typeof error !== "object" || error === null) return false;
457
+ const candidate = error as {
458
+ name?: string;
459
+ Code?: string;
460
+ code?: string;
461
+ $metadata?: { httpStatusCode?: number };
462
+ };
463
+
464
+ return (
465
+ candidate.name === "NoSuchKey" ||
466
+ candidate.name === "NotFound" ||
467
+ candidate.Code === "NoSuchKey" ||
468
+ candidate.code === "NoSuchKey" ||
469
+ candidate.$metadata?.httpStatusCode === 404
470
+ );
471
+ }
472
+
473
+ function errorMessage(error: unknown): string {
474
+ return error instanceof Error ? error.message : String(error);
475
+ }
476
+
477
+ function createDefaultClient(options: S3StorageOptions): S3StorageClient {
478
+ return new S3Client({
479
+ ...options.clientConfig,
480
+ region: options.region ?? "us-east-1",
481
+ ...(options.endpoint !== undefined ? { endpoint: options.endpoint } : {}),
482
+ ...(options.credentials !== undefined
483
+ ? { credentials: options.credentials }
484
+ : {}),
485
+ ...(options.forcePathStyle !== undefined
486
+ ? { forcePathStyle: options.forcePathStyle }
487
+ : {}),
488
+ });
489
+ }
490
+
491
+ export function createS3Storage(options: S3StorageOptions): StoragePort {
492
+ const client = options.client ?? createDefaultClient(options);
493
+ const keyPrefix = normalizeKeyPrefix(options.keyPrefix);
494
+ const devtools = createProviderDevtools(options.devtools, {
495
+ providerName: "storage-s3",
496
+ watcher: "storage",
497
+ });
498
+
499
+ const recordStorageEvent = (event: StorageEvent) => {
500
+ devtools.custom({
501
+ name: `storage.${event.operation}`,
502
+ label: `Storage ${event.operation}`,
503
+ summary: event.summary,
504
+ details: {
505
+ provider: "s3",
506
+ operation: event.operation,
507
+ key: event.key,
508
+ bucket: options.bucket,
509
+ ...event.details,
510
+ },
511
+ });
512
+ };
513
+
514
+ async function statObject(key: string): Promise<StorageObject | null> {
515
+ const s3Key = objectKey(keyPrefix, key);
516
+
517
+ try {
518
+ const output = (await client.send(
519
+ new HeadObjectCommand({
520
+ Bucket: options.bucket,
521
+ Key: s3Key,
522
+ }),
523
+ )) as Parameters<typeof objectFromHead>[1];
524
+
525
+ return objectFromHead(key, output);
526
+ } catch (error) {
527
+ if (isNotFoundError(error)) return null;
528
+ throw error;
529
+ }
530
+ }
531
+
532
+ return {
533
+ async put(key, body, putOptions) {
534
+ const startedAt = Date.now();
535
+
536
+ try {
537
+ const s3Key = objectKey(keyPrefix, key);
538
+ const visibility = putOptions?.visibility ?? "private";
539
+
540
+ await client.send(
541
+ new PutObjectCommand({
542
+ Bucket: options.bucket,
543
+ Key: s3Key,
544
+ Body: toPutBody(body),
545
+ ...(putOptions?.contentType !== undefined
546
+ ? { ContentType: putOptions.contentType }
547
+ : {}),
548
+ ...(putOptions?.cacheControl !== undefined
549
+ ? { CacheControl: putOptions.cacheControl }
550
+ : {}),
551
+ Metadata: metadataForPut(putOptions?.metadata, visibility),
552
+ }),
553
+ );
554
+
555
+ const object =
556
+ (await statObject(key)) ??
557
+ ({
558
+ key,
559
+ size: 0,
560
+ ...(putOptions?.contentType !== undefined
561
+ ? { contentType: putOptions.contentType }
562
+ : {}),
563
+ ...(putOptions?.cacheControl !== undefined
564
+ ? { cacheControl: putOptions.cacheControl }
565
+ : {}),
566
+ metadata: { ...(putOptions?.metadata ?? {}) },
567
+ visibility,
568
+ lastModified: new Date(),
569
+ } satisfies StorageObject);
570
+
571
+ recordStorageEvent({
572
+ operation: "put",
573
+ key,
574
+ summary: "Storage object written",
575
+ details: {
576
+ s3Key,
577
+ size: object.size,
578
+ visibility: object.visibility,
579
+ contentType: object.contentType ?? null,
580
+ metadataKeys: Object.keys(object.metadata),
581
+ durationMs: Date.now() - startedAt,
582
+ },
583
+ });
584
+
585
+ return object;
586
+ } catch (error) {
587
+ recordStorageEvent({
588
+ operation: "put.failed",
589
+ key,
590
+ summary: "Storage put failed",
591
+ details: {
592
+ durationMs: Date.now() - startedAt,
593
+ error: errorMessage(error),
594
+ },
595
+ });
596
+ throw error;
597
+ }
598
+ },
599
+
600
+ async get(key) {
601
+ const startedAt = Date.now();
602
+
603
+ try {
604
+ const s3Key = objectKey(keyPrefix, key);
605
+ const output = (await client.send(
606
+ new GetObjectCommand({
607
+ Bucket: options.bucket,
608
+ Key: s3Key,
609
+ }),
610
+ )) as Parameters<typeof objectFromHead>[1] & { Body?: unknown };
611
+
612
+ if (output.Body === undefined) return null;
613
+ const object = objectFromHead(key, output);
614
+
615
+ recordStorageEvent({
616
+ operation: "get",
617
+ key,
618
+ summary: "Storage object read",
619
+ details: {
620
+ s3Key,
621
+ hit: true,
622
+ size: object.size,
623
+ durationMs: Date.now() - startedAt,
624
+ },
625
+ });
626
+
627
+ return createObjectBody(object, output.Body);
628
+ } catch (error) {
629
+ if (isNotFoundError(error)) {
630
+ recordStorageEvent({
631
+ operation: "get",
632
+ key,
633
+ summary: "Storage object missing",
634
+ details: {
635
+ hit: false,
636
+ durationMs: Date.now() - startedAt,
637
+ },
638
+ });
639
+ return null;
640
+ }
641
+
642
+ recordStorageEvent({
643
+ operation: "get.failed",
644
+ key,
645
+ summary: "Storage get failed",
646
+ details: {
647
+ durationMs: Date.now() - startedAt,
648
+ error: errorMessage(error),
649
+ },
650
+ });
651
+ throw error;
652
+ }
653
+ },
654
+
655
+ async stat(key) {
656
+ const startedAt = Date.now();
657
+
658
+ try {
659
+ const object = await statObject(key);
660
+ recordStorageEvent({
661
+ operation: "stat",
662
+ key,
663
+ summary: object ? "Storage object found" : "Storage object missing",
664
+ details: {
665
+ hit: object !== null,
666
+ size: object?.size ?? null,
667
+ durationMs: Date.now() - startedAt,
668
+ },
669
+ });
670
+ return object;
671
+ } catch (error) {
672
+ recordStorageEvent({
673
+ operation: "stat.failed",
674
+ key,
675
+ summary: "Storage stat failed",
676
+ details: {
677
+ durationMs: Date.now() - startedAt,
678
+ error: errorMessage(error),
679
+ },
680
+ });
681
+ throw error;
682
+ }
683
+ },
684
+
685
+ async delete(key) {
686
+ const startedAt = Date.now();
687
+
688
+ try {
689
+ const s3Key = objectKey(keyPrefix, key);
690
+ const existed = (await statObject(key)) !== null;
691
+
692
+ await client.send(
693
+ new DeleteObjectCommand({
694
+ Bucket: options.bucket,
695
+ Key: s3Key,
696
+ }),
697
+ );
698
+
699
+ recordStorageEvent({
700
+ operation: "delete",
701
+ key,
702
+ summary: existed
703
+ ? "Storage object deleted"
704
+ : "Storage object missing",
705
+ details: {
706
+ s3Key,
707
+ deleted: existed,
708
+ durationMs: Date.now() - startedAt,
709
+ },
710
+ });
711
+
712
+ return existed;
713
+ } catch (error) {
714
+ recordStorageEvent({
715
+ operation: "delete.failed",
716
+ key,
717
+ summary: "Storage delete failed",
718
+ details: {
719
+ durationMs: Date.now() - startedAt,
720
+ error: errorMessage(error),
721
+ },
722
+ });
723
+ throw error;
724
+ }
725
+ },
726
+
727
+ async exists(key) {
728
+ const startedAt = Date.now();
729
+
730
+ try {
731
+ const exists = (await statObject(key)) !== null;
732
+ recordStorageEvent({
733
+ operation: "exists",
734
+ key,
735
+ summary: exists ? "Storage object exists" : "Storage object missing",
736
+ details: {
737
+ exists,
738
+ durationMs: Date.now() - startedAt,
739
+ },
740
+ });
741
+ return exists;
742
+ } catch (error) {
743
+ recordStorageEvent({
744
+ operation: "exists.failed",
745
+ key,
746
+ summary: "Storage exists failed",
747
+ details: {
748
+ durationMs: Date.now() - startedAt,
749
+ error: errorMessage(error),
750
+ },
751
+ });
752
+ throw error;
753
+ }
754
+ },
755
+
756
+ async publicUrl(key) {
757
+ const startedAt = Date.now();
758
+
759
+ try {
760
+ const s3Key = objectKey(keyPrefix, key);
761
+ const object = await statObject(key);
762
+ const url =
763
+ object?.visibility === "public" && options.publicBaseUrl
764
+ ? joinPublicUrl(options.publicBaseUrl, s3Key)
765
+ : null;
766
+
767
+ recordStorageEvent({
768
+ operation: "publicUrl",
769
+ key,
770
+ summary: url ? "Storage public URL resolved" : "No public URL",
771
+ details: {
772
+ s3Key,
773
+ hit: object !== null,
774
+ visibility: object?.visibility ?? null,
775
+ hasPublicUrl: url !== null,
776
+ durationMs: Date.now() - startedAt,
777
+ },
778
+ });
779
+
780
+ return url;
781
+ } catch (error) {
782
+ recordStorageEvent({
783
+ operation: "publicUrl.failed",
784
+ key,
785
+ summary: "Storage public URL failed",
786
+ details: {
787
+ durationMs: Date.now() - startedAt,
788
+ error: errorMessage(error),
789
+ },
790
+ });
791
+ throw error;
792
+ }
793
+ },
794
+ };
795
+ }
796
+
797
+ export interface S3StorageProviderPorts {
798
+ storage: StoragePort;
799
+ s3Storage: {
800
+ client: S3StorageClient;
801
+ bucket: string;
802
+ keyPrefix: string;
803
+ objectKey(key: string): string;
804
+ objectPrefix(prefix: string): string;
805
+ };
806
+ }
807
+
808
+ export function createS3StorageProvider(
809
+ options: S3StorageProviderOptions = {},
810
+ ) {
811
+ const ConfigSchema = S3StorageConfigSchema.extend({
812
+ BUCKET:
813
+ options.bucket !== undefined
814
+ ? z.string().min(1).default(options.bucket)
815
+ : S3StorageConfigSchema.shape.BUCKET,
816
+ REGION: z
817
+ .string()
818
+ .min(1)
819
+ .default(options.region ?? "us-east-1"),
820
+ ENDPOINT:
821
+ options.endpoint !== undefined
822
+ ? z.string().url().default(options.endpoint)
823
+ : S3StorageConfigSchema.shape.ENDPOINT,
824
+ PUBLIC_BASE_URL:
825
+ options.publicBaseUrl !== undefined
826
+ ? z.string().min(1).default(options.publicBaseUrl)
827
+ : S3StorageConfigSchema.shape.PUBLIC_BASE_URL,
828
+ FORCE_PATH_STYLE:
829
+ options.forcePathStyle !== undefined
830
+ ? BooleanString.default(options.forcePathStyle)
831
+ : S3StorageConfigSchema.shape.FORCE_PATH_STYLE,
832
+ KEY_PREFIX:
833
+ options.keyPrefix !== undefined
834
+ ? z.string().default(options.keyPrefix)
835
+ : S3StorageConfigSchema.shape.KEY_PREFIX,
836
+ });
837
+
838
+ return createProvider({
839
+ name: options.name ?? "storage-s3",
840
+
841
+ config: {
842
+ schema: ConfigSchema,
843
+ envPrefix: "STORAGE_S3_",
844
+ },
845
+
846
+ async setup({ ports, config }) {
847
+ if (!config?.BUCKET) {
848
+ throw new Error(
849
+ "[s3StorageProvider] Missing S3 storage config. " +
850
+ "Please set STORAGE_S3_BUCKET.",
851
+ );
852
+ }
853
+
854
+ const keyPrefix = normalizeKeyPrefix(
855
+ config.KEY_PREFIX ?? options.keyPrefix,
856
+ );
857
+ const client =
858
+ options.createClient?.(config) ??
859
+ createDefaultClient({
860
+ ...options,
861
+ bucket: config.BUCKET,
862
+ region: config.REGION ?? options.region,
863
+ endpoint: config.ENDPOINT ?? options.endpoint,
864
+ publicBaseUrl: config.PUBLIC_BASE_URL ?? options.publicBaseUrl,
865
+ forcePathStyle: config.FORCE_PATH_STYLE ?? options.forcePathStyle,
866
+ keyPrefix,
867
+ credentials:
868
+ config.ACCESS_KEY_ID && config.SECRET_ACCESS_KEY
869
+ ? {
870
+ accessKeyId: config.ACCESS_KEY_ID,
871
+ secretAccessKey: config.SECRET_ACCESS_KEY,
872
+ ...(config.SESSION_TOKEN !== undefined
873
+ ? { sessionToken: config.SESSION_TOKEN }
874
+ : {}),
875
+ }
876
+ : options.credentials,
877
+ });
878
+
879
+ return {
880
+ ports: {
881
+ storage: createS3Storage({
882
+ ...options,
883
+ bucket: config.BUCKET,
884
+ client,
885
+ publicBaseUrl: config.PUBLIC_BASE_URL ?? options.publicBaseUrl,
886
+ keyPrefix,
887
+ devtools: ports,
888
+ }),
889
+ s3Storage: {
890
+ client,
891
+ bucket: config.BUCKET,
892
+ keyPrefix,
893
+ objectKey(key) {
894
+ return objectKey(keyPrefix, key);
895
+ },
896
+ objectPrefix(prefix) {
897
+ return objectPrefix(keyPrefix, prefix);
898
+ },
899
+ },
900
+ } satisfies S3StorageProviderPorts,
901
+ };
902
+ },
903
+ });
904
+ }
905
+
906
+ export const s3StorageProvider = createS3StorageProvider();