@chidchanun/bcp 0.1.25 → 0.1.26

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.
@@ -0,0 +1,1159 @@
1
+ import {
2
+ createHash,
3
+ } from "node:crypto";
4
+ import {
5
+ Readable,
6
+ } from "node:stream";
7
+
8
+ import {
9
+ DeleteObjectCommand,
10
+ GetObjectCommand,
11
+ HeadObjectCommand,
12
+ PutObjectCommand,
13
+ S3Client,
14
+ } from "@aws-sdk/client-s3";
15
+ import {
16
+ Upload,
17
+ } from "@aws-sdk/lib-storage";
18
+
19
+ import {
20
+ normalizeStorageKey,
21
+ StorageError,
22
+ type StorageAdapter,
23
+ type StorageObjectMetadata,
24
+ type StoragePutOptions,
25
+ type StoragePutStreamOptions,
26
+ type StorageReadableStream,
27
+ type StorageReadOptions,
28
+ type StorageWriteValue,
29
+ } from "./storage.js";
30
+
31
+ const MIN_MULTIPART_PART_BYTES =
32
+ 5 * 1024 * 1024;
33
+
34
+ export interface S3StorageMultipartOptions {
35
+ partSize?: number;
36
+ queueSize?: number;
37
+ }
38
+
39
+ export interface S3StorageOptions {
40
+ bucket: string;
41
+ region: string;
42
+ endpoint?: string;
43
+ forcePathStyle?: boolean;
44
+ prefix?: string;
45
+ accessKeyId?: string;
46
+ secretAccessKey?: string;
47
+ sessionToken?: string;
48
+ multipart?: S3StorageMultipartOptions;
49
+ client?: S3Client;
50
+ }
51
+
52
+ export interface S3StorageAdapter
53
+ extends StorageAdapter {
54
+ readonly client: S3Client;
55
+ destroy(): void;
56
+ }
57
+
58
+ export function createS3Storage(
59
+ options: S3StorageOptions
60
+ ): S3StorageAdapter {
61
+ const bucket =
62
+ assertNonEmptyString(
63
+ options.bucket,
64
+ "bucket"
65
+ );
66
+ const region =
67
+ assertNonEmptyString(
68
+ options.region,
69
+ "region"
70
+ );
71
+ const prefix =
72
+ normalizePrefix(
73
+ options.prefix
74
+ );
75
+ const multipart =
76
+ normalizeMultipartOptions(
77
+ options.multipart
78
+ );
79
+ const ownsClient =
80
+ options.client === undefined;
81
+ const client =
82
+ options.client ??
83
+ new S3Client({
84
+ region,
85
+ ...(options.endpoint === undefined
86
+ ? {}
87
+ : {
88
+ endpoint:
89
+ assertNonEmptyString(
90
+ options.endpoint,
91
+ "endpoint"
92
+ ),
93
+ }),
94
+ forcePathStyle:
95
+ options.forcePathStyle ??
96
+ false,
97
+ ...createCredentials(
98
+ options
99
+ ),
100
+ });
101
+
102
+ const providerKey =
103
+ (key: string) => {
104
+ const normalizedKey =
105
+ normalizeStorageKey(
106
+ key
107
+ );
108
+
109
+ return {
110
+ normalizedKey,
111
+ providerKey:
112
+ prefix.length === 0
113
+ ? normalizedKey
114
+ : `${prefix}/${normalizedKey}`,
115
+ };
116
+ };
117
+
118
+ const statObject =
119
+ async (
120
+ key: string
121
+ ): Promise<StorageObjectMetadata | null> => {
122
+ const resolved =
123
+ providerKey(
124
+ key
125
+ );
126
+
127
+ try {
128
+ const response =
129
+ await client.send(
130
+ new HeadObjectCommand({
131
+ Bucket:
132
+ bucket,
133
+ Key:
134
+ resolved.providerKey,
135
+ })
136
+ );
137
+
138
+ return createS3Metadata(
139
+ resolved.normalizedKey,
140
+ response.ContentLength ??
141
+ 0,
142
+ response.ContentType ??
143
+ "application/octet-stream",
144
+ response.LastModified ??
145
+ new Date(0),
146
+ response.ETag,
147
+ response.Metadata
148
+ ?.bcp_sha256
149
+ );
150
+ } catch (error) {
151
+ if (
152
+ isS3Status(
153
+ error,
154
+ 404
155
+ )
156
+ ) {
157
+ return null;
158
+ }
159
+
160
+ throw error;
161
+ }
162
+ };
163
+
164
+ const readStream =
165
+ async (
166
+ key: string,
167
+ readOptions:
168
+ StorageReadOptions = {}
169
+ ): Promise<StorageReadableStream> => {
170
+ const resolved =
171
+ providerKey(
172
+ key
173
+ );
174
+ const metadata =
175
+ await statObject(
176
+ resolved.normalizedKey
177
+ );
178
+
179
+ if (!metadata) {
180
+ throw objectNotFound(
181
+ resolved.normalizedKey
182
+ );
183
+ }
184
+
185
+ const range =
186
+ normalizeRemoteRange(
187
+ resolved.normalizedKey,
188
+ metadata.size,
189
+ readOptions
190
+ );
191
+
192
+ try {
193
+ const response =
194
+ await client.send(
195
+ new GetObjectCommand({
196
+ Bucket:
197
+ bucket,
198
+ Key:
199
+ resolved.providerKey,
200
+ ...(range === null
201
+ ? {}
202
+ : {
203
+ Range:
204
+ `bytes=${range.start}-${range.end}`,
205
+ }),
206
+ })
207
+ );
208
+
209
+ if (!response.Body) {
210
+ return emptyStorageStream();
211
+ }
212
+
213
+ return ownStorageStream(
214
+ response.Body
215
+ .transformToWebStream() as
216
+ ReadableStream<
217
+ Uint8Array
218
+ >
219
+ );
220
+ } catch (error) {
221
+ if (
222
+ isS3Status(
223
+ error,
224
+ 404
225
+ )
226
+ ) {
227
+ throw objectNotFound(
228
+ resolved.normalizedKey
229
+ );
230
+ }
231
+
232
+ if (
233
+ isS3Status(
234
+ error,
235
+ 416
236
+ )
237
+ ) {
238
+ throw rangeNotSatisfiable(
239
+ resolved.normalizedKey,
240
+ metadata.size
241
+ );
242
+ }
243
+
244
+ throw error;
245
+ }
246
+ };
247
+
248
+ return {
249
+ client,
250
+
251
+ capabilities: {
252
+ streamingRead: true,
253
+ streamingWrite: true,
254
+ ranges: true,
255
+ signedUrls: false,
256
+ listing: false,
257
+ },
258
+
259
+ async put(
260
+ key,
261
+ value,
262
+ putOptions:
263
+ StoragePutOptions = {}
264
+ ) {
265
+ const resolved =
266
+ providerKey(
267
+ key
268
+ );
269
+ const bytes =
270
+ await toStorageBytes(
271
+ value
272
+ );
273
+ const checksumSha256 =
274
+ createHash(
275
+ "sha256"
276
+ )
277
+ .update(
278
+ bytes
279
+ )
280
+ .digest(
281
+ "hex"
282
+ );
283
+ const contentType =
284
+ normalizeContentType(
285
+ putOptions.contentType ??
286
+ (
287
+ value instanceof Blob
288
+ ? value.type
289
+ : ""
290
+ )
291
+ );
292
+
293
+ try {
294
+ await client.send(
295
+ new PutObjectCommand({
296
+ Bucket:
297
+ bucket,
298
+ Key:
299
+ resolved.providerKey,
300
+ Body:
301
+ bytes,
302
+ ContentLength:
303
+ bytes.byteLength,
304
+ ContentType:
305
+ contentType,
306
+ Metadata: {
307
+ bcp_sha256:
308
+ checksumSha256,
309
+ },
310
+ ...(putOptions.overwrite
311
+ ? {}
312
+ : {
313
+ IfNoneMatch:
314
+ "*",
315
+ }),
316
+ })
317
+ );
318
+ } catch (error) {
319
+ const mapped =
320
+ mapWriteError(
321
+ error,
322
+ resolved.normalizedKey
323
+ );
324
+
325
+ if (mapped) {
326
+ throw mapped;
327
+ }
328
+
329
+ throw error;
330
+ }
331
+
332
+ return requireStoredMetadata(
333
+ statObject,
334
+ resolved.normalizedKey
335
+ );
336
+ },
337
+
338
+ async putStream(
339
+ key,
340
+ stream,
341
+ putOptions:
342
+ StoragePutStreamOptions = {}
343
+ ) {
344
+ const resolved =
345
+ providerKey(
346
+ key
347
+ );
348
+
349
+ if (
350
+ !putOptions.overwrite &&
351
+ await statObject(
352
+ resolved.normalizedKey
353
+ ) !== null
354
+ ) {
355
+ throw objectExists(
356
+ resolved.normalizedKey
357
+ );
358
+ }
359
+
360
+ const contentType =
361
+ normalizeContentType(
362
+ putOptions.contentType ??
363
+ ""
364
+ );
365
+ const body =
366
+ createLimitedNodeReadable(
367
+ stream,
368
+ putOptions.maxBytes,
369
+ putOptions.signal
370
+ );
371
+ const upload =
372
+ new Upload({
373
+ client,
374
+ params: {
375
+ Bucket:
376
+ bucket,
377
+ Key:
378
+ resolved.providerKey,
379
+ Body:
380
+ body,
381
+ ContentType:
382
+ contentType,
383
+ },
384
+ leavePartsOnError:
385
+ false,
386
+ ...(multipart.partSize === undefined
387
+ ? {}
388
+ : {
389
+ partSize:
390
+ multipart.partSize,
391
+ }),
392
+ ...(multipart.queueSize === undefined
393
+ ? {}
394
+ : {
395
+ queueSize:
396
+ multipart.queueSize,
397
+ }),
398
+ });
399
+
400
+ const onAbort =
401
+ () => {
402
+ void upload.abort();
403
+ };
404
+
405
+ putOptions.signal
406
+ ?.addEventListener(
407
+ "abort",
408
+ onAbort,
409
+ {
410
+ once: true,
411
+ }
412
+ );
413
+
414
+ try {
415
+ putOptions.signal
416
+ ?.throwIfAborted();
417
+ await upload.done();
418
+ putOptions.signal
419
+ ?.throwIfAborted();
420
+ } catch (error) {
421
+ if (
422
+ putOptions.signal
423
+ ?.aborted
424
+ ) {
425
+ throw putOptions.signal
426
+ .reason ??
427
+ error;
428
+ }
429
+
430
+ const mapped =
431
+ mapWriteError(
432
+ error,
433
+ resolved.normalizedKey
434
+ );
435
+
436
+ if (mapped) {
437
+ throw mapped;
438
+ }
439
+
440
+ throw error;
441
+ } finally {
442
+ putOptions.signal
443
+ ?.removeEventListener(
444
+ "abort",
445
+ onAbort
446
+ );
447
+ }
448
+
449
+ return requireStoredMetadata(
450
+ statObject,
451
+ resolved.normalizedKey
452
+ );
453
+ },
454
+
455
+ stat:
456
+ statObject,
457
+
458
+ async read(
459
+ key,
460
+ readOptions:
461
+ StorageReadOptions = {}
462
+ ) {
463
+ return collectStorageStream(
464
+ await readStream(
465
+ key,
466
+ readOptions
467
+ )
468
+ );
469
+ },
470
+
471
+ readStream,
472
+
473
+ async exists(key) {
474
+ return (
475
+ await statObject(
476
+ key
477
+ )
478
+ ) !== null;
479
+ },
480
+
481
+ async delete(key) {
482
+ const resolved =
483
+ providerKey(
484
+ key
485
+ );
486
+
487
+ if (
488
+ await statObject(
489
+ resolved.normalizedKey
490
+ ) === null
491
+ ) {
492
+ return false;
493
+ }
494
+
495
+ await client.send(
496
+ new DeleteObjectCommand({
497
+ Bucket:
498
+ bucket,
499
+ Key:
500
+ resolved.providerKey,
501
+ })
502
+ );
503
+
504
+ return true;
505
+ },
506
+
507
+ destroy() {
508
+ if (ownsClient) {
509
+ client.destroy();
510
+ }
511
+ },
512
+ };
513
+ }
514
+
515
+ function createCredentials(
516
+ options: S3StorageOptions
517
+ ): {
518
+ credentials: {
519
+ accessKeyId: string;
520
+ secretAccessKey: string;
521
+ sessionToken?: string;
522
+ };
523
+ } | Record<string, never> {
524
+ const hasAccessKey =
525
+ typeof options.accessKeyId ===
526
+ "string" &&
527
+ options.accessKeyId.trim().length >
528
+ 0;
529
+ const hasSecret =
530
+ typeof options.secretAccessKey ===
531
+ "string" &&
532
+ options.secretAccessKey.trim().length >
533
+ 0;
534
+
535
+ if (
536
+ hasAccessKey !== hasSecret
537
+ ) {
538
+ throw new TypeError(
539
+ "BCP Framework: S3 accessKeyId and secretAccessKey must be provided together."
540
+ );
541
+ }
542
+
543
+ if (!hasAccessKey) {
544
+ return {};
545
+ }
546
+
547
+ return {
548
+ credentials: {
549
+ accessKeyId:
550
+ options.accessKeyId!
551
+ .trim(),
552
+ secretAccessKey:
553
+ options.secretAccessKey!
554
+ .trim(),
555
+ ...(typeof options.sessionToken ===
556
+ "string" &&
557
+ options.sessionToken.trim().length >
558
+ 0
559
+ ? {
560
+ sessionToken:
561
+ options.sessionToken.trim(),
562
+ }
563
+ : {}),
564
+ },
565
+ };
566
+ }
567
+
568
+ function normalizeMultipartOptions(
569
+ value:
570
+ S3StorageMultipartOptions |
571
+ undefined
572
+ ): S3StorageMultipartOptions {
573
+ if (!value) {
574
+ return {};
575
+ }
576
+
577
+ if (
578
+ value.partSize !== undefined &&
579
+ (
580
+ !Number.isSafeInteger(
581
+ value.partSize
582
+ ) ||
583
+ value.partSize <
584
+ MIN_MULTIPART_PART_BYTES
585
+ )
586
+ ) {
587
+ throw new TypeError(
588
+ `BCP Framework: S3 multipart partSize must be at least ${MIN_MULTIPART_PART_BYTES} bytes.`
589
+ );
590
+ }
591
+
592
+ if (
593
+ value.queueSize !== undefined &&
594
+ (
595
+ !Number.isSafeInteger(
596
+ value.queueSize
597
+ ) ||
598
+ value.queueSize < 1
599
+ )
600
+ ) {
601
+ throw new TypeError(
602
+ "BCP Framework: S3 multipart queueSize must be a positive safe integer."
603
+ );
604
+ }
605
+
606
+ return {
607
+ ...(value.partSize === undefined
608
+ ? {}
609
+ : {
610
+ partSize:
611
+ value.partSize,
612
+ }),
613
+ ...(value.queueSize === undefined
614
+ ? {}
615
+ : {
616
+ queueSize:
617
+ value.queueSize,
618
+ }),
619
+ };
620
+ }
621
+
622
+ function normalizePrefix(
623
+ value: string | undefined
624
+ ): string {
625
+ if (value === undefined) {
626
+ return "";
627
+ }
628
+
629
+ const trimmed =
630
+ value
631
+ .trim()
632
+ .replace(
633
+ /^\/+|\/+$/g,
634
+ ""
635
+ );
636
+
637
+ if (trimmed.length === 0) {
638
+ return "";
639
+ }
640
+
641
+ return normalizeStorageKey(
642
+ trimmed
643
+ );
644
+ }
645
+
646
+ async function toStorageBytes(
647
+ value: StorageWriteValue
648
+ ): Promise<Uint8Array> {
649
+ if (
650
+ typeof value ===
651
+ "string"
652
+ ) {
653
+ return Uint8Array.from(
654
+ Buffer.from(
655
+ value,
656
+ "utf8"
657
+ )
658
+ );
659
+ }
660
+
661
+ if (
662
+ value instanceof Blob
663
+ ) {
664
+ return new Uint8Array(
665
+ await value.arrayBuffer()
666
+ );
667
+ }
668
+
669
+ if (
670
+ value instanceof ArrayBuffer
671
+ ) {
672
+ return new Uint8Array(
673
+ value
674
+ );
675
+ }
676
+
677
+ if (
678
+ ArrayBuffer.isView(
679
+ value
680
+ )
681
+ ) {
682
+ return Uint8Array.from(
683
+ new Uint8Array(
684
+ value.buffer,
685
+ value.byteOffset,
686
+ value.byteLength
687
+ )
688
+ );
689
+ }
690
+
691
+ throw new TypeError(
692
+ "BCP Framework: unsupported S3 storage write value."
693
+ );
694
+ }
695
+
696
+ function createLimitedNodeReadable(
697
+ stream:
698
+ ReadableStream<Uint8Array>,
699
+ maxBytes: number | undefined,
700
+ signal: AbortSignal | undefined
701
+ ): Readable {
702
+ const normalizedLimit =
703
+ normalizeOptionalByteLimit(
704
+ maxBytes
705
+ );
706
+
707
+ return Readable.from(
708
+ (async function* () {
709
+ const reader =
710
+ stream.getReader();
711
+ let totalBytes =
712
+ 0;
713
+ const onAbort =
714
+ () => {
715
+ void reader.cancel(
716
+ signal?.reason
717
+ );
718
+ };
719
+
720
+ signal?.addEventListener(
721
+ "abort",
722
+ onAbort,
723
+ {
724
+ once: true,
725
+ }
726
+ );
727
+
728
+ try {
729
+ while (true) {
730
+ signal?.throwIfAborted();
731
+
732
+ const result =
733
+ await reader.read();
734
+
735
+ if (result.done) {
736
+ break;
737
+ }
738
+
739
+ const chunk =
740
+ result.value;
741
+ const nextTotal =
742
+ totalBytes +
743
+ chunk.byteLength;
744
+
745
+ if (
746
+ normalizedLimit !== undefined &&
747
+ nextTotal >
748
+ normalizedLimit
749
+ ) {
750
+ throw new StorageError(
751
+ "STREAM_TOO_LARGE",
752
+ `BCP Framework: storage stream exceeded ${normalizedLimit} bytes.`,
753
+ 413
754
+ );
755
+ }
756
+
757
+ totalBytes =
758
+ nextTotal;
759
+
760
+ yield Buffer.from(
761
+ chunk
762
+ );
763
+ }
764
+ } catch (error) {
765
+ try {
766
+ await reader.cancel(
767
+ error
768
+ );
769
+ } catch {
770
+ // Preserve the source/upload failure.
771
+ }
772
+
773
+ throw error;
774
+ } finally {
775
+ signal?.removeEventListener(
776
+ "abort",
777
+ onAbort
778
+ );
779
+ reader.releaseLock();
780
+ }
781
+ })()
782
+ );
783
+ }
784
+
785
+ async function collectStorageStream(
786
+ stream: StorageReadableStream
787
+ ): Promise<Uint8Array<ArrayBuffer>> {
788
+ const reader =
789
+ stream.getReader();
790
+ const chunks:
791
+ Uint8Array<ArrayBuffer>[] = [];
792
+ let totalBytes =
793
+ 0;
794
+
795
+ try {
796
+ while (true) {
797
+ const result =
798
+ await reader.read();
799
+
800
+ if (result.done) {
801
+ break;
802
+ }
803
+
804
+ const chunk =
805
+ result.value;
806
+ const owned =
807
+ new Uint8Array(
808
+ chunk.byteLength
809
+ );
810
+ owned.set(
811
+ chunk
812
+ );
813
+ chunks.push(
814
+ owned
815
+ );
816
+ totalBytes +=
817
+ owned.byteLength;
818
+ }
819
+ } finally {
820
+ reader.releaseLock();
821
+ }
822
+
823
+ const result =
824
+ new Uint8Array(
825
+ totalBytes
826
+ );
827
+ let offset =
828
+ 0;
829
+
830
+ for (const chunk of chunks) {
831
+ result.set(
832
+ chunk,
833
+ offset
834
+ );
835
+ offset +=
836
+ chunk.byteLength;
837
+ }
838
+
839
+ return result;
840
+ }
841
+
842
+ function ownStorageStream(
843
+ source:
844
+ ReadableStream<Uint8Array>
845
+ ): StorageReadableStream {
846
+ const reader =
847
+ source.getReader();
848
+
849
+ return new ReadableStream<
850
+ Uint8Array<ArrayBuffer>
851
+ >({
852
+ async pull(controller) {
853
+ try {
854
+ const result =
855
+ await reader.read();
856
+
857
+ if (result.done) {
858
+ reader.releaseLock();
859
+ controller.close();
860
+ return;
861
+ }
862
+
863
+ const owned =
864
+ new Uint8Array(
865
+ result.value.byteLength
866
+ );
867
+ owned.set(
868
+ result.value
869
+ );
870
+ controller.enqueue(
871
+ owned
872
+ );
873
+ } catch (error) {
874
+ try {
875
+ reader.releaseLock();
876
+ } catch {
877
+ // Ignore lock cleanup after failure.
878
+ }
879
+ controller.error(
880
+ error
881
+ );
882
+ }
883
+ },
884
+
885
+ async cancel(reason) {
886
+ try {
887
+ await reader.cancel(
888
+ reason
889
+ );
890
+ } finally {
891
+ try {
892
+ reader.releaseLock();
893
+ } catch {
894
+ // Ignore duplicate release.
895
+ }
896
+ }
897
+ },
898
+ });
899
+ }
900
+
901
+ function emptyStorageStream(): StorageReadableStream {
902
+ return new ReadableStream<
903
+ Uint8Array<ArrayBuffer>
904
+ >({
905
+ start(controller) {
906
+ controller.close();
907
+ },
908
+ });
909
+ }
910
+
911
+ function normalizeRemoteRange(
912
+ key: string,
913
+ size: number,
914
+ options: StorageReadOptions
915
+ ): {
916
+ start: number;
917
+ end: number;
918
+ } | null {
919
+ if (
920
+ options.start === undefined &&
921
+ options.end === undefined
922
+ ) {
923
+ return null;
924
+ }
925
+
926
+ const start =
927
+ options.start ??
928
+ 0;
929
+ const end =
930
+ options.end ??
931
+ size - 1;
932
+
933
+ if (
934
+ !Number.isSafeInteger(
935
+ start
936
+ ) ||
937
+ !Number.isSafeInteger(
938
+ end
939
+ ) ||
940
+ start < 0 ||
941
+ end < start ||
942
+ start >= size ||
943
+ end >= size
944
+ ) {
945
+ throw rangeNotSatisfiable(
946
+ key,
947
+ size
948
+ );
949
+ }
950
+
951
+ return {
952
+ start,
953
+ end,
954
+ };
955
+ }
956
+
957
+ function createS3Metadata(
958
+ key: string,
959
+ size: number,
960
+ contentType: string,
961
+ lastModified: Date,
962
+ etag: string | undefined,
963
+ checksumSha256: string | undefined
964
+ ): StorageObjectMetadata {
965
+ const normalizedChecksum =
966
+ typeof checksumSha256 ===
967
+ "string" &&
968
+ /^[a-f0-9]{64}$/i.test(
969
+ checksumSha256
970
+ )
971
+ ? checksumSha256
972
+ .toLowerCase()
973
+ : undefined;
974
+ const normalizedEtag =
975
+ typeof etag === "string" &&
976
+ etag.trim().length > 0
977
+ ? etag.trim()
978
+ : `W/"${size}-${Math.trunc(
979
+ lastModified.getTime()
980
+ )}"`;
981
+
982
+ return {
983
+ key,
984
+ size,
985
+ contentType:
986
+ normalizeContentType(
987
+ contentType
988
+ ),
989
+ lastModified:
990
+ new Date(
991
+ lastModified.getTime()
992
+ ),
993
+ etag:
994
+ normalizedEtag,
995
+ ...(normalizedChecksum === undefined
996
+ ? {}
997
+ : {
998
+ checksumSha256:
999
+ normalizedChecksum,
1000
+ }),
1001
+ };
1002
+ }
1003
+
1004
+ async function requireStoredMetadata(
1005
+ statObject:
1006
+ (
1007
+ key: string
1008
+ ) => Promise<
1009
+ StorageObjectMetadata |
1010
+ null
1011
+ >,
1012
+ key: string
1013
+ ): Promise<StorageObjectMetadata> {
1014
+ const metadata =
1015
+ await statObject(
1016
+ key
1017
+ );
1018
+
1019
+ if (!metadata) {
1020
+ throw new Error(
1021
+ `BCP Framework: S3 object "${key}" was written but could not be read back with HeadObject.`
1022
+ );
1023
+ }
1024
+
1025
+ return metadata;
1026
+ }
1027
+
1028
+ function mapWriteError(
1029
+ error: unknown,
1030
+ key: string
1031
+ ): StorageError | null {
1032
+ if (error instanceof StorageError) {
1033
+ return error;
1034
+ }
1035
+
1036
+ if (
1037
+ isS3Status(
1038
+ error,
1039
+ 409
1040
+ ) ||
1041
+ isS3Status(
1042
+ error,
1043
+ 412
1044
+ )
1045
+ ) {
1046
+ return objectExists(
1047
+ key
1048
+ );
1049
+ }
1050
+
1051
+ return null;
1052
+ }
1053
+
1054
+ function objectNotFound(
1055
+ key: string
1056
+ ): StorageError {
1057
+ return new StorageError(
1058
+ "OBJECT_NOT_FOUND",
1059
+ `BCP Framework: storage object "${key}" was not found.`,
1060
+ 404
1061
+ );
1062
+ }
1063
+
1064
+ function objectExists(
1065
+ key: string
1066
+ ): StorageError {
1067
+ return new StorageError(
1068
+ "OBJECT_EXISTS",
1069
+ `BCP Framework: storage object "${key}" already exists.`,
1070
+ 409
1071
+ );
1072
+ }
1073
+
1074
+ function rangeNotSatisfiable(
1075
+ key: string,
1076
+ size: number
1077
+ ): StorageError {
1078
+ return new StorageError(
1079
+ "RANGE_NOT_SATISFIABLE",
1080
+ `BCP Framework: requested storage range for "${key}" is invalid for ${size} bytes.`,
1081
+ 416
1082
+ );
1083
+ }
1084
+
1085
+ function isS3Status(
1086
+ error: unknown,
1087
+ status: number
1088
+ ): boolean {
1089
+ return (
1090
+ error instanceof Error &&
1091
+ "$metadata" in error &&
1092
+ (
1093
+ error as Error & {
1094
+ $metadata?: {
1095
+ httpStatusCode?: number;
1096
+ };
1097
+ }
1098
+ ).$metadata
1099
+ ?.httpStatusCode ===
1100
+ status
1101
+ );
1102
+ }
1103
+
1104
+ function normalizeContentType(
1105
+ value: string
1106
+ ): string {
1107
+ const normalized =
1108
+ value
1109
+ .trim()
1110
+ .toLowerCase();
1111
+
1112
+ if (
1113
+ normalized.length === 0 ||
1114
+ /[\r\n]/.test(
1115
+ normalized
1116
+ )
1117
+ ) {
1118
+ return "application/octet-stream";
1119
+ }
1120
+
1121
+ return normalized;
1122
+ }
1123
+
1124
+ function normalizeOptionalByteLimit(
1125
+ value: number | undefined
1126
+ ): number | undefined {
1127
+ if (value === undefined) {
1128
+ return undefined;
1129
+ }
1130
+
1131
+ if (
1132
+ !Number.isSafeInteger(
1133
+ value
1134
+ ) ||
1135
+ value < 0
1136
+ ) {
1137
+ throw new TypeError(
1138
+ "BCP Framework: S3 storage stream maxBytes must be a non-negative safe integer."
1139
+ );
1140
+ }
1141
+
1142
+ return value;
1143
+ }
1144
+
1145
+ function assertNonEmptyString(
1146
+ value: string,
1147
+ label: string
1148
+ ): string {
1149
+ if (
1150
+ typeof value !== "string" ||
1151
+ value.trim().length === 0
1152
+ ) {
1153
+ throw new TypeError(
1154
+ `BCP Framework: S3 storage ${label} must be a non-empty string.`
1155
+ );
1156
+ }
1157
+
1158
+ return value.trim();
1159
+ }