@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.
- package/README.md +658 -578
- package/docs/README.md +317 -318
- package/docs/auth-route-guards.md +14 -0
- package/docs/developer-tools.md +45 -22
- package/docs/file-upload.md +280 -0
- package/docs/releases/0.1.24.md +166 -0
- package/docs/releases/0.1.25.md +132 -0
- package/docs/storage.md +285 -0
- package/package.json +1 -1
- package/packages/bundler/src/server-production-actions.ts +15 -0
- package/packages/bundler/src/server-production-guards.ts +15 -0
- package/packages/client/src/server.ts +40 -0
- package/packages/server/src/dev-route-graph.ts +91 -0
- package/packages/server/src/file-delivery.ts +629 -0
- package/packages/server/src/middleware-dev-server.ts +217 -9
- package/packages/server/src/storage.ts +829 -0
- package/packages/server/src/upload.ts +556 -0
|
@@ -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
|
+
}
|