@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,846 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+ import {
5
+ createRequire,
6
+ } from "node:module";
7
+ import path from "node:path";
8
+ import {
9
+ Readable,
10
+ type Writable,
11
+ } from "node:stream";
12
+
13
+ import {
14
+ UploadError,
15
+ type UploadConstraints,
16
+ } from "./upload.js";
17
+ import {
18
+ putStorageStream,
19
+ StorageError,
20
+ type StorageAdapter,
21
+ type StorageObjectMetadata,
22
+ } from "./storage.js";
23
+
24
+ const require =
25
+ createRequire(
26
+ import.meta.url
27
+ );
28
+
29
+ interface BusboyFileInfo {
30
+ filename: string;
31
+ encoding: string;
32
+ mimeType: string;
33
+ }
34
+
35
+ type MultipartParser =
36
+ Writable & {
37
+ on(
38
+ event: "file",
39
+ listener:
40
+ (
41
+ fieldName: string,
42
+ file: Readable,
43
+ info: BusboyFileInfo
44
+ ) => void
45
+ ): MultipartParser;
46
+ };
47
+
48
+ type BusboyFactory =
49
+ (
50
+ options: {
51
+ headers:
52
+ Record<
53
+ string,
54
+ string
55
+ >;
56
+ }
57
+ ) => MultipartParser;
58
+
59
+ const createBusboy =
60
+ require(
61
+ "busboy"
62
+ ) as BusboyFactory;
63
+
64
+ export interface MultipartStorageFileInfo {
65
+ fieldName: string;
66
+ originalName: string;
67
+ encoding: string;
68
+ contentType: string;
69
+ }
70
+
71
+ export interface MultipartStorageUploadOptions {
72
+ storage: StorageAdapter;
73
+ fieldName: string;
74
+ key?:
75
+ | string
76
+ | (
77
+ (
78
+ file:
79
+ MultipartStorageFileInfo
80
+ ) =>
81
+ | string
82
+ | Promise<string>
83
+ );
84
+ overwrite?: boolean;
85
+ maxBytes?: number;
86
+ constraints?: UploadConstraints;
87
+ signal?: AbortSignal;
88
+ }
89
+
90
+ export interface StoredMultipartFile
91
+ extends StorageObjectMetadata,
92
+ MultipartStorageFileInfo {}
93
+
94
+ export async function storeMultipartFile(
95
+ request: Request,
96
+ options:
97
+ MultipartStorageUploadOptions
98
+ ): Promise<StoredMultipartFile> {
99
+ const fieldName =
100
+ assertNonEmptyString(
101
+ options.fieldName,
102
+ "multipart fieldName"
103
+ );
104
+ const contentType =
105
+ request.headers.get(
106
+ "content-type"
107
+ ) ?? "";
108
+
109
+ if (
110
+ !contentType
111
+ .toLowerCase()
112
+ .startsWith(
113
+ "multipart/form-data"
114
+ )
115
+ ) {
116
+ throw new UploadError(
117
+ "INVALID_MULTIPART",
118
+ "BCP Framework: streaming upload request must use multipart/form-data.",
119
+ 400
120
+ );
121
+ }
122
+
123
+ if (!request.body) {
124
+ throw new UploadError(
125
+ "INVALID_MULTIPART",
126
+ "BCP Framework: streaming multipart request body is missing.",
127
+ 400
128
+ );
129
+ }
130
+
131
+ const maxRequestBytes =
132
+ normalizePositiveLimit(
133
+ options.maxBytes,
134
+ "maxBytes"
135
+ );
136
+ const contentLength =
137
+ readContentLength(
138
+ request.headers.get(
139
+ "content-length"
140
+ )
141
+ );
142
+
143
+ if (
144
+ maxRequestBytes !== null &&
145
+ contentLength !== null &&
146
+ contentLength >
147
+ maxRequestBytes
148
+ ) {
149
+ throw new UploadError(
150
+ "UPLOAD_TOO_LARGE",
151
+ `BCP Framework: multipart request exceeds the ${maxRequestBytes} byte upload limit.`,
152
+ 413
153
+ );
154
+ }
155
+
156
+ let parser:
157
+ MultipartParser;
158
+
159
+ try {
160
+ parser =
161
+ createBusboy({
162
+ headers:
163
+ Object.fromEntries(
164
+ request.headers
165
+ .entries()
166
+ ),
167
+ });
168
+ } catch (error) {
169
+ throw new UploadError(
170
+ "INVALID_MULTIPART",
171
+ `BCP Framework: invalid multipart request (${errorMessage(
172
+ error
173
+ )}).`,
174
+ 400
175
+ );
176
+ }
177
+
178
+ const uploadAbort =
179
+ new AbortController();
180
+ let targetSeen =
181
+ false;
182
+ const uploadState: {
183
+ current:
184
+ Promise<StoredMultipartFile> |
185
+ null;
186
+ } = {
187
+ current: null,
188
+ };
189
+ let primaryError:
190
+ unknown =
191
+ null;
192
+ let parserSettled =
193
+ false;
194
+
195
+ const recordFailure =
196
+ (error: unknown) => {
197
+ if (primaryError === null) {
198
+ primaryError =
199
+ error;
200
+ }
201
+ };
202
+
203
+ const fail =
204
+ (error: unknown) => {
205
+ recordFailure(
206
+ error
207
+ );
208
+
209
+ if (
210
+ !uploadAbort.signal
211
+ .aborted
212
+ ) {
213
+ uploadAbort.abort(
214
+ error
215
+ );
216
+ }
217
+
218
+ if (
219
+ !parserSettled &&
220
+ !parser.destroyed
221
+ ) {
222
+ parser.destroy(
223
+ asError(
224
+ error
225
+ )
226
+ );
227
+ }
228
+ };
229
+
230
+ const parserDone =
231
+ new Promise<void>(
232
+ (
233
+ resolve,
234
+ reject
235
+ ) => {
236
+ parser.once(
237
+ "finish",
238
+ () => {
239
+ parserSettled =
240
+ true;
241
+ resolve();
242
+ }
243
+ );
244
+ parser.once(
245
+ "error",
246
+ (error) => {
247
+ parserSettled =
248
+ true;
249
+ reject(
250
+ error
251
+ );
252
+ }
253
+ );
254
+ }
255
+ );
256
+
257
+ void parserDone.catch(
258
+ () => {
259
+ // The main flow awaits and reports parser failures.
260
+ }
261
+ );
262
+
263
+ parser.on(
264
+ "file",
265
+ (
266
+ incomingField,
267
+ file,
268
+ info
269
+ ) => {
270
+ if (
271
+ incomingField !==
272
+ fieldName
273
+ ) {
274
+ file.resume();
275
+ return;
276
+ }
277
+
278
+ if (targetSeen) {
279
+ file.resume();
280
+ fail(
281
+ new UploadError(
282
+ "INVALID_MULTIPART",
283
+ `BCP Framework: multipart field "${fieldName}" must contain only one streamed file.`,
284
+ 400
285
+ )
286
+ );
287
+ return;
288
+ }
289
+
290
+ targetSeen =
291
+ true;
292
+
293
+ let fileInfo:
294
+ MultipartStorageFileInfo;
295
+
296
+ try {
297
+ fileInfo =
298
+ createFileInfo(
299
+ incomingField,
300
+ info,
301
+ options.constraints
302
+ );
303
+ } catch (error) {
304
+ recordFailure(
305
+ error
306
+ );
307
+ file.resume();
308
+ return;
309
+ }
310
+
311
+ uploadState.current =
312
+ (async () => {
313
+ const key =
314
+ await resolveStorageKey(
315
+ options.key,
316
+ fileInfo
317
+ );
318
+ const stream =
319
+ Readable.toWeb(
320
+ file
321
+ ) as ReadableStream<
322
+ Uint8Array
323
+ >;
324
+
325
+ try {
326
+ const metadata =
327
+ await putStorageStream(
328
+ options.storage,
329
+ key,
330
+ stream,
331
+ {
332
+ overwrite:
333
+ options.overwrite,
334
+ contentType:
335
+ fileInfo.contentType,
336
+ maxBytes:
337
+ normalizePositiveLimit(
338
+ options.constraints
339
+ ?.maxBytes,
340
+ "file maxBytes"
341
+ ) ??
342
+ undefined,
343
+ signal:
344
+ uploadAbort.signal,
345
+ }
346
+ );
347
+
348
+ return {
349
+ ...metadata,
350
+ ...fileInfo,
351
+ };
352
+ } catch (error) {
353
+ const mapped =
354
+ mapStorageUploadError(
355
+ error,
356
+ fileInfo.originalName,
357
+ key
358
+ );
359
+ fail(
360
+ mapped
361
+ );
362
+ throw mapped;
363
+ }
364
+ })();
365
+
366
+ void uploadState.current.catch(
367
+ () => {
368
+ // The main flow awaits and reports this failure.
369
+ }
370
+ );
371
+ }
372
+ );
373
+
374
+ const onCallerAbort =
375
+ () => {
376
+ fail(
377
+ options.signal
378
+ ?.reason ??
379
+ new Error(
380
+ "BCP Framework: streaming multipart upload was aborted."
381
+ )
382
+ );
383
+ };
384
+
385
+ options.signal
386
+ ?.addEventListener(
387
+ "abort",
388
+ onCallerAbort,
389
+ {
390
+ once: true,
391
+ }
392
+ );
393
+
394
+ try {
395
+ options.signal
396
+ ?.throwIfAborted();
397
+
398
+ await pumpRequestBody(
399
+ request.body,
400
+ parser,
401
+ maxRequestBytes
402
+ );
403
+ await parserDone;
404
+
405
+ if (primaryError !== null) {
406
+ throw primaryError;
407
+ }
408
+
409
+ const uploadPromise =
410
+ uploadState.current;
411
+
412
+ if (
413
+ !targetSeen ||
414
+ uploadPromise === null
415
+ ) {
416
+ throw new UploadError(
417
+ "FILE_REQUIRED",
418
+ `BCP Framework: upload field "${fieldName}" must contain a file.`,
419
+ 400
420
+ );
421
+ }
422
+
423
+ return await uploadPromise;
424
+ } catch (error) {
425
+ fail(
426
+ error
427
+ );
428
+
429
+ const uploadPromise =
430
+ uploadState.current;
431
+
432
+ if (uploadPromise !== null) {
433
+ await uploadPromise.catch(
434
+ () => {
435
+ // Preserve the primary parser/upload error.
436
+ }
437
+ );
438
+ }
439
+
440
+ throw primaryError ??
441
+ error;
442
+ } finally {
443
+ options.signal
444
+ ?.removeEventListener(
445
+ "abort",
446
+ onCallerAbort
447
+ );
448
+ }
449
+ }
450
+
451
+ async function pumpRequestBody(
452
+ body:
453
+ ReadableStream<Uint8Array>,
454
+ parser: MultipartParser,
455
+ maxBytes: number | null
456
+ ): Promise<void> {
457
+ const reader =
458
+ body.getReader();
459
+ let totalBytes =
460
+ 0;
461
+
462
+ try {
463
+ while (true) {
464
+ const result =
465
+ await reader.read();
466
+
467
+ if (result.done) {
468
+ break;
469
+ }
470
+
471
+ const nextTotal =
472
+ totalBytes +
473
+ result.value.byteLength;
474
+
475
+ if (
476
+ maxBytes !== null &&
477
+ nextTotal > maxBytes
478
+ ) {
479
+ throw new UploadError(
480
+ "UPLOAD_TOO_LARGE",
481
+ `BCP Framework: multipart payload exceeds the ${maxBytes} byte upload limit.`,
482
+ 413
483
+ );
484
+ }
485
+
486
+ totalBytes =
487
+ nextTotal;
488
+
489
+ if (
490
+ !parser.write(
491
+ Buffer.from(
492
+ result.value
493
+ )
494
+ )
495
+ ) {
496
+ await waitForDrain(
497
+ parser
498
+ );
499
+ }
500
+ }
501
+
502
+ parser.end();
503
+ } catch (error) {
504
+ try {
505
+ await reader.cancel(
506
+ error
507
+ );
508
+ } catch {
509
+ // Preserve the parser/upload failure.
510
+ }
511
+
512
+ throw error;
513
+ } finally {
514
+ reader.releaseLock();
515
+ }
516
+ }
517
+
518
+ function waitForDrain(
519
+ parser: MultipartParser
520
+ ): Promise<void> {
521
+ return new Promise<void>(
522
+ (
523
+ resolve,
524
+ reject
525
+ ) => {
526
+ const cleanup =
527
+ () => {
528
+ parser.off(
529
+ "drain",
530
+ onDrain
531
+ );
532
+ parser.off(
533
+ "error",
534
+ onError
535
+ );
536
+ };
537
+ const onDrain =
538
+ () => {
539
+ cleanup();
540
+ resolve();
541
+ };
542
+ const onError =
543
+ (error: Error) => {
544
+ cleanup();
545
+ reject(
546
+ error
547
+ );
548
+ };
549
+
550
+ parser.once(
551
+ "drain",
552
+ onDrain
553
+ );
554
+ parser.once(
555
+ "error",
556
+ onError
557
+ );
558
+ }
559
+ );
560
+ }
561
+
562
+ function createFileInfo(
563
+ fieldName: string,
564
+ info: BusboyFileInfo,
565
+ constraints:
566
+ UploadConstraints |
567
+ undefined
568
+ ): MultipartStorageFileInfo {
569
+ const originalName =
570
+ path.basename(
571
+ String(
572
+ info.filename ??
573
+ ""
574
+ ).replace(
575
+ /\\/g,
576
+ "/"
577
+ )
578
+ );
579
+
580
+ if (
581
+ originalName.length === 0 ||
582
+ originalName === "." ||
583
+ originalName === ".."
584
+ ) {
585
+ throw new UploadError(
586
+ "INVALID_FILE_NAME",
587
+ "BCP Framework: streamed upload file name is invalid.",
588
+ 400
589
+ );
590
+ }
591
+
592
+ const contentType =
593
+ String(
594
+ info.mimeType ??
595
+ ""
596
+ )
597
+ .trim()
598
+ .toLowerCase() ||
599
+ "application/octet-stream";
600
+
601
+ if (
602
+ constraints?.allowedTypes &&
603
+ constraints.allowedTypes.length >
604
+ 0
605
+ ) {
606
+ const allowedTypes =
607
+ constraints.allowedTypes.map(
608
+ (value) =>
609
+ value
610
+ .trim()
611
+ .toLowerCase()
612
+ );
613
+
614
+ if (
615
+ !allowedTypes.includes(
616
+ contentType
617
+ )
618
+ ) {
619
+ throw new UploadError(
620
+ "FILE_TYPE_NOT_ALLOWED",
621
+ `BCP Framework: file type "${contentType}" is not allowed for "${originalName}".`,
622
+ 415
623
+ );
624
+ }
625
+ }
626
+
627
+ if (
628
+ constraints?.allowedExtensions &&
629
+ constraints.allowedExtensions.length >
630
+ 0
631
+ ) {
632
+ const extension =
633
+ path.extname(
634
+ originalName
635
+ ).toLowerCase();
636
+ const allowedExtensions =
637
+ constraints.allowedExtensions.map(
638
+ normalizeExtension
639
+ );
640
+
641
+ if (
642
+ !allowedExtensions.includes(
643
+ extension
644
+ )
645
+ ) {
646
+ throw new UploadError(
647
+ "FILE_EXTENSION_NOT_ALLOWED",
648
+ `BCP Framework: file extension "${extension || "(none)"}" is not allowed for "${originalName}".`,
649
+ 415
650
+ );
651
+ }
652
+ }
653
+
654
+ return {
655
+ fieldName,
656
+ originalName,
657
+ encoding:
658
+ String(
659
+ info.encoding ??
660
+ ""
661
+ ),
662
+ contentType,
663
+ };
664
+ }
665
+
666
+ async function resolveStorageKey(
667
+ value:
668
+ MultipartStorageUploadOptions[
669
+ "key"
670
+ ],
671
+ file:
672
+ MultipartStorageFileInfo
673
+ ): Promise<string> {
674
+ if (
675
+ typeof value ===
676
+ "function"
677
+ ) {
678
+ return value(
679
+ file
680
+ );
681
+ }
682
+
683
+ if (
684
+ typeof value ===
685
+ "string"
686
+ ) {
687
+ return value;
688
+ }
689
+
690
+ const extension =
691
+ path.extname(
692
+ file.originalName
693
+ )
694
+ .toLowerCase()
695
+ .replace(
696
+ /[^.a-z0-9]/g,
697
+ ""
698
+ )
699
+ .slice(
700
+ 0,
701
+ 16
702
+ );
703
+
704
+ return `${randomUUID()}${extension}`;
705
+ }
706
+
707
+ function mapStorageUploadError(
708
+ error: unknown,
709
+ originalName: string,
710
+ key: string
711
+ ): unknown {
712
+ if (
713
+ !(error instanceof
714
+ StorageError)
715
+ ) {
716
+ return error;
717
+ }
718
+
719
+ if (
720
+ error.code ===
721
+ "STREAM_TOO_LARGE"
722
+ ) {
723
+ return new UploadError(
724
+ "FILE_TOO_LARGE",
725
+ `BCP Framework: file "${originalName}" exceeded the configured streaming file limit.`,
726
+ 413
727
+ );
728
+ }
729
+
730
+ if (
731
+ error.code ===
732
+ "OBJECT_EXISTS"
733
+ ) {
734
+ return new UploadError(
735
+ "FILE_EXISTS",
736
+ `BCP Framework: upload storage key "${key}" already exists.`,
737
+ 409
738
+ );
739
+ }
740
+
741
+ return error;
742
+ }
743
+
744
+ function normalizePositiveLimit(
745
+ value: number | undefined,
746
+ label: string
747
+ ): number | null {
748
+ if (value === undefined) {
749
+ return null;
750
+ }
751
+
752
+ if (
753
+ !Number.isSafeInteger(
754
+ value
755
+ ) ||
756
+ value <= 0
757
+ ) {
758
+ throw new TypeError(
759
+ `BCP Framework: streaming upload ${label} must be a positive safe integer.`
760
+ );
761
+ }
762
+
763
+ return value;
764
+ }
765
+
766
+ function readContentLength(
767
+ value: string | null
768
+ ): number | null {
769
+ if (!value) {
770
+ return null;
771
+ }
772
+
773
+ if (!/^\d+$/.test(value)) {
774
+ return null;
775
+ }
776
+
777
+ const parsed =
778
+ Number(
779
+ value
780
+ );
781
+
782
+ return Number.isSafeInteger(
783
+ parsed
784
+ )
785
+ ? parsed
786
+ : null;
787
+ }
788
+
789
+ function normalizeExtension(
790
+ value: string
791
+ ): string {
792
+ const normalized =
793
+ value
794
+ .trim()
795
+ .toLowerCase();
796
+
797
+ if (normalized.length === 0) {
798
+ return normalized;
799
+ }
800
+
801
+ return normalized.startsWith(
802
+ "."
803
+ )
804
+ ? normalized
805
+ : `.${normalized}`;
806
+ }
807
+
808
+ function assertNonEmptyString(
809
+ value: string,
810
+ label: string
811
+ ): string {
812
+ if (
813
+ typeof value !== "string" ||
814
+ value.trim().length === 0
815
+ ) {
816
+ throw new TypeError(
817
+ `BCP Framework: ${label} must be a non-empty string.`
818
+ );
819
+ }
820
+
821
+ return value.trim();
822
+ }
823
+
824
+ function asError(
825
+ value: unknown
826
+ ): Error {
827
+ if (value instanceof Error) {
828
+ return value;
829
+ }
830
+
831
+ return new Error(
832
+ errorMessage(
833
+ value
834
+ )
835
+ );
836
+ }
837
+
838
+ function errorMessage(
839
+ value: unknown
840
+ ): string {
841
+ return value instanceof Error
842
+ ? value.message
843
+ : String(
844
+ value
845
+ );
846
+ }