@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.
@@ -20,12 +20,15 @@ import {
20
20
 
21
21
  const METADATA_DIRECTORY =
22
22
  ".bcp-storage-meta";
23
+ const STREAM_CHUNK_BYTES =
24
+ 64 * 1024;
23
25
 
24
26
  export type StorageErrorCode =
25
27
  | "INVALID_KEY"
26
28
  | "OBJECT_NOT_FOUND"
27
29
  | "OBJECT_EXISTS"
28
- | "RANGE_NOT_SATISFIABLE";
30
+ | "RANGE_NOT_SATISFIABLE"
31
+ | "STREAM_TOO_LARGE";
29
32
 
30
33
  export class StorageError extends Error {
31
34
  readonly code:
@@ -62,18 +65,49 @@ export interface StoragePutOptions {
62
65
  overwrite?: boolean;
63
66
  }
64
67
 
68
+ export interface StoragePutStreamOptions
69
+ extends StoragePutOptions {
70
+ maxBytes?: number;
71
+ signal?: AbortSignal;
72
+ }
73
+
65
74
  export interface StorageReadOptions {
66
75
  start?: number;
67
76
  end?: number;
68
77
  }
69
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
+
70
92
  export interface StorageAdapter {
93
+ capabilities?:
94
+ Partial<
95
+ StorageAdapterCapabilities
96
+ >;
97
+
71
98
  put(
72
99
  key: string,
73
100
  value: StorageWriteValue,
74
101
  options?: StoragePutOptions
75
102
  ): Promise<StorageObjectMetadata>;
76
103
 
104
+ putStream?(
105
+ key: string,
106
+ stream:
107
+ ReadableStream<Uint8Array>,
108
+ options?: StoragePutStreamOptions
109
+ ): Promise<StorageObjectMetadata>;
110
+
77
111
  stat(
78
112
  key: string
79
113
  ): Promise<StorageObjectMetadata | null>;
@@ -83,6 +117,11 @@ export interface StorageAdapter {
83
117
  options?: StorageReadOptions
84
118
  ): Promise<Uint8Array>;
85
119
 
120
+ readStream?(
121
+ key: string,
122
+ options?: StorageReadOptions
123
+ ): Promise<StorageReadableStream>;
124
+
86
125
  exists(
87
126
  key: string
88
127
  ): Promise<boolean>;
@@ -137,7 +176,433 @@ export function createLocalStorage(
137
176
  METADATA_DIRECTORY
138
177
  );
139
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
+
140
597
  return {
598
+ capabilities: {
599
+ streamingRead: true,
600
+ streamingWrite: true,
601
+ ranges: true,
602
+ signedUrls: false,
603
+ listing: false,
604
+ },
605
+
141
606
  async put(
142
607
  key,
143
608
  value,
@@ -234,56 +699,10 @@ export function createLocalStorage(
234
699
  );
235
700
  },
236
701
 
237
- async stat(key) {
238
- const normalizedKey =
239
- normalizeStorageKey(
240
- key
241
- );
242
- const destination =
243
- resolveStoragePath(
244
- rootDirectory,
245
- normalizedKey
246
- );
247
-
248
- let fileStat;
249
-
250
- try {
251
- fileStat =
252
- await stat(
253
- destination
254
- );
255
- } catch (error) {
256
- if (
257
- isNodeErrorCode(
258
- error,
259
- "ENOENT"
260
- )
261
- ) {
262
- return null;
263
- }
264
-
265
- throw error;
266
- }
702
+ putStream,
267
703
 
268
- if (!fileStat.isFile()) {
269
- return null;
270
- }
271
-
272
- const record =
273
- await readMetadataRecord(
274
- metadataDirectory,
275
- normalizedKey
276
- );
277
-
278
- return createObjectMetadata(
279
- normalizedKey,
280
- fileStat.size,
281
- fileStat.mtime,
282
- record?.contentType ??
283
- "application/octet-stream",
284
- record?.checksumSha256
285
- );
286
- },
704
+ stat:
705
+ statObject,
287
706
 
288
707
  async read(
289
708
  key,
@@ -299,7 +718,7 @@ export function createLocalStorage(
299
718
  normalizedKey
300
719
  );
301
720
  const metadata =
302
- await this.stat(
721
+ await statObject(
303
722
  normalizedKey
304
723
  );
305
724
 
@@ -359,9 +778,11 @@ export function createLocalStorage(
359
778
  }
360
779
  },
361
780
 
781
+ readStream,
782
+
362
783
  async exists(key) {
363
784
  return (
364
- await this.stat(
785
+ await statObject(
365
786
  key
366
787
  )
367
788
  ) !== null;
@@ -410,6 +831,113 @@ export function createLocalStorage(
410
831
  };
411
832
  }
412
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
+
413
941
  export async function storeUploadedFile(
414
942
  file: File,
415
943
  options: StoreUploadedFileOptions
@@ -428,15 +956,19 @@ export async function storeUploadedFile(
428
956
  options.key
429
957
  );
430
958
  const metadata =
431
- await options.storage.put(
959
+ await putStorageStream(
960
+ options.storage,
432
961
  key,
433
- file,
962
+ file.stream(),
434
963
  {
435
964
  overwrite:
436
965
  options.overwrite,
437
966
  contentType:
438
967
  file.type ||
439
968
  "application/octet-stream",
969
+ maxBytes:
970
+ options.constraints
971
+ ?.maxBytes,
440
972
  }
441
973
  );
442
974
 
@@ -583,6 +1115,160 @@ async function toStorageBytes(
583
1115
  );
584
1116
  }
585
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
+
586
1272
  function normalizeReadRange(
587
1273
  size: number,
588
1274
  options: StorageReadOptions