@chidchanun/bcp 0.1.23 → 0.1.25

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,629 @@
1
+ import path from "node:path";
2
+
3
+ import {
4
+ StorageError,
5
+ type StorageAdapter,
6
+ type StorageObjectMetadata,
7
+ } from "./storage.js";
8
+
9
+ export interface StorageResponseOptions {
10
+ cacheControl?: string;
11
+ contentType?: string;
12
+ disposition?:
13
+ "inline" |
14
+ "attachment";
15
+ downloadName?: string;
16
+ headers?: HeadersInit;
17
+ }
18
+
19
+ interface ByteRange {
20
+ start: number;
21
+ end: number;
22
+ }
23
+
24
+ export async function createStorageResponse(
25
+ request: Request,
26
+ storage: StorageAdapter,
27
+ key: string,
28
+ options: StorageResponseOptions = {}
29
+ ): Promise<Response> {
30
+ const method =
31
+ request.method
32
+ .toUpperCase();
33
+
34
+ if (
35
+ method !== "GET" &&
36
+ method !== "HEAD"
37
+ ) {
38
+ return new Response(
39
+ "Method Not Allowed",
40
+ {
41
+ status: 405,
42
+ headers: {
43
+ Allow:
44
+ "GET, HEAD",
45
+ },
46
+ }
47
+ );
48
+ }
49
+
50
+ const metadata =
51
+ await storage.stat(
52
+ key
53
+ );
54
+
55
+ if (!metadata) {
56
+ return new Response(
57
+ "Not Found",
58
+ {
59
+ status: 404,
60
+ }
61
+ );
62
+ }
63
+
64
+ const baseHeaders =
65
+ createBaseHeaders(
66
+ metadata,
67
+ options
68
+ );
69
+
70
+ if (
71
+ isNotModified(
72
+ request,
73
+ metadata
74
+ )
75
+ ) {
76
+ stripEntityLengthHeaders(
77
+ baseHeaders
78
+ );
79
+
80
+ return new Response(
81
+ null,
82
+ {
83
+ status: 304,
84
+ headers:
85
+ baseHeaders,
86
+ }
87
+ );
88
+ }
89
+
90
+ const rangeHeader =
91
+ request.headers.get(
92
+ "range"
93
+ );
94
+ const canUseRange =
95
+ rangeHeader !== null &&
96
+ ifRangeAllowsPartial(
97
+ request.headers.get(
98
+ "if-range"
99
+ ),
100
+ metadata
101
+ );
102
+
103
+ if (canUseRange) {
104
+ const range =
105
+ parseByteRange(
106
+ rangeHeader,
107
+ metadata.size
108
+ );
109
+
110
+ if (!range) {
111
+ baseHeaders.set(
112
+ "Content-Range",
113
+ `bytes */${metadata.size}`
114
+ );
115
+ baseHeaders.set(
116
+ "Content-Length",
117
+ "0"
118
+ );
119
+
120
+ return new Response(
121
+ null,
122
+ {
123
+ status: 416,
124
+ headers:
125
+ baseHeaders,
126
+ }
127
+ );
128
+ }
129
+
130
+ const length =
131
+ range.end -
132
+ range.start +
133
+ 1;
134
+
135
+ baseHeaders.set(
136
+ "Content-Range",
137
+ `bytes ${range.start}-${range.end}/${metadata.size}`
138
+ );
139
+ baseHeaders.set(
140
+ "Content-Length",
141
+ String(length)
142
+ );
143
+
144
+ const body =
145
+ method === "HEAD"
146
+ ? null
147
+ : toResponseBody(
148
+ await storage.read(
149
+ key,
150
+ range
151
+ )
152
+ );
153
+
154
+ return new Response(
155
+ body,
156
+ {
157
+ status: 206,
158
+ headers:
159
+ baseHeaders,
160
+ }
161
+ );
162
+ }
163
+
164
+ baseHeaders.set(
165
+ "Content-Length",
166
+ String(
167
+ metadata.size
168
+ )
169
+ );
170
+
171
+ const body =
172
+ method === "HEAD"
173
+ ? null
174
+ : toResponseBody(
175
+ await storage.read(
176
+ key
177
+ )
178
+ );
179
+
180
+ return new Response(
181
+ body,
182
+ {
183
+ status: 200,
184
+ headers:
185
+ baseHeaders,
186
+ }
187
+ );
188
+ }
189
+
190
+ function createBaseHeaders(
191
+ metadata: StorageObjectMetadata,
192
+ options: StorageResponseOptions
193
+ ): Headers {
194
+ const headers =
195
+ new Headers(
196
+ options.headers
197
+ );
198
+
199
+ headers.set(
200
+ "Accept-Ranges",
201
+ "bytes"
202
+ );
203
+ headers.set(
204
+ "Content-Type",
205
+ normalizeHeaderContentType(
206
+ options.contentType ??
207
+ metadata.contentType
208
+ )
209
+ );
210
+ headers.set(
211
+ "ETag",
212
+ metadata.etag
213
+ );
214
+ headers.set(
215
+ "Last-Modified",
216
+ metadata.lastModified
217
+ .toUTCString()
218
+ );
219
+ headers.set(
220
+ "Cache-Control",
221
+ normalizeCacheControl(
222
+ options.cacheControl
223
+ )
224
+ );
225
+
226
+ if (
227
+ options.downloadName !==
228
+ undefined ||
229
+ options.disposition !==
230
+ undefined
231
+ ) {
232
+ const disposition =
233
+ options.disposition ??
234
+ "attachment";
235
+ const fileName =
236
+ options.downloadName ??
237
+ path.basename(
238
+ metadata.key
239
+ );
240
+
241
+ headers.set(
242
+ "Content-Disposition",
243
+ createContentDisposition(
244
+ disposition,
245
+ fileName
246
+ )
247
+ );
248
+ }
249
+
250
+ return headers;
251
+ }
252
+
253
+ function isNotModified(
254
+ request: Request,
255
+ metadata: StorageObjectMetadata
256
+ ): boolean {
257
+ const ifNoneMatch =
258
+ request.headers.get(
259
+ "if-none-match"
260
+ );
261
+
262
+ if (ifNoneMatch) {
263
+ return etagListMatches(
264
+ ifNoneMatch,
265
+ metadata.etag
266
+ );
267
+ }
268
+
269
+ const ifModifiedSince =
270
+ request.headers.get(
271
+ "if-modified-since"
272
+ );
273
+
274
+ if (!ifModifiedSince) {
275
+ return false;
276
+ }
277
+
278
+ const timestamp =
279
+ Date.parse(
280
+ ifModifiedSince
281
+ );
282
+
283
+ if (
284
+ !Number.isFinite(
285
+ timestamp
286
+ )
287
+ ) {
288
+ return false;
289
+ }
290
+
291
+ return Math.trunc(
292
+ metadata.lastModified.getTime() /
293
+ 1000
294
+ ) <=
295
+ Math.trunc(
296
+ timestamp /
297
+ 1000
298
+ );
299
+ }
300
+
301
+ function ifRangeAllowsPartial(
302
+ value: string | null,
303
+ metadata: StorageObjectMetadata
304
+ ): boolean {
305
+ if (!value) {
306
+ return true;
307
+ }
308
+
309
+ const normalized =
310
+ value.trim();
311
+
312
+ if (
313
+ normalized.startsWith("\"") ||
314
+ normalized.startsWith(
315
+ "W/\""
316
+ )
317
+ ) {
318
+ return normalized ===
319
+ metadata.etag;
320
+ }
321
+
322
+ const timestamp =
323
+ Date.parse(
324
+ normalized
325
+ );
326
+
327
+ if (
328
+ !Number.isFinite(
329
+ timestamp
330
+ )
331
+ ) {
332
+ return false;
333
+ }
334
+
335
+ return metadata.lastModified
336
+ .getTime() <=
337
+ timestamp;
338
+ }
339
+
340
+ function parseByteRange(
341
+ value: string,
342
+ size: number
343
+ ): ByteRange | null {
344
+ if (
345
+ size <= 0 ||
346
+ !value
347
+ .toLowerCase()
348
+ .startsWith(
349
+ "bytes="
350
+ )
351
+ ) {
352
+ return null;
353
+ }
354
+
355
+ const raw =
356
+ value.slice(
357
+ "bytes=".length
358
+ )
359
+ .trim();
360
+
361
+ if (
362
+ raw.length === 0 ||
363
+ raw.includes(",")
364
+ ) {
365
+ return null;
366
+ }
367
+
368
+ const separator =
369
+ raw.indexOf("-");
370
+
371
+ if (separator < 0) {
372
+ return null;
373
+ }
374
+
375
+ const startText =
376
+ raw
377
+ .slice(
378
+ 0,
379
+ separator
380
+ )
381
+ .trim();
382
+ const endText =
383
+ raw
384
+ .slice(
385
+ separator + 1
386
+ )
387
+ .trim();
388
+
389
+ if (
390
+ startText.length === 0
391
+ ) {
392
+ const suffixLength =
393
+ parseRangeInteger(
394
+ endText
395
+ );
396
+
397
+ if (
398
+ suffixLength === null ||
399
+ suffixLength <= 0
400
+ ) {
401
+ return null;
402
+ }
403
+
404
+ const length =
405
+ Math.min(
406
+ suffixLength,
407
+ size
408
+ );
409
+
410
+ return {
411
+ start:
412
+ size - length,
413
+ end:
414
+ size - 1,
415
+ };
416
+ }
417
+
418
+ const start =
419
+ parseRangeInteger(
420
+ startText
421
+ );
422
+
423
+ if (
424
+ start === null ||
425
+ start >= size
426
+ ) {
427
+ return null;
428
+ }
429
+
430
+ if (
431
+ endText.length === 0
432
+ ) {
433
+ return {
434
+ start,
435
+ end:
436
+ size - 1,
437
+ };
438
+ }
439
+
440
+ const requestedEnd =
441
+ parseRangeInteger(
442
+ endText
443
+ );
444
+
445
+ if (
446
+ requestedEnd === null ||
447
+ requestedEnd < start
448
+ ) {
449
+ return null;
450
+ }
451
+
452
+ return {
453
+ start,
454
+ end:
455
+ Math.min(
456
+ requestedEnd,
457
+ size - 1
458
+ ),
459
+ };
460
+ }
461
+
462
+ function parseRangeInteger(
463
+ value: string
464
+ ): number | null {
465
+ if (
466
+ !/^\d+$/.test(
467
+ value
468
+ )
469
+ ) {
470
+ return null;
471
+ }
472
+
473
+ const parsed =
474
+ Number(
475
+ value
476
+ );
477
+
478
+ return Number.isSafeInteger(
479
+ parsed
480
+ )
481
+ ? parsed
482
+ : null;
483
+ }
484
+
485
+ function etagListMatches(
486
+ value: string,
487
+ etag: string
488
+ ): boolean {
489
+ return value
490
+ .split(",")
491
+ .map(
492
+ (entry) =>
493
+ entry.trim()
494
+ )
495
+ .some(
496
+ (entry) =>
497
+ entry === "*" ||
498
+ entry === etag
499
+ );
500
+ }
501
+
502
+ function createContentDisposition(
503
+ disposition:
504
+ "inline" |
505
+ "attachment",
506
+ fileName: string
507
+ ): string {
508
+ const normalized =
509
+ path.basename(
510
+ fileName
511
+ .replace(
512
+ /\\/g,
513
+ "/"
514
+ )
515
+ )
516
+ .replace(
517
+ /[\u0000-\u001F\u007F]/g,
518
+ ""
519
+ )
520
+ .trim();
521
+ const safeName =
522
+ normalized ||
523
+ "download";
524
+ const asciiName =
525
+ safeName
526
+ .replace(
527
+ /[^\x20-\x7E]/g,
528
+ "_"
529
+ )
530
+ .replace(
531
+ /["\\]/g,
532
+ "_"
533
+ );
534
+ const encoded =
535
+ encodeURIComponent(
536
+ safeName
537
+ )
538
+ .replace(
539
+ /['()*]/g,
540
+ (character) =>
541
+ `%${character
542
+ .charCodeAt(0)
543
+ .toString(16)
544
+ .toUpperCase()}`
545
+ );
546
+
547
+ return `${disposition}; filename="${asciiName}"; filename*=UTF-8''${encoded}`;
548
+ }
549
+
550
+ function normalizeCacheControl(
551
+ value: string | undefined
552
+ ): string {
553
+ if (
554
+ value === undefined
555
+ ) {
556
+ return "private, max-age=0, must-revalidate";
557
+ }
558
+
559
+ const normalized =
560
+ value.trim();
561
+
562
+ if (
563
+ normalized.length === 0 ||
564
+ /[\r\n]/.test(
565
+ normalized
566
+ )
567
+ ) {
568
+ throw new TypeError(
569
+ "BCP Framework: file response cacheControl must be a non-empty header value."
570
+ );
571
+ }
572
+
573
+ return normalized;
574
+ }
575
+
576
+ function normalizeHeaderContentType(
577
+ value: string
578
+ ): string {
579
+ const normalized =
580
+ value.trim();
581
+
582
+ if (
583
+ normalized.length === 0 ||
584
+ /[\r\n]/.test(
585
+ normalized
586
+ )
587
+ ) {
588
+ return "application/octet-stream";
589
+ }
590
+
591
+ return normalized;
592
+ }
593
+
594
+ function stripEntityLengthHeaders(
595
+ headers: Headers
596
+ ): void {
597
+ headers.delete(
598
+ "Content-Length"
599
+ );
600
+ headers.delete(
601
+ "Content-Range"
602
+ );
603
+ }
604
+
605
+ function toResponseBody(
606
+ bytes: Uint8Array
607
+ ): ArrayBuffer {
608
+ const body =
609
+ new Uint8Array(
610
+ bytes.byteLength
611
+ );
612
+
613
+ body.set(
614
+ bytes
615
+ );
616
+
617
+ return body.buffer;
618
+ }
619
+
620
+ export function isStorageRangeError(
621
+ error: unknown
622
+ ): error is StorageError {
623
+ return (
624
+ error instanceof
625
+ StorageError &&
626
+ error.code ===
627
+ "RANGE_NOT_SATISFIABLE"
628
+ );
629
+ }