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