@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,556 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
randomUUID,
|
|
4
|
+
} from "node:crypto";
|
|
5
|
+
import {
|
|
6
|
+
mkdir,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
export type UploadErrorCode =
|
|
12
|
+
| "INVALID_MULTIPART"
|
|
13
|
+
| "UPLOAD_TOO_LARGE"
|
|
14
|
+
| "FILE_REQUIRED"
|
|
15
|
+
| "FILE_TOO_LARGE"
|
|
16
|
+
| "FILE_TYPE_NOT_ALLOWED"
|
|
17
|
+
| "FILE_EXTENSION_NOT_ALLOWED"
|
|
18
|
+
| "INVALID_FILE_NAME"
|
|
19
|
+
| "FILE_EXISTS";
|
|
20
|
+
|
|
21
|
+
export interface MultipartUploadOptions {
|
|
22
|
+
maxBytes?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UploadConstraints {
|
|
26
|
+
maxBytes?: number;
|
|
27
|
+
allowedTypes?: readonly string[];
|
|
28
|
+
allowedExtensions?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SaveUploadedFileOptions {
|
|
32
|
+
directory: string;
|
|
33
|
+
fileName?: string;
|
|
34
|
+
overwrite?: boolean;
|
|
35
|
+
constraints?: UploadConstraints;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SavedUploadedFile {
|
|
39
|
+
originalName: string;
|
|
40
|
+
fileName: string;
|
|
41
|
+
path: string;
|
|
42
|
+
type: string;
|
|
43
|
+
size: number;
|
|
44
|
+
checksumSha256: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class UploadError extends Error {
|
|
48
|
+
readonly code:
|
|
49
|
+
UploadErrorCode;
|
|
50
|
+
readonly status:
|
|
51
|
+
number;
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
code: UploadErrorCode,
|
|
55
|
+
message: string,
|
|
56
|
+
status: number
|
|
57
|
+
) {
|
|
58
|
+
super(
|
|
59
|
+
message
|
|
60
|
+
);
|
|
61
|
+
this.name =
|
|
62
|
+
"UploadError";
|
|
63
|
+
this.code =
|
|
64
|
+
code;
|
|
65
|
+
this.status =
|
|
66
|
+
status;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function parseMultipartFormData(
|
|
71
|
+
request: Request,
|
|
72
|
+
options: MultipartUploadOptions = {}
|
|
73
|
+
): Promise<FormData> {
|
|
74
|
+
const contentType =
|
|
75
|
+
request.headers.get(
|
|
76
|
+
"content-type"
|
|
77
|
+
) ?? "";
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
!contentType
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.startsWith(
|
|
83
|
+
"multipart/form-data"
|
|
84
|
+
)
|
|
85
|
+
) {
|
|
86
|
+
throw new UploadError(
|
|
87
|
+
"INVALID_MULTIPART",
|
|
88
|
+
"BCP Framework: upload request must use multipart/form-data.",
|
|
89
|
+
400
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const maxBytes =
|
|
94
|
+
normalizePositiveLimit(
|
|
95
|
+
options.maxBytes,
|
|
96
|
+
"maxBytes"
|
|
97
|
+
);
|
|
98
|
+
const contentLength =
|
|
99
|
+
readContentLength(
|
|
100
|
+
request.headers.get(
|
|
101
|
+
"content-length"
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
maxBytes !== null &&
|
|
107
|
+
contentLength !== null &&
|
|
108
|
+
contentLength > maxBytes
|
|
109
|
+
) {
|
|
110
|
+
throw new UploadError(
|
|
111
|
+
"UPLOAD_TOO_LARGE",
|
|
112
|
+
`BCP Framework: multipart request exceeds the ${maxBytes} byte upload limit.`,
|
|
113
|
+
413
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const formData =
|
|
118
|
+
await request.formData();
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
maxBytes !== null &&
|
|
122
|
+
estimateFormDataBytes(
|
|
123
|
+
formData
|
|
124
|
+
) > maxBytes
|
|
125
|
+
) {
|
|
126
|
+
throw new UploadError(
|
|
127
|
+
"UPLOAD_TOO_LARGE",
|
|
128
|
+
`BCP Framework: multipart payload exceeds the ${maxBytes} byte upload limit.`,
|
|
129
|
+
413
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return formData;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getUploadedFile(
|
|
137
|
+
formData: FormData,
|
|
138
|
+
fieldName: string,
|
|
139
|
+
constraints: UploadConstraints = {}
|
|
140
|
+
): File | null {
|
|
141
|
+
const value =
|
|
142
|
+
formData.get(
|
|
143
|
+
fieldName
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
!isUploadedFile(
|
|
148
|
+
value
|
|
149
|
+
)
|
|
150
|
+
) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
validateUploadedFile(
|
|
155
|
+
value,
|
|
156
|
+
constraints
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function requireUploadedFile(
|
|
163
|
+
formData: FormData,
|
|
164
|
+
fieldName: string,
|
|
165
|
+
constraints: UploadConstraints = {}
|
|
166
|
+
): File {
|
|
167
|
+
const file =
|
|
168
|
+
getUploadedFile(
|
|
169
|
+
formData,
|
|
170
|
+
fieldName,
|
|
171
|
+
constraints
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (!file) {
|
|
175
|
+
throw new UploadError(
|
|
176
|
+
"FILE_REQUIRED",
|
|
177
|
+
`BCP Framework: upload field "${fieldName}" must contain a file.`,
|
|
178
|
+
400
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return file;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function validateUploadedFile(
|
|
186
|
+
file: File,
|
|
187
|
+
constraints: UploadConstraints = {}
|
|
188
|
+
): void {
|
|
189
|
+
const maxBytes =
|
|
190
|
+
normalizePositiveLimit(
|
|
191
|
+
constraints.maxBytes,
|
|
192
|
+
"maxBytes"
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
maxBytes !== null &&
|
|
197
|
+
file.size > maxBytes
|
|
198
|
+
) {
|
|
199
|
+
throw new UploadError(
|
|
200
|
+
"FILE_TOO_LARGE",
|
|
201
|
+
`BCP Framework: file "${file.name}" exceeds the ${maxBytes} byte file limit.`,
|
|
202
|
+
413
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
constraints.allowedTypes &&
|
|
208
|
+
constraints.allowedTypes.length > 0
|
|
209
|
+
) {
|
|
210
|
+
const allowedTypes =
|
|
211
|
+
constraints.allowedTypes.map(
|
|
212
|
+
(value) =>
|
|
213
|
+
value
|
|
214
|
+
.trim()
|
|
215
|
+
.toLowerCase()
|
|
216
|
+
);
|
|
217
|
+
const fileType =
|
|
218
|
+
file.type
|
|
219
|
+
.trim()
|
|
220
|
+
.toLowerCase();
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
!allowedTypes.includes(
|
|
224
|
+
fileType
|
|
225
|
+
)
|
|
226
|
+
) {
|
|
227
|
+
throw new UploadError(
|
|
228
|
+
"FILE_TYPE_NOT_ALLOWED",
|
|
229
|
+
`BCP Framework: file type "${file.type || "unknown"}" is not allowed for "${file.name}".`,
|
|
230
|
+
415
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
constraints.allowedExtensions &&
|
|
237
|
+
constraints.allowedExtensions.length > 0
|
|
238
|
+
) {
|
|
239
|
+
const extension =
|
|
240
|
+
path.extname(
|
|
241
|
+
file.name
|
|
242
|
+
).toLowerCase();
|
|
243
|
+
const allowedExtensions =
|
|
244
|
+
constraints.allowedExtensions.map(
|
|
245
|
+
normalizeExtension
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
!allowedExtensions.includes(
|
|
250
|
+
extension
|
|
251
|
+
)
|
|
252
|
+
) {
|
|
253
|
+
throw new UploadError(
|
|
254
|
+
"FILE_EXTENSION_NOT_ALLOWED",
|
|
255
|
+
`BCP Framework: file extension "${extension || "(none)"}" is not allowed for "${file.name}".`,
|
|
256
|
+
415
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function saveUploadedFile(
|
|
263
|
+
file: File,
|
|
264
|
+
options: SaveUploadedFileOptions
|
|
265
|
+
): Promise<SavedUploadedFile> {
|
|
266
|
+
validateUploadedFile(
|
|
267
|
+
file,
|
|
268
|
+
options.constraints
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const directory =
|
|
272
|
+
path.resolve(
|
|
273
|
+
options.directory
|
|
274
|
+
);
|
|
275
|
+
const requestedName =
|
|
276
|
+
options.fileName ??
|
|
277
|
+
createStoredFileName(
|
|
278
|
+
file.name
|
|
279
|
+
);
|
|
280
|
+
const fileName =
|
|
281
|
+
sanitizeUploadFileName(
|
|
282
|
+
requestedName
|
|
283
|
+
);
|
|
284
|
+
const destination =
|
|
285
|
+
path.resolve(
|
|
286
|
+
directory,
|
|
287
|
+
fileName
|
|
288
|
+
);
|
|
289
|
+
const relative =
|
|
290
|
+
path.relative(
|
|
291
|
+
directory,
|
|
292
|
+
destination
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
if (
|
|
296
|
+
relative === "" ||
|
|
297
|
+
relative.startsWith(
|
|
298
|
+
".."
|
|
299
|
+
) ||
|
|
300
|
+
path.isAbsolute(
|
|
301
|
+
relative
|
|
302
|
+
)
|
|
303
|
+
) {
|
|
304
|
+
throw new UploadError(
|
|
305
|
+
"INVALID_FILE_NAME",
|
|
306
|
+
"BCP Framework: upload destination must remain inside the configured directory.",
|
|
307
|
+
400
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await mkdir(
|
|
312
|
+
directory,
|
|
313
|
+
{
|
|
314
|
+
recursive: true,
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const bytes =
|
|
319
|
+
Buffer.from(
|
|
320
|
+
await file.arrayBuffer()
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
await writeFile(
|
|
325
|
+
destination,
|
|
326
|
+
bytes,
|
|
327
|
+
{
|
|
328
|
+
flag:
|
|
329
|
+
options.overwrite
|
|
330
|
+
? "w"
|
|
331
|
+
: "wx",
|
|
332
|
+
}
|
|
333
|
+
);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (
|
|
336
|
+
isNodeErrorCode(
|
|
337
|
+
error,
|
|
338
|
+
"EEXIST"
|
|
339
|
+
)
|
|
340
|
+
) {
|
|
341
|
+
throw new UploadError(
|
|
342
|
+
"FILE_EXISTS",
|
|
343
|
+
`BCP Framework: upload destination "${fileName}" already exists.`,
|
|
344
|
+
409
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
originalName:
|
|
353
|
+
file.name,
|
|
354
|
+
fileName,
|
|
355
|
+
path:
|
|
356
|
+
destination,
|
|
357
|
+
type:
|
|
358
|
+
file.type,
|
|
359
|
+
size:
|
|
360
|
+
file.size,
|
|
361
|
+
checksumSha256:
|
|
362
|
+
createHash(
|
|
363
|
+
"sha256"
|
|
364
|
+
)
|
|
365
|
+
.update(
|
|
366
|
+
bytes
|
|
367
|
+
)
|
|
368
|
+
.digest(
|
|
369
|
+
"hex"
|
|
370
|
+
),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function sanitizeUploadFileName(
|
|
375
|
+
value: string
|
|
376
|
+
): string {
|
|
377
|
+
const baseName =
|
|
378
|
+
path.basename(
|
|
379
|
+
value
|
|
380
|
+
.replace(
|
|
381
|
+
/\\/g,
|
|
382
|
+
"/"
|
|
383
|
+
)
|
|
384
|
+
);
|
|
385
|
+
const sanitized =
|
|
386
|
+
baseName
|
|
387
|
+
.replace(
|
|
388
|
+
/[\u0000-\u001F\u007F]/g,
|
|
389
|
+
""
|
|
390
|
+
)
|
|
391
|
+
.replace(
|
|
392
|
+
/[<>:"/\\|?*]/g,
|
|
393
|
+
"-"
|
|
394
|
+
)
|
|
395
|
+
.replace(
|
|
396
|
+
/\s+/g,
|
|
397
|
+
"-"
|
|
398
|
+
)
|
|
399
|
+
.replace(
|
|
400
|
+
/-+/g,
|
|
401
|
+
"-"
|
|
402
|
+
)
|
|
403
|
+
.replace(
|
|
404
|
+
/^[.\s-]+|[.\s-]+$/g,
|
|
405
|
+
""
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
if (
|
|
409
|
+
!sanitized ||
|
|
410
|
+
sanitized === "." ||
|
|
411
|
+
sanitized === ".."
|
|
412
|
+
) {
|
|
413
|
+
throw new UploadError(
|
|
414
|
+
"INVALID_FILE_NAME",
|
|
415
|
+
"BCP Framework: upload file name is invalid.",
|
|
416
|
+
400
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return sanitized;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function createStoredFileName(
|
|
424
|
+
originalName: string
|
|
425
|
+
): string {
|
|
426
|
+
const extension =
|
|
427
|
+
path.extname(
|
|
428
|
+
originalName
|
|
429
|
+
)
|
|
430
|
+
.toLowerCase()
|
|
431
|
+
.replace(
|
|
432
|
+
/[^.a-z0-9]/g,
|
|
433
|
+
""
|
|
434
|
+
)
|
|
435
|
+
.slice(
|
|
436
|
+
0,
|
|
437
|
+
16
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
return `${randomUUID()}${extension}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function estimateFormDataBytes(
|
|
444
|
+
formData: FormData
|
|
445
|
+
): number {
|
|
446
|
+
let total =
|
|
447
|
+
0;
|
|
448
|
+
|
|
449
|
+
for (
|
|
450
|
+
const value
|
|
451
|
+
of formData.values()
|
|
452
|
+
) {
|
|
453
|
+
total +=
|
|
454
|
+
isUploadedFile(
|
|
455
|
+
value
|
|
456
|
+
)
|
|
457
|
+
? value.size
|
|
458
|
+
: Buffer.byteLength(
|
|
459
|
+
value
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return total;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isUploadedFile(
|
|
467
|
+
value: FormDataEntryValue | null
|
|
468
|
+
): value is File {
|
|
469
|
+
return (
|
|
470
|
+
value !== null &&
|
|
471
|
+
typeof value !== "string" &&
|
|
472
|
+
typeof value.name === "string" &&
|
|
473
|
+
typeof value.type === "string" &&
|
|
474
|
+
typeof value.size === "number" &&
|
|
475
|
+
typeof value.arrayBuffer ===
|
|
476
|
+
"function"
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function normalizeExtension(
|
|
481
|
+
value: string
|
|
482
|
+
): string {
|
|
483
|
+
const normalized =
|
|
484
|
+
value
|
|
485
|
+
.trim()
|
|
486
|
+
.toLowerCase();
|
|
487
|
+
|
|
488
|
+
if (!normalized) {
|
|
489
|
+
return "";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return normalized.startsWith(
|
|
493
|
+
"."
|
|
494
|
+
)
|
|
495
|
+
? normalized
|
|
496
|
+
: `.${normalized}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function normalizePositiveLimit(
|
|
500
|
+
value: number | undefined,
|
|
501
|
+
label: string
|
|
502
|
+
): number | null {
|
|
503
|
+
if (
|
|
504
|
+
value === undefined
|
|
505
|
+
) {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (
|
|
510
|
+
!Number.isSafeInteger(
|
|
511
|
+
value
|
|
512
|
+
) ||
|
|
513
|
+
value <= 0
|
|
514
|
+
) {
|
|
515
|
+
throw new TypeError(
|
|
516
|
+
`BCP Framework: upload ${label} must be a positive safe integer.`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return value;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function readContentLength(
|
|
524
|
+
value: string | null
|
|
525
|
+
): number | null {
|
|
526
|
+
if (!value) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const parsed =
|
|
531
|
+
Number(
|
|
532
|
+
value
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
return Number.isSafeInteger(
|
|
536
|
+
parsed
|
|
537
|
+
) &&
|
|
538
|
+
parsed >= 0
|
|
539
|
+
? parsed
|
|
540
|
+
: null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function isNodeErrorCode(
|
|
544
|
+
error: unknown,
|
|
545
|
+
code: string
|
|
546
|
+
): boolean {
|
|
547
|
+
return (
|
|
548
|
+
error instanceof Error &&
|
|
549
|
+
"code" in error &&
|
|
550
|
+
(
|
|
551
|
+
error as Error & {
|
|
552
|
+
code?: string;
|
|
553
|
+
}
|
|
554
|
+
).code === code
|
|
555
|
+
);
|
|
556
|
+
}
|