@chidchanun/bcp 0.1.24 → 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,1515 @@
1
+ import {
2
+ createHash,
3
+ randomUUID,
4
+ } from "node:crypto";
5
+ import {
6
+ mkdir,
7
+ open,
8
+ readFile,
9
+ rm,
10
+ stat,
11
+ unlink,
12
+ writeFile,
13
+ } from "node:fs/promises";
14
+ import path from "node:path";
15
+
16
+ import {
17
+ validateUploadedFile,
18
+ type UploadConstraints,
19
+ } from "./upload.js";
20
+
21
+ const METADATA_DIRECTORY =
22
+ ".bcp-storage-meta";
23
+ const STREAM_CHUNK_BYTES =
24
+ 64 * 1024;
25
+
26
+ export type StorageErrorCode =
27
+ | "INVALID_KEY"
28
+ | "OBJECT_NOT_FOUND"
29
+ | "OBJECT_EXISTS"
30
+ | "RANGE_NOT_SATISFIABLE"
31
+ | "STREAM_TOO_LARGE";
32
+
33
+ export class StorageError extends Error {
34
+ readonly code:
35
+ StorageErrorCode;
36
+ readonly status:
37
+ number;
38
+
39
+ constructor(
40
+ code: StorageErrorCode,
41
+ message: string,
42
+ status: number
43
+ ) {
44
+ super(message);
45
+ this.name =
46
+ "StorageError";
47
+ this.code =
48
+ code;
49
+ this.status =
50
+ status;
51
+ }
52
+ }
53
+
54
+ export interface StorageObjectMetadata {
55
+ key: string;
56
+ size: number;
57
+ contentType: string;
58
+ lastModified: Date;
59
+ etag: string;
60
+ checksumSha256?: string;
61
+ }
62
+
63
+ export interface StoragePutOptions {
64
+ contentType?: string;
65
+ overwrite?: boolean;
66
+ }
67
+
68
+ export interface StoragePutStreamOptions
69
+ extends StoragePutOptions {
70
+ maxBytes?: number;
71
+ signal?: AbortSignal;
72
+ }
73
+
74
+ export interface StorageReadOptions {
75
+ start?: number;
76
+ end?: number;
77
+ }
78
+
79
+ export interface StorageAdapterCapabilities {
80
+ streamingRead: boolean;
81
+ streamingWrite: boolean;
82
+ ranges: boolean;
83
+ signedUrls: boolean;
84
+ listing: boolean;
85
+ }
86
+
87
+ export type StorageReadableStream =
88
+ ReadableStream<
89
+ Uint8Array<ArrayBuffer>
90
+ >;
91
+
92
+ export interface StorageAdapter {
93
+ capabilities?:
94
+ Partial<
95
+ StorageAdapterCapabilities
96
+ >;
97
+
98
+ put(
99
+ key: string,
100
+ value: StorageWriteValue,
101
+ options?: StoragePutOptions
102
+ ): Promise<StorageObjectMetadata>;
103
+
104
+ putStream?(
105
+ key: string,
106
+ stream:
107
+ ReadableStream<Uint8Array>,
108
+ options?: StoragePutStreamOptions
109
+ ): Promise<StorageObjectMetadata>;
110
+
111
+ stat(
112
+ key: string
113
+ ): Promise<StorageObjectMetadata | null>;
114
+
115
+ read(
116
+ key: string,
117
+ options?: StorageReadOptions
118
+ ): Promise<Uint8Array>;
119
+
120
+ readStream?(
121
+ key: string,
122
+ options?: StorageReadOptions
123
+ ): Promise<StorageReadableStream>;
124
+
125
+ exists(
126
+ key: string
127
+ ): Promise<boolean>;
128
+
129
+ delete(
130
+ key: string
131
+ ): Promise<boolean>;
132
+ }
133
+
134
+ export type StorageWriteValue =
135
+ | string
136
+ | Blob
137
+ | ArrayBuffer
138
+ | ArrayBufferView;
139
+
140
+ export interface LocalStorageOptions {
141
+ directory: string;
142
+ }
143
+
144
+ export interface StoreUploadedFileOptions {
145
+ storage: StorageAdapter;
146
+ key?: string;
147
+ overwrite?: boolean;
148
+ constraints?: UploadConstraints;
149
+ }
150
+
151
+ export interface StoredUploadedObject
152
+ extends StorageObjectMetadata {
153
+ originalName: string;
154
+ }
155
+
156
+ interface LocalMetadataRecord {
157
+ version: 1;
158
+ key: string;
159
+ contentType: string;
160
+ checksumSha256: string;
161
+ }
162
+
163
+ export function createLocalStorage(
164
+ options: LocalStorageOptions
165
+ ): StorageAdapter {
166
+ const rootDirectory =
167
+ path.resolve(
168
+ assertNonEmptyString(
169
+ options.directory,
170
+ "directory"
171
+ )
172
+ );
173
+ const metadataDirectory =
174
+ path.join(
175
+ rootDirectory,
176
+ METADATA_DIRECTORY
177
+ );
178
+
179
+ const statObject =
180
+ async (
181
+ key: string
182
+ ): Promise<StorageObjectMetadata | null> => {
183
+ const normalizedKey =
184
+ normalizeStorageKey(
185
+ key
186
+ );
187
+ const destination =
188
+ resolveStoragePath(
189
+ rootDirectory,
190
+ normalizedKey
191
+ );
192
+
193
+ let fileStat;
194
+
195
+ try {
196
+ fileStat =
197
+ await stat(
198
+ destination
199
+ );
200
+ } catch (error) {
201
+ if (
202
+ isNodeErrorCode(
203
+ error,
204
+ "ENOENT"
205
+ )
206
+ ) {
207
+ return null;
208
+ }
209
+
210
+ throw error;
211
+ }
212
+
213
+ if (!fileStat.isFile()) {
214
+ return null;
215
+ }
216
+
217
+ const record =
218
+ await readMetadataRecord(
219
+ metadataDirectory,
220
+ normalizedKey
221
+ );
222
+
223
+ return createObjectMetadata(
224
+ normalizedKey,
225
+ fileStat.size,
226
+ fileStat.mtime,
227
+ record?.contentType ??
228
+ "application/octet-stream",
229
+ record?.checksumSha256
230
+ );
231
+ };
232
+
233
+ const putStream =
234
+ async (
235
+ key: string,
236
+ stream:
237
+ ReadableStream<Uint8Array>,
238
+ putOptions:
239
+ StoragePutStreamOptions = {}
240
+ ): Promise<StorageObjectMetadata> => {
241
+ const normalizedKey =
242
+ normalizeStorageKey(
243
+ key
244
+ );
245
+ const destination =
246
+ resolveStoragePath(
247
+ rootDirectory,
248
+ normalizedKey
249
+ );
250
+ const contentType =
251
+ normalizeContentType(
252
+ putOptions.contentType ??
253
+ ""
254
+ );
255
+ const maxBytes =
256
+ normalizeOptionalByteLimit(
257
+ putOptions.maxBytes,
258
+ "stream maxBytes"
259
+ );
260
+
261
+ await mkdir(
262
+ path.dirname(
263
+ destination
264
+ ),
265
+ {
266
+ recursive: true,
267
+ }
268
+ );
269
+
270
+ const reader =
271
+ stream.getReader();
272
+ let handle:
273
+ Awaited<
274
+ ReturnType<typeof open>
275
+ > | null =
276
+ null;
277
+ let destinationOpened =
278
+ false;
279
+ let completed =
280
+ false;
281
+
282
+ try {
283
+ putOptions.signal
284
+ ?.throwIfAborted();
285
+
286
+ try {
287
+ handle =
288
+ await open(
289
+ destination,
290
+ putOptions.overwrite
291
+ ? "w"
292
+ : "wx"
293
+ );
294
+ destinationOpened =
295
+ true;
296
+ } catch (error) {
297
+ if (
298
+ isNodeErrorCode(
299
+ error,
300
+ "EEXIST"
301
+ )
302
+ ) {
303
+ throw new StorageError(
304
+ "OBJECT_EXISTS",
305
+ `BCP Framework: storage object "${normalizedKey}" already exists.`,
306
+ 409
307
+ );
308
+ }
309
+
310
+ throw error;
311
+ }
312
+
313
+ const hash =
314
+ createHash(
315
+ "sha256"
316
+ );
317
+ let totalBytes =
318
+ 0;
319
+
320
+ while (true) {
321
+ putOptions.signal
322
+ ?.throwIfAborted();
323
+
324
+ const result =
325
+ await reader.read();
326
+
327
+ if (result.done) {
328
+ break;
329
+ }
330
+
331
+ const chunk =
332
+ result.value;
333
+
334
+ if (
335
+ !(chunk instanceof
336
+ Uint8Array)
337
+ ) {
338
+ throw new TypeError(
339
+ "BCP Framework: storage streams must emit Uint8Array chunks."
340
+ );
341
+ }
342
+
343
+ const nextTotal =
344
+ totalBytes +
345
+ chunk.byteLength;
346
+
347
+ if (
348
+ maxBytes !== undefined &&
349
+ nextTotal > maxBytes
350
+ ) {
351
+ throw new StorageError(
352
+ "STREAM_TOO_LARGE",
353
+ `BCP Framework: storage stream exceeded ${maxBytes} bytes.`,
354
+ 413
355
+ );
356
+ }
357
+
358
+ await writeAll(
359
+ handle,
360
+ chunk
361
+ );
362
+ hash.update(
363
+ chunk
364
+ );
365
+ totalBytes =
366
+ nextTotal;
367
+ }
368
+
369
+ await handle.close();
370
+ handle =
371
+ null;
372
+
373
+ const checksumSha256 =
374
+ hash.digest(
375
+ "hex"
376
+ );
377
+
378
+ await writeMetadataRecord(
379
+ metadataDirectory,
380
+ {
381
+ version: 1,
382
+ key:
383
+ normalizedKey,
384
+ contentType,
385
+ checksumSha256,
386
+ }
387
+ );
388
+
389
+ const fileStat =
390
+ await stat(
391
+ destination
392
+ );
393
+
394
+ completed =
395
+ true;
396
+
397
+ return createObjectMetadata(
398
+ normalizedKey,
399
+ fileStat.size,
400
+ fileStat.mtime,
401
+ contentType,
402
+ checksumSha256
403
+ );
404
+ } catch (error) {
405
+ try {
406
+ await reader.cancel(
407
+ error
408
+ );
409
+ } catch {
410
+ // Ignore source cancellation failures.
411
+ }
412
+
413
+ throw error;
414
+ } finally {
415
+ reader.releaseLock();
416
+
417
+ if (handle) {
418
+ try {
419
+ await handle.close();
420
+ } catch {
421
+ // Preserve the original failure.
422
+ }
423
+ }
424
+
425
+ if (
426
+ destinationOpened &&
427
+ !completed
428
+ ) {
429
+ await rm(
430
+ destination,
431
+ {
432
+ force: true,
433
+ }
434
+ );
435
+ }
436
+ }
437
+ };
438
+
439
+ const readStream =
440
+ async (
441
+ key: string,
442
+ readOptions:
443
+ StorageReadOptions = {}
444
+ ): Promise<StorageReadableStream> => {
445
+ const normalizedKey =
446
+ normalizeStorageKey(
447
+ key
448
+ );
449
+ const destination =
450
+ resolveStoragePath(
451
+ rootDirectory,
452
+ normalizedKey
453
+ );
454
+ const metadata =
455
+ await statObject(
456
+ normalizedKey
457
+ );
458
+
459
+ if (!metadata) {
460
+ throw new StorageError(
461
+ "OBJECT_NOT_FOUND",
462
+ `BCP Framework: storage object "${normalizedKey}" was not found.`,
463
+ 404
464
+ );
465
+ }
466
+
467
+ const range =
468
+ normalizeReadRange(
469
+ metadata.size,
470
+ readOptions
471
+ );
472
+
473
+ if (
474
+ metadata.size === 0 &&
475
+ range === null
476
+ ) {
477
+ return new ReadableStream<
478
+ Uint8Array<ArrayBuffer>
479
+ >({
480
+ start(controller) {
481
+ controller.close();
482
+ },
483
+ });
484
+ }
485
+
486
+ const start =
487
+ range?.start ??
488
+ 0;
489
+ const end =
490
+ range?.end ??
491
+ metadata.size - 1;
492
+ const handle =
493
+ await open(
494
+ destination,
495
+ "r"
496
+ );
497
+ let position =
498
+ start;
499
+ let closed =
500
+ false;
501
+
502
+ const closeHandle =
503
+ async () => {
504
+ if (closed) {
505
+ return;
506
+ }
507
+
508
+ closed =
509
+ true;
510
+ await handle.close();
511
+ };
512
+
513
+ return new ReadableStream<
514
+ Uint8Array<ArrayBuffer>
515
+ >({
516
+ async pull(controller) {
517
+ try {
518
+ if (
519
+ position > end
520
+ ) {
521
+ await closeHandle();
522
+ controller.close();
523
+ return;
524
+ }
525
+
526
+ const remaining =
527
+ end -
528
+ position +
529
+ 1;
530
+ const requested =
531
+ Math.min(
532
+ STREAM_CHUNK_BYTES,
533
+ remaining
534
+ );
535
+ const buffer =
536
+ Buffer.allocUnsafe(
537
+ requested
538
+ );
539
+ const result =
540
+ await handle.read(
541
+ buffer,
542
+ 0,
543
+ requested,
544
+ position
545
+ );
546
+
547
+ if (
548
+ result.bytesRead === 0
549
+ ) {
550
+ await closeHandle();
551
+ controller.close();
552
+ return;
553
+ }
554
+
555
+ const chunk =
556
+ new Uint8Array(
557
+ result.bytesRead
558
+ );
559
+ chunk.set(
560
+ buffer.subarray(
561
+ 0,
562
+ result.bytesRead
563
+ )
564
+ );
565
+ position +=
566
+ result.bytesRead;
567
+
568
+ controller.enqueue(
569
+ chunk
570
+ );
571
+
572
+ if (
573
+ position > end
574
+ ) {
575
+ await closeHandle();
576
+ controller.close();
577
+ }
578
+ } catch (error) {
579
+ try {
580
+ await closeHandle();
581
+ } catch {
582
+ // Preserve the read failure.
583
+ }
584
+
585
+ controller.error(
586
+ error
587
+ );
588
+ }
589
+ },
590
+
591
+ async cancel() {
592
+ await closeHandle();
593
+ },
594
+ });
595
+ };
596
+
597
+ return {
598
+ capabilities: {
599
+ streamingRead: true,
600
+ streamingWrite: true,
601
+ ranges: true,
602
+ signedUrls: false,
603
+ listing: false,
604
+ },
605
+
606
+ async put(
607
+ key,
608
+ value,
609
+ putOptions = {}
610
+ ) {
611
+ const normalizedKey =
612
+ normalizeStorageKey(
613
+ key
614
+ );
615
+ const destination =
616
+ resolveStoragePath(
617
+ rootDirectory,
618
+ normalizedKey
619
+ );
620
+ const bytes =
621
+ await toStorageBytes(
622
+ value
623
+ );
624
+ const checksumSha256 =
625
+ createHash(
626
+ "sha256"
627
+ )
628
+ .update(bytes)
629
+ .digest("hex");
630
+ const contentType =
631
+ normalizeContentType(
632
+ putOptions.contentType ??
633
+ (
634
+ value instanceof Blob
635
+ ? value.type
636
+ : ""
637
+ )
638
+ );
639
+
640
+ await mkdir(
641
+ path.dirname(
642
+ destination
643
+ ),
644
+ {
645
+ recursive: true,
646
+ }
647
+ );
648
+
649
+ try {
650
+ await writeFile(
651
+ destination,
652
+ bytes,
653
+ {
654
+ flag:
655
+ putOptions.overwrite
656
+ ? "w"
657
+ : "wx",
658
+ }
659
+ );
660
+ } catch (error) {
661
+ if (
662
+ isNodeErrorCode(
663
+ error,
664
+ "EEXIST"
665
+ )
666
+ ) {
667
+ throw new StorageError(
668
+ "OBJECT_EXISTS",
669
+ `BCP Framework: storage object "${normalizedKey}" already exists.`,
670
+ 409
671
+ );
672
+ }
673
+
674
+ throw error;
675
+ }
676
+
677
+ await writeMetadataRecord(
678
+ metadataDirectory,
679
+ {
680
+ version: 1,
681
+ key:
682
+ normalizedKey,
683
+ contentType,
684
+ checksumSha256,
685
+ }
686
+ );
687
+
688
+ const fileStat =
689
+ await stat(
690
+ destination
691
+ );
692
+
693
+ return createObjectMetadata(
694
+ normalizedKey,
695
+ fileStat.size,
696
+ fileStat.mtime,
697
+ contentType,
698
+ checksumSha256
699
+ );
700
+ },
701
+
702
+ putStream,
703
+
704
+ stat:
705
+ statObject,
706
+
707
+ async read(
708
+ key,
709
+ readOptions = {}
710
+ ) {
711
+ const normalizedKey =
712
+ normalizeStorageKey(
713
+ key
714
+ );
715
+ const destination =
716
+ resolveStoragePath(
717
+ rootDirectory,
718
+ normalizedKey
719
+ );
720
+ const metadata =
721
+ await statObject(
722
+ normalizedKey
723
+ );
724
+
725
+ if (!metadata) {
726
+ throw new StorageError(
727
+ "OBJECT_NOT_FOUND",
728
+ `BCP Framework: storage object "${normalizedKey}" was not found.`,
729
+ 404
730
+ );
731
+ }
732
+
733
+ const range =
734
+ normalizeReadRange(
735
+ metadata.size,
736
+ readOptions
737
+ );
738
+
739
+ if (!range) {
740
+ return Uint8Array.from(
741
+ await readFile(
742
+ destination
743
+ )
744
+ );
745
+ }
746
+
747
+ const length =
748
+ range.end -
749
+ range.start +
750
+ 1;
751
+ const handle =
752
+ await open(
753
+ destination,
754
+ "r"
755
+ );
756
+
757
+ try {
758
+ const buffer =
759
+ Buffer.allocUnsafe(
760
+ length
761
+ );
762
+ const result =
763
+ await handle.read(
764
+ buffer,
765
+ 0,
766
+ length,
767
+ range.start
768
+ );
769
+
770
+ return Uint8Array.from(
771
+ buffer.subarray(
772
+ 0,
773
+ result.bytesRead
774
+ )
775
+ );
776
+ } finally {
777
+ await handle.close();
778
+ }
779
+ },
780
+
781
+ readStream,
782
+
783
+ async exists(key) {
784
+ return (
785
+ await statObject(
786
+ key
787
+ )
788
+ ) !== null;
789
+ },
790
+
791
+ async delete(key) {
792
+ const normalizedKey =
793
+ normalizeStorageKey(
794
+ key
795
+ );
796
+ const destination =
797
+ resolveStoragePath(
798
+ rootDirectory,
799
+ normalizedKey
800
+ );
801
+
802
+ try {
803
+ await unlink(
804
+ destination
805
+ );
806
+ } catch (error) {
807
+ if (
808
+ isNodeErrorCode(
809
+ error,
810
+ "ENOENT"
811
+ )
812
+ ) {
813
+ return false;
814
+ }
815
+
816
+ throw error;
817
+ }
818
+
819
+ await rm(
820
+ metadataFilePath(
821
+ metadataDirectory,
822
+ normalizedKey
823
+ ),
824
+ {
825
+ force: true,
826
+ }
827
+ );
828
+
829
+ return true;
830
+ },
831
+ };
832
+ }
833
+
834
+ export function getStorageCapabilities(
835
+ storage: StorageAdapter
836
+ ): StorageAdapterCapabilities {
837
+ return {
838
+ streamingRead:
839
+ storage.capabilities
840
+ ?.streamingRead ??
841
+ typeof storage.readStream ===
842
+ "function",
843
+ streamingWrite:
844
+ storage.capabilities
845
+ ?.streamingWrite ??
846
+ typeof storage.putStream ===
847
+ "function",
848
+ ranges:
849
+ storage.capabilities
850
+ ?.ranges ??
851
+ true,
852
+ signedUrls:
853
+ storage.capabilities
854
+ ?.signedUrls ??
855
+ false,
856
+ listing:
857
+ storage.capabilities
858
+ ?.listing ??
859
+ false,
860
+ };
861
+ }
862
+
863
+ export async function putStorageStream(
864
+ storage: StorageAdapter,
865
+ key: string,
866
+ stream:
867
+ ReadableStream<Uint8Array>,
868
+ options:
869
+ StoragePutStreamOptions = {}
870
+ ): Promise<StorageObjectMetadata> {
871
+ if (storage.putStream) {
872
+ return storage.putStream(
873
+ key,
874
+ stream,
875
+ options
876
+ );
877
+ }
878
+
879
+ const bytes =
880
+ await collectStorageStream(
881
+ stream,
882
+ options
883
+ );
884
+
885
+ return storage.put(
886
+ key,
887
+ bytes,
888
+ {
889
+ contentType:
890
+ options.contentType,
891
+ overwrite:
892
+ options.overwrite,
893
+ }
894
+ );
895
+ }
896
+
897
+ export async function readStorageStream(
898
+ storage: StorageAdapter,
899
+ key: string,
900
+ options:
901
+ StorageReadOptions = {}
902
+ ): Promise<StorageReadableStream> {
903
+ if (storage.readStream) {
904
+ return storage.readStream(
905
+ key,
906
+ options
907
+ );
908
+ }
909
+
910
+ const bytes =
911
+ await storage.read(
912
+ key,
913
+ options
914
+ );
915
+ const chunk =
916
+ new Uint8Array(
917
+ bytes.byteLength
918
+ );
919
+
920
+ chunk.set(
921
+ bytes
922
+ );
923
+
924
+ return new ReadableStream<
925
+ Uint8Array<ArrayBuffer>
926
+ >({
927
+ start(controller) {
928
+ if (
929
+ chunk.byteLength > 0
930
+ ) {
931
+ controller.enqueue(
932
+ chunk
933
+ );
934
+ }
935
+
936
+ controller.close();
937
+ },
938
+ });
939
+ }
940
+
941
+ export async function storeUploadedFile(
942
+ file: File,
943
+ options: StoreUploadedFileOptions
944
+ ): Promise<StoredUploadedObject> {
945
+ validateUploadedFile(
946
+ file,
947
+ options.constraints
948
+ );
949
+
950
+ const key =
951
+ options.key === undefined
952
+ ? createUploadStorageKey(
953
+ file.name
954
+ )
955
+ : normalizeStorageKey(
956
+ options.key
957
+ );
958
+ const metadata =
959
+ await putStorageStream(
960
+ options.storage,
961
+ key,
962
+ file.stream(),
963
+ {
964
+ overwrite:
965
+ options.overwrite,
966
+ contentType:
967
+ file.type ||
968
+ "application/octet-stream",
969
+ maxBytes:
970
+ options.constraints
971
+ ?.maxBytes,
972
+ }
973
+ );
974
+
975
+ return {
976
+ ...metadata,
977
+ originalName:
978
+ file.name,
979
+ };
980
+ }
981
+
982
+ export function normalizeStorageKey(
983
+ value: string
984
+ ): string {
985
+ if (
986
+ typeof value !== "string" ||
987
+ value.trim().length === 0
988
+ ) {
989
+ throw new StorageError(
990
+ "INVALID_KEY",
991
+ "BCP Framework: storage key must be a non-empty relative path.",
992
+ 400
993
+ );
994
+ }
995
+
996
+ const normalized =
997
+ value
998
+ .replace(
999
+ /\\/g,
1000
+ "/"
1001
+ )
1002
+ .replace(
1003
+ /^\/+|\/+$/g,
1004
+ ""
1005
+ );
1006
+ const segments =
1007
+ normalized.split("/");
1008
+
1009
+ if (
1010
+ normalized.length === 0 ||
1011
+ path.isAbsolute(
1012
+ value
1013
+ ) ||
1014
+ segments.some(
1015
+ (segment) =>
1016
+ segment.length === 0 ||
1017
+ segment === "." ||
1018
+ segment === ".." ||
1019
+ /[\u0000-\u001F\u007F]/.test(
1020
+ segment
1021
+ )
1022
+ ) ||
1023
+ segments[0] ===
1024
+ METADATA_DIRECTORY
1025
+ ) {
1026
+ throw new StorageError(
1027
+ "INVALID_KEY",
1028
+ `BCP Framework: invalid storage key "${value}".`,
1029
+ 400
1030
+ );
1031
+ }
1032
+
1033
+ return segments.join("/");
1034
+ }
1035
+
1036
+ function resolveStoragePath(
1037
+ rootDirectory: string,
1038
+ key: string
1039
+ ): string {
1040
+ const destination =
1041
+ path.resolve(
1042
+ rootDirectory,
1043
+ ...key.split("/")
1044
+ );
1045
+ const relative =
1046
+ path.relative(
1047
+ rootDirectory,
1048
+ destination
1049
+ );
1050
+
1051
+ if (
1052
+ relative === "" ||
1053
+ relative.startsWith("..") ||
1054
+ path.isAbsolute(
1055
+ relative
1056
+ )
1057
+ ) {
1058
+ throw new StorageError(
1059
+ "INVALID_KEY",
1060
+ `BCP Framework: storage key "${key}" escapes the configured storage directory.`,
1061
+ 400
1062
+ );
1063
+ }
1064
+
1065
+ return destination;
1066
+ }
1067
+
1068
+ async function toStorageBytes(
1069
+ value: StorageWriteValue
1070
+ ): Promise<Uint8Array> {
1071
+ if (
1072
+ typeof value ===
1073
+ "string"
1074
+ ) {
1075
+ return Uint8Array.from(
1076
+ Buffer.from(
1077
+ value,
1078
+ "utf8"
1079
+ )
1080
+ );
1081
+ }
1082
+
1083
+ if (
1084
+ value instanceof Blob
1085
+ ) {
1086
+ return new Uint8Array(
1087
+ await value.arrayBuffer()
1088
+ );
1089
+ }
1090
+
1091
+ if (
1092
+ value instanceof ArrayBuffer
1093
+ ) {
1094
+ return new Uint8Array(
1095
+ value
1096
+ );
1097
+ }
1098
+
1099
+ if (
1100
+ ArrayBuffer.isView(
1101
+ value
1102
+ )
1103
+ ) {
1104
+ return Uint8Array.from(
1105
+ new Uint8Array(
1106
+ value.buffer,
1107
+ value.byteOffset,
1108
+ value.byteLength
1109
+ )
1110
+ );
1111
+ }
1112
+
1113
+ throw new TypeError(
1114
+ "BCP Framework: unsupported storage write value."
1115
+ );
1116
+ }
1117
+
1118
+ async function collectStorageStream(
1119
+ stream:
1120
+ ReadableStream<Uint8Array>,
1121
+ options:
1122
+ Pick<
1123
+ StoragePutStreamOptions,
1124
+ "maxBytes" |
1125
+ "signal"
1126
+ >
1127
+ ): Promise<Uint8Array<ArrayBuffer>> {
1128
+ const maxBytes =
1129
+ normalizeOptionalByteLimit(
1130
+ options.maxBytes,
1131
+ "stream maxBytes"
1132
+ );
1133
+ const chunks:
1134
+ Uint8Array<ArrayBuffer>[] = [];
1135
+ let totalBytes =
1136
+ 0;
1137
+ const reader =
1138
+ stream.getReader();
1139
+
1140
+ try {
1141
+ while (true) {
1142
+ options.signal
1143
+ ?.throwIfAborted();
1144
+
1145
+ const result =
1146
+ await reader.read();
1147
+
1148
+ if (result.done) {
1149
+ break;
1150
+ }
1151
+
1152
+ const chunk =
1153
+ result.value;
1154
+ const nextTotal =
1155
+ totalBytes +
1156
+ chunk.byteLength;
1157
+
1158
+ if (
1159
+ maxBytes !== undefined &&
1160
+ nextTotal > maxBytes
1161
+ ) {
1162
+ throw new StorageError(
1163
+ "STREAM_TOO_LARGE",
1164
+ `BCP Framework: storage stream exceeded ${maxBytes} bytes.`,
1165
+ 413
1166
+ );
1167
+ }
1168
+
1169
+ const ownedChunk =
1170
+ new Uint8Array(
1171
+ chunk.byteLength
1172
+ );
1173
+ ownedChunk.set(
1174
+ chunk
1175
+ );
1176
+ chunks.push(
1177
+ ownedChunk
1178
+ );
1179
+ totalBytes =
1180
+ nextTotal;
1181
+ }
1182
+ } catch (error) {
1183
+ try {
1184
+ await reader.cancel(
1185
+ error
1186
+ );
1187
+ } catch {
1188
+ // Ignore source cancellation failures.
1189
+ }
1190
+
1191
+ throw error;
1192
+ } finally {
1193
+ reader.releaseLock();
1194
+ }
1195
+
1196
+ const bytes =
1197
+ new Uint8Array(
1198
+ totalBytes
1199
+ );
1200
+ let offset =
1201
+ 0;
1202
+
1203
+ for (const chunk of chunks) {
1204
+ bytes.set(
1205
+ chunk,
1206
+ offset
1207
+ );
1208
+ offset +=
1209
+ chunk.byteLength;
1210
+ }
1211
+
1212
+ return bytes;
1213
+ }
1214
+
1215
+ async function writeAll(
1216
+ handle:
1217
+ Awaited<
1218
+ ReturnType<typeof open>
1219
+ >,
1220
+ bytes: Uint8Array
1221
+ ): Promise<void> {
1222
+ let offset =
1223
+ 0;
1224
+
1225
+ while (
1226
+ offset < bytes.byteLength
1227
+ ) {
1228
+ const result =
1229
+ await handle.write(
1230
+ bytes,
1231
+ offset,
1232
+ bytes.byteLength -
1233
+ offset,
1234
+ null
1235
+ );
1236
+
1237
+ if (
1238
+ result.bytesWritten <= 0
1239
+ ) {
1240
+ throw new Error(
1241
+ "BCP Framework: storage stream write made no progress."
1242
+ );
1243
+ }
1244
+
1245
+ offset +=
1246
+ result.bytesWritten;
1247
+ }
1248
+ }
1249
+
1250
+ function normalizeOptionalByteLimit(
1251
+ value: number | undefined,
1252
+ label: string
1253
+ ): number | undefined {
1254
+ if (value === undefined) {
1255
+ return undefined;
1256
+ }
1257
+
1258
+ if (
1259
+ !Number.isSafeInteger(
1260
+ value
1261
+ ) ||
1262
+ value < 0
1263
+ ) {
1264
+ throw new TypeError(
1265
+ `BCP Framework: storage ${label} must be a non-negative safe integer.`
1266
+ );
1267
+ }
1268
+
1269
+ return value;
1270
+ }
1271
+
1272
+ function normalizeReadRange(
1273
+ size: number,
1274
+ options: StorageReadOptions
1275
+ ): {
1276
+ start: number;
1277
+ end: number;
1278
+ } | null {
1279
+ if (
1280
+ options.start === undefined &&
1281
+ options.end === undefined
1282
+ ) {
1283
+ return null;
1284
+ }
1285
+
1286
+ const start =
1287
+ options.start ??
1288
+ 0;
1289
+ const end =
1290
+ options.end ??
1291
+ size - 1;
1292
+
1293
+ if (
1294
+ !Number.isSafeInteger(start) ||
1295
+ !Number.isSafeInteger(end) ||
1296
+ start < 0 ||
1297
+ end < start ||
1298
+ start >= size ||
1299
+ end >= size
1300
+ ) {
1301
+ throw new StorageError(
1302
+ "RANGE_NOT_SATISFIABLE",
1303
+ `BCP Framework: requested storage range ${start}-${end} is invalid for ${size} bytes.`,
1304
+ 416
1305
+ );
1306
+ }
1307
+
1308
+ return {
1309
+ start,
1310
+ end,
1311
+ };
1312
+ }
1313
+
1314
+ function createObjectMetadata(
1315
+ key: string,
1316
+ size: number,
1317
+ lastModified: Date,
1318
+ contentType: string,
1319
+ checksumSha256?: string
1320
+ ): StorageObjectMetadata {
1321
+ const etag =
1322
+ checksumSha256
1323
+ ? `"sha256-${checksumSha256}"`
1324
+ : `W/"${size}-${Math.trunc(
1325
+ lastModified.getTime()
1326
+ )}"`;
1327
+
1328
+ return {
1329
+ key,
1330
+ size,
1331
+ contentType:
1332
+ normalizeContentType(
1333
+ contentType
1334
+ ),
1335
+ lastModified:
1336
+ new Date(
1337
+ lastModified.getTime()
1338
+ ),
1339
+ etag,
1340
+ ...(checksumSha256
1341
+ ? {
1342
+ checksumSha256,
1343
+ }
1344
+ : {}),
1345
+ };
1346
+ }
1347
+
1348
+ async function writeMetadataRecord(
1349
+ metadataDirectory: string,
1350
+ record: LocalMetadataRecord
1351
+ ): Promise<void> {
1352
+ await mkdir(
1353
+ metadataDirectory,
1354
+ {
1355
+ recursive: true,
1356
+ }
1357
+ );
1358
+
1359
+ await writeFile(
1360
+ metadataFilePath(
1361
+ metadataDirectory,
1362
+ record.key
1363
+ ),
1364
+ JSON.stringify(
1365
+ record
1366
+ ),
1367
+ "utf8"
1368
+ );
1369
+ }
1370
+
1371
+ async function readMetadataRecord(
1372
+ metadataDirectory: string,
1373
+ key: string
1374
+ ): Promise<LocalMetadataRecord | null> {
1375
+ try {
1376
+ const raw =
1377
+ await readFile(
1378
+ metadataFilePath(
1379
+ metadataDirectory,
1380
+ key
1381
+ ),
1382
+ "utf8"
1383
+ );
1384
+ const parsed =
1385
+ JSON.parse(
1386
+ raw
1387
+ ) as Partial<
1388
+ LocalMetadataRecord
1389
+ >;
1390
+
1391
+ if (
1392
+ parsed.version !== 1 ||
1393
+ parsed.key !== key ||
1394
+ typeof parsed.contentType !==
1395
+ "string" ||
1396
+ typeof parsed.checksumSha256 !==
1397
+ "string"
1398
+ ) {
1399
+ return null;
1400
+ }
1401
+
1402
+ return {
1403
+ version: 1,
1404
+ key,
1405
+ contentType:
1406
+ parsed.contentType,
1407
+ checksumSha256:
1408
+ parsed.checksumSha256,
1409
+ };
1410
+ } catch (error) {
1411
+ if (
1412
+ isNodeErrorCode(
1413
+ error,
1414
+ "ENOENT"
1415
+ ) ||
1416
+ error instanceof SyntaxError
1417
+ ) {
1418
+ return null;
1419
+ }
1420
+
1421
+ throw error;
1422
+ }
1423
+ }
1424
+
1425
+ function metadataFilePath(
1426
+ metadataDirectory: string,
1427
+ key: string
1428
+ ): string {
1429
+ const digest =
1430
+ createHash(
1431
+ "sha256"
1432
+ )
1433
+ .update(
1434
+ key
1435
+ )
1436
+ .digest(
1437
+ "hex"
1438
+ );
1439
+
1440
+ return path.join(
1441
+ metadataDirectory,
1442
+ `${digest}.json`
1443
+ );
1444
+ }
1445
+
1446
+ function createUploadStorageKey(
1447
+ originalName: string
1448
+ ): string {
1449
+ const extension =
1450
+ path.extname(
1451
+ originalName
1452
+ )
1453
+ .toLowerCase()
1454
+ .replace(
1455
+ /[^.a-z0-9]/g,
1456
+ ""
1457
+ )
1458
+ .slice(
1459
+ 0,
1460
+ 16
1461
+ );
1462
+
1463
+ return `${randomUUID()}${extension}`;
1464
+ }
1465
+
1466
+ function normalizeContentType(
1467
+ value: string
1468
+ ): string {
1469
+ const normalized =
1470
+ value
1471
+ .trim()
1472
+ .toLowerCase();
1473
+
1474
+ if (
1475
+ normalized.length === 0 ||
1476
+ /[\r\n]/.test(
1477
+ normalized
1478
+ )
1479
+ ) {
1480
+ return "application/octet-stream";
1481
+ }
1482
+
1483
+ return normalized;
1484
+ }
1485
+
1486
+ function assertNonEmptyString(
1487
+ value: string,
1488
+ label: string
1489
+ ): string {
1490
+ if (
1491
+ typeof value !== "string" ||
1492
+ value.trim().length === 0
1493
+ ) {
1494
+ throw new TypeError(
1495
+ `BCP Framework: storage ${label} must be a non-empty string.`
1496
+ );
1497
+ }
1498
+
1499
+ return value;
1500
+ }
1501
+
1502
+ function isNodeErrorCode(
1503
+ error: unknown,
1504
+ code: string
1505
+ ): boolean {
1506
+ return (
1507
+ error instanceof Error &&
1508
+ "code" in error &&
1509
+ (
1510
+ error as Error & {
1511
+ code?: string;
1512
+ }
1513
+ ).code === code
1514
+ );
1515
+ }