@chidchanun/bcp 0.1.25 → 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.
- package/README.md +169 -263
- package/docs/README.md +148 -210
- package/docs/file-upload.md +194 -32
- package/docs/releases/0.1.26.md +281 -0
- package/docs/s3-storage.md +276 -0
- package/docs/storage.md +278 -162
- package/package.json +5 -2
- package/packages/client/src/database.mjs +236 -0
- package/packages/client/src/server.ts +22 -0
- package/packages/server/src/file-delivery.ts +8 -24
- package/packages/server/src/storage-s3.ts +1159 -0
- package/packages/server/src/storage.ts +740 -54
- package/packages/server/src/upload-stream.ts +846 -0
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
} from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
Readable,
|
|
6
|
+
} from "node:stream";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DeleteObjectCommand,
|
|
10
|
+
GetObjectCommand,
|
|
11
|
+
HeadObjectCommand,
|
|
12
|
+
PutObjectCommand,
|
|
13
|
+
S3Client,
|
|
14
|
+
} from "@aws-sdk/client-s3";
|
|
15
|
+
import {
|
|
16
|
+
Upload,
|
|
17
|
+
} from "@aws-sdk/lib-storage";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
normalizeStorageKey,
|
|
21
|
+
StorageError,
|
|
22
|
+
type StorageAdapter,
|
|
23
|
+
type StorageObjectMetadata,
|
|
24
|
+
type StoragePutOptions,
|
|
25
|
+
type StoragePutStreamOptions,
|
|
26
|
+
type StorageReadableStream,
|
|
27
|
+
type StorageReadOptions,
|
|
28
|
+
type StorageWriteValue,
|
|
29
|
+
} from "./storage.js";
|
|
30
|
+
|
|
31
|
+
const MIN_MULTIPART_PART_BYTES =
|
|
32
|
+
5 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export interface S3StorageMultipartOptions {
|
|
35
|
+
partSize?: number;
|
|
36
|
+
queueSize?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface S3StorageOptions {
|
|
40
|
+
bucket: string;
|
|
41
|
+
region: string;
|
|
42
|
+
endpoint?: string;
|
|
43
|
+
forcePathStyle?: boolean;
|
|
44
|
+
prefix?: string;
|
|
45
|
+
accessKeyId?: string;
|
|
46
|
+
secretAccessKey?: string;
|
|
47
|
+
sessionToken?: string;
|
|
48
|
+
multipart?: S3StorageMultipartOptions;
|
|
49
|
+
client?: S3Client;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface S3StorageAdapter
|
|
53
|
+
extends StorageAdapter {
|
|
54
|
+
readonly client: S3Client;
|
|
55
|
+
destroy(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createS3Storage(
|
|
59
|
+
options: S3StorageOptions
|
|
60
|
+
): S3StorageAdapter {
|
|
61
|
+
const bucket =
|
|
62
|
+
assertNonEmptyString(
|
|
63
|
+
options.bucket,
|
|
64
|
+
"bucket"
|
|
65
|
+
);
|
|
66
|
+
const region =
|
|
67
|
+
assertNonEmptyString(
|
|
68
|
+
options.region,
|
|
69
|
+
"region"
|
|
70
|
+
);
|
|
71
|
+
const prefix =
|
|
72
|
+
normalizePrefix(
|
|
73
|
+
options.prefix
|
|
74
|
+
);
|
|
75
|
+
const multipart =
|
|
76
|
+
normalizeMultipartOptions(
|
|
77
|
+
options.multipart
|
|
78
|
+
);
|
|
79
|
+
const ownsClient =
|
|
80
|
+
options.client === undefined;
|
|
81
|
+
const client =
|
|
82
|
+
options.client ??
|
|
83
|
+
new S3Client({
|
|
84
|
+
region,
|
|
85
|
+
...(options.endpoint === undefined
|
|
86
|
+
? {}
|
|
87
|
+
: {
|
|
88
|
+
endpoint:
|
|
89
|
+
assertNonEmptyString(
|
|
90
|
+
options.endpoint,
|
|
91
|
+
"endpoint"
|
|
92
|
+
),
|
|
93
|
+
}),
|
|
94
|
+
forcePathStyle:
|
|
95
|
+
options.forcePathStyle ??
|
|
96
|
+
false,
|
|
97
|
+
...createCredentials(
|
|
98
|
+
options
|
|
99
|
+
),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const providerKey =
|
|
103
|
+
(key: string) => {
|
|
104
|
+
const normalizedKey =
|
|
105
|
+
normalizeStorageKey(
|
|
106
|
+
key
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
normalizedKey,
|
|
111
|
+
providerKey:
|
|
112
|
+
prefix.length === 0
|
|
113
|
+
? normalizedKey
|
|
114
|
+
: `${prefix}/${normalizedKey}`,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const statObject =
|
|
119
|
+
async (
|
|
120
|
+
key: string
|
|
121
|
+
): Promise<StorageObjectMetadata | null> => {
|
|
122
|
+
const resolved =
|
|
123
|
+
providerKey(
|
|
124
|
+
key
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const response =
|
|
129
|
+
await client.send(
|
|
130
|
+
new HeadObjectCommand({
|
|
131
|
+
Bucket:
|
|
132
|
+
bucket,
|
|
133
|
+
Key:
|
|
134
|
+
resolved.providerKey,
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return createS3Metadata(
|
|
139
|
+
resolved.normalizedKey,
|
|
140
|
+
response.ContentLength ??
|
|
141
|
+
0,
|
|
142
|
+
response.ContentType ??
|
|
143
|
+
"application/octet-stream",
|
|
144
|
+
response.LastModified ??
|
|
145
|
+
new Date(0),
|
|
146
|
+
response.ETag,
|
|
147
|
+
response.Metadata
|
|
148
|
+
?.bcp_sha256
|
|
149
|
+
);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (
|
|
152
|
+
isS3Status(
|
|
153
|
+
error,
|
|
154
|
+
404
|
|
155
|
+
)
|
|
156
|
+
) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const readStream =
|
|
165
|
+
async (
|
|
166
|
+
key: string,
|
|
167
|
+
readOptions:
|
|
168
|
+
StorageReadOptions = {}
|
|
169
|
+
): Promise<StorageReadableStream> => {
|
|
170
|
+
const resolved =
|
|
171
|
+
providerKey(
|
|
172
|
+
key
|
|
173
|
+
);
|
|
174
|
+
const metadata =
|
|
175
|
+
await statObject(
|
|
176
|
+
resolved.normalizedKey
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (!metadata) {
|
|
180
|
+
throw objectNotFound(
|
|
181
|
+
resolved.normalizedKey
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const range =
|
|
186
|
+
normalizeRemoteRange(
|
|
187
|
+
resolved.normalizedKey,
|
|
188
|
+
metadata.size,
|
|
189
|
+
readOptions
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const response =
|
|
194
|
+
await client.send(
|
|
195
|
+
new GetObjectCommand({
|
|
196
|
+
Bucket:
|
|
197
|
+
bucket,
|
|
198
|
+
Key:
|
|
199
|
+
resolved.providerKey,
|
|
200
|
+
...(range === null
|
|
201
|
+
? {}
|
|
202
|
+
: {
|
|
203
|
+
Range:
|
|
204
|
+
`bytes=${range.start}-${range.end}`,
|
|
205
|
+
}),
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (!response.Body) {
|
|
210
|
+
return emptyStorageStream();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return ownStorageStream(
|
|
214
|
+
response.Body
|
|
215
|
+
.transformToWebStream() as
|
|
216
|
+
ReadableStream<
|
|
217
|
+
Uint8Array
|
|
218
|
+
>
|
|
219
|
+
);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (
|
|
222
|
+
isS3Status(
|
|
223
|
+
error,
|
|
224
|
+
404
|
|
225
|
+
)
|
|
226
|
+
) {
|
|
227
|
+
throw objectNotFound(
|
|
228
|
+
resolved.normalizedKey
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
isS3Status(
|
|
234
|
+
error,
|
|
235
|
+
416
|
|
236
|
+
)
|
|
237
|
+
) {
|
|
238
|
+
throw rangeNotSatisfiable(
|
|
239
|
+
resolved.normalizedKey,
|
|
240
|
+
metadata.size
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
client,
|
|
250
|
+
|
|
251
|
+
capabilities: {
|
|
252
|
+
streamingRead: true,
|
|
253
|
+
streamingWrite: true,
|
|
254
|
+
ranges: true,
|
|
255
|
+
signedUrls: false,
|
|
256
|
+
listing: false,
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async put(
|
|
260
|
+
key,
|
|
261
|
+
value,
|
|
262
|
+
putOptions:
|
|
263
|
+
StoragePutOptions = {}
|
|
264
|
+
) {
|
|
265
|
+
const resolved =
|
|
266
|
+
providerKey(
|
|
267
|
+
key
|
|
268
|
+
);
|
|
269
|
+
const bytes =
|
|
270
|
+
await toStorageBytes(
|
|
271
|
+
value
|
|
272
|
+
);
|
|
273
|
+
const checksumSha256 =
|
|
274
|
+
createHash(
|
|
275
|
+
"sha256"
|
|
276
|
+
)
|
|
277
|
+
.update(
|
|
278
|
+
bytes
|
|
279
|
+
)
|
|
280
|
+
.digest(
|
|
281
|
+
"hex"
|
|
282
|
+
);
|
|
283
|
+
const contentType =
|
|
284
|
+
normalizeContentType(
|
|
285
|
+
putOptions.contentType ??
|
|
286
|
+
(
|
|
287
|
+
value instanceof Blob
|
|
288
|
+
? value.type
|
|
289
|
+
: ""
|
|
290
|
+
)
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
await client.send(
|
|
295
|
+
new PutObjectCommand({
|
|
296
|
+
Bucket:
|
|
297
|
+
bucket,
|
|
298
|
+
Key:
|
|
299
|
+
resolved.providerKey,
|
|
300
|
+
Body:
|
|
301
|
+
bytes,
|
|
302
|
+
ContentLength:
|
|
303
|
+
bytes.byteLength,
|
|
304
|
+
ContentType:
|
|
305
|
+
contentType,
|
|
306
|
+
Metadata: {
|
|
307
|
+
bcp_sha256:
|
|
308
|
+
checksumSha256,
|
|
309
|
+
},
|
|
310
|
+
...(putOptions.overwrite
|
|
311
|
+
? {}
|
|
312
|
+
: {
|
|
313
|
+
IfNoneMatch:
|
|
314
|
+
"*",
|
|
315
|
+
}),
|
|
316
|
+
})
|
|
317
|
+
);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const mapped =
|
|
320
|
+
mapWriteError(
|
|
321
|
+
error,
|
|
322
|
+
resolved.normalizedKey
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
if (mapped) {
|
|
326
|
+
throw mapped;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return requireStoredMetadata(
|
|
333
|
+
statObject,
|
|
334
|
+
resolved.normalizedKey
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
async putStream(
|
|
339
|
+
key,
|
|
340
|
+
stream,
|
|
341
|
+
putOptions:
|
|
342
|
+
StoragePutStreamOptions = {}
|
|
343
|
+
) {
|
|
344
|
+
const resolved =
|
|
345
|
+
providerKey(
|
|
346
|
+
key
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
if (
|
|
350
|
+
!putOptions.overwrite &&
|
|
351
|
+
await statObject(
|
|
352
|
+
resolved.normalizedKey
|
|
353
|
+
) !== null
|
|
354
|
+
) {
|
|
355
|
+
throw objectExists(
|
|
356
|
+
resolved.normalizedKey
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const contentType =
|
|
361
|
+
normalizeContentType(
|
|
362
|
+
putOptions.contentType ??
|
|
363
|
+
""
|
|
364
|
+
);
|
|
365
|
+
const body =
|
|
366
|
+
createLimitedNodeReadable(
|
|
367
|
+
stream,
|
|
368
|
+
putOptions.maxBytes,
|
|
369
|
+
putOptions.signal
|
|
370
|
+
);
|
|
371
|
+
const upload =
|
|
372
|
+
new Upload({
|
|
373
|
+
client,
|
|
374
|
+
params: {
|
|
375
|
+
Bucket:
|
|
376
|
+
bucket,
|
|
377
|
+
Key:
|
|
378
|
+
resolved.providerKey,
|
|
379
|
+
Body:
|
|
380
|
+
body,
|
|
381
|
+
ContentType:
|
|
382
|
+
contentType,
|
|
383
|
+
},
|
|
384
|
+
leavePartsOnError:
|
|
385
|
+
false,
|
|
386
|
+
...(multipart.partSize === undefined
|
|
387
|
+
? {}
|
|
388
|
+
: {
|
|
389
|
+
partSize:
|
|
390
|
+
multipart.partSize,
|
|
391
|
+
}),
|
|
392
|
+
...(multipart.queueSize === undefined
|
|
393
|
+
? {}
|
|
394
|
+
: {
|
|
395
|
+
queueSize:
|
|
396
|
+
multipart.queueSize,
|
|
397
|
+
}),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const onAbort =
|
|
401
|
+
() => {
|
|
402
|
+
void upload.abort();
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
putOptions.signal
|
|
406
|
+
?.addEventListener(
|
|
407
|
+
"abort",
|
|
408
|
+
onAbort,
|
|
409
|
+
{
|
|
410
|
+
once: true,
|
|
411
|
+
}
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
putOptions.signal
|
|
416
|
+
?.throwIfAborted();
|
|
417
|
+
await upload.done();
|
|
418
|
+
putOptions.signal
|
|
419
|
+
?.throwIfAborted();
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (
|
|
422
|
+
putOptions.signal
|
|
423
|
+
?.aborted
|
|
424
|
+
) {
|
|
425
|
+
throw putOptions.signal
|
|
426
|
+
.reason ??
|
|
427
|
+
error;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const mapped =
|
|
431
|
+
mapWriteError(
|
|
432
|
+
error,
|
|
433
|
+
resolved.normalizedKey
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
if (mapped) {
|
|
437
|
+
throw mapped;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
throw error;
|
|
441
|
+
} finally {
|
|
442
|
+
putOptions.signal
|
|
443
|
+
?.removeEventListener(
|
|
444
|
+
"abort",
|
|
445
|
+
onAbort
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return requireStoredMetadata(
|
|
450
|
+
statObject,
|
|
451
|
+
resolved.normalizedKey
|
|
452
|
+
);
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
stat:
|
|
456
|
+
statObject,
|
|
457
|
+
|
|
458
|
+
async read(
|
|
459
|
+
key,
|
|
460
|
+
readOptions:
|
|
461
|
+
StorageReadOptions = {}
|
|
462
|
+
) {
|
|
463
|
+
return collectStorageStream(
|
|
464
|
+
await readStream(
|
|
465
|
+
key,
|
|
466
|
+
readOptions
|
|
467
|
+
)
|
|
468
|
+
);
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
readStream,
|
|
472
|
+
|
|
473
|
+
async exists(key) {
|
|
474
|
+
return (
|
|
475
|
+
await statObject(
|
|
476
|
+
key
|
|
477
|
+
)
|
|
478
|
+
) !== null;
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
async delete(key) {
|
|
482
|
+
const resolved =
|
|
483
|
+
providerKey(
|
|
484
|
+
key
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
if (
|
|
488
|
+
await statObject(
|
|
489
|
+
resolved.normalizedKey
|
|
490
|
+
) === null
|
|
491
|
+
) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
await client.send(
|
|
496
|
+
new DeleteObjectCommand({
|
|
497
|
+
Bucket:
|
|
498
|
+
bucket,
|
|
499
|
+
Key:
|
|
500
|
+
resolved.providerKey,
|
|
501
|
+
})
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
return true;
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
destroy() {
|
|
508
|
+
if (ownsClient) {
|
|
509
|
+
client.destroy();
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function createCredentials(
|
|
516
|
+
options: S3StorageOptions
|
|
517
|
+
): {
|
|
518
|
+
credentials: {
|
|
519
|
+
accessKeyId: string;
|
|
520
|
+
secretAccessKey: string;
|
|
521
|
+
sessionToken?: string;
|
|
522
|
+
};
|
|
523
|
+
} | Record<string, never> {
|
|
524
|
+
const hasAccessKey =
|
|
525
|
+
typeof options.accessKeyId ===
|
|
526
|
+
"string" &&
|
|
527
|
+
options.accessKeyId.trim().length >
|
|
528
|
+
0;
|
|
529
|
+
const hasSecret =
|
|
530
|
+
typeof options.secretAccessKey ===
|
|
531
|
+
"string" &&
|
|
532
|
+
options.secretAccessKey.trim().length >
|
|
533
|
+
0;
|
|
534
|
+
|
|
535
|
+
if (
|
|
536
|
+
hasAccessKey !== hasSecret
|
|
537
|
+
) {
|
|
538
|
+
throw new TypeError(
|
|
539
|
+
"BCP Framework: S3 accessKeyId and secretAccessKey must be provided together."
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!hasAccessKey) {
|
|
544
|
+
return {};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
credentials: {
|
|
549
|
+
accessKeyId:
|
|
550
|
+
options.accessKeyId!
|
|
551
|
+
.trim(),
|
|
552
|
+
secretAccessKey:
|
|
553
|
+
options.secretAccessKey!
|
|
554
|
+
.trim(),
|
|
555
|
+
...(typeof options.sessionToken ===
|
|
556
|
+
"string" &&
|
|
557
|
+
options.sessionToken.trim().length >
|
|
558
|
+
0
|
|
559
|
+
? {
|
|
560
|
+
sessionToken:
|
|
561
|
+
options.sessionToken.trim(),
|
|
562
|
+
}
|
|
563
|
+
: {}),
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function normalizeMultipartOptions(
|
|
569
|
+
value:
|
|
570
|
+
S3StorageMultipartOptions |
|
|
571
|
+
undefined
|
|
572
|
+
): S3StorageMultipartOptions {
|
|
573
|
+
if (!value) {
|
|
574
|
+
return {};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (
|
|
578
|
+
value.partSize !== undefined &&
|
|
579
|
+
(
|
|
580
|
+
!Number.isSafeInteger(
|
|
581
|
+
value.partSize
|
|
582
|
+
) ||
|
|
583
|
+
value.partSize <
|
|
584
|
+
MIN_MULTIPART_PART_BYTES
|
|
585
|
+
)
|
|
586
|
+
) {
|
|
587
|
+
throw new TypeError(
|
|
588
|
+
`BCP Framework: S3 multipart partSize must be at least ${MIN_MULTIPART_PART_BYTES} bytes.`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (
|
|
593
|
+
value.queueSize !== undefined &&
|
|
594
|
+
(
|
|
595
|
+
!Number.isSafeInteger(
|
|
596
|
+
value.queueSize
|
|
597
|
+
) ||
|
|
598
|
+
value.queueSize < 1
|
|
599
|
+
)
|
|
600
|
+
) {
|
|
601
|
+
throw new TypeError(
|
|
602
|
+
"BCP Framework: S3 multipart queueSize must be a positive safe integer."
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
...(value.partSize === undefined
|
|
608
|
+
? {}
|
|
609
|
+
: {
|
|
610
|
+
partSize:
|
|
611
|
+
value.partSize,
|
|
612
|
+
}),
|
|
613
|
+
...(value.queueSize === undefined
|
|
614
|
+
? {}
|
|
615
|
+
: {
|
|
616
|
+
queueSize:
|
|
617
|
+
value.queueSize,
|
|
618
|
+
}),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function normalizePrefix(
|
|
623
|
+
value: string | undefined
|
|
624
|
+
): string {
|
|
625
|
+
if (value === undefined) {
|
|
626
|
+
return "";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const trimmed =
|
|
630
|
+
value
|
|
631
|
+
.trim()
|
|
632
|
+
.replace(
|
|
633
|
+
/^\/+|\/+$/g,
|
|
634
|
+
""
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
if (trimmed.length === 0) {
|
|
638
|
+
return "";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return normalizeStorageKey(
|
|
642
|
+
trimmed
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function toStorageBytes(
|
|
647
|
+
value: StorageWriteValue
|
|
648
|
+
): Promise<Uint8Array> {
|
|
649
|
+
if (
|
|
650
|
+
typeof value ===
|
|
651
|
+
"string"
|
|
652
|
+
) {
|
|
653
|
+
return Uint8Array.from(
|
|
654
|
+
Buffer.from(
|
|
655
|
+
value,
|
|
656
|
+
"utf8"
|
|
657
|
+
)
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (
|
|
662
|
+
value instanceof Blob
|
|
663
|
+
) {
|
|
664
|
+
return new Uint8Array(
|
|
665
|
+
await value.arrayBuffer()
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (
|
|
670
|
+
value instanceof ArrayBuffer
|
|
671
|
+
) {
|
|
672
|
+
return new Uint8Array(
|
|
673
|
+
value
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (
|
|
678
|
+
ArrayBuffer.isView(
|
|
679
|
+
value
|
|
680
|
+
)
|
|
681
|
+
) {
|
|
682
|
+
return Uint8Array.from(
|
|
683
|
+
new Uint8Array(
|
|
684
|
+
value.buffer,
|
|
685
|
+
value.byteOffset,
|
|
686
|
+
value.byteLength
|
|
687
|
+
)
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
throw new TypeError(
|
|
692
|
+
"BCP Framework: unsupported S3 storage write value."
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function createLimitedNodeReadable(
|
|
697
|
+
stream:
|
|
698
|
+
ReadableStream<Uint8Array>,
|
|
699
|
+
maxBytes: number | undefined,
|
|
700
|
+
signal: AbortSignal | undefined
|
|
701
|
+
): Readable {
|
|
702
|
+
const normalizedLimit =
|
|
703
|
+
normalizeOptionalByteLimit(
|
|
704
|
+
maxBytes
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
return Readable.from(
|
|
708
|
+
(async function* () {
|
|
709
|
+
const reader =
|
|
710
|
+
stream.getReader();
|
|
711
|
+
let totalBytes =
|
|
712
|
+
0;
|
|
713
|
+
const onAbort =
|
|
714
|
+
() => {
|
|
715
|
+
void reader.cancel(
|
|
716
|
+
signal?.reason
|
|
717
|
+
);
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
signal?.addEventListener(
|
|
721
|
+
"abort",
|
|
722
|
+
onAbort,
|
|
723
|
+
{
|
|
724
|
+
once: true,
|
|
725
|
+
}
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
try {
|
|
729
|
+
while (true) {
|
|
730
|
+
signal?.throwIfAborted();
|
|
731
|
+
|
|
732
|
+
const result =
|
|
733
|
+
await reader.read();
|
|
734
|
+
|
|
735
|
+
if (result.done) {
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const chunk =
|
|
740
|
+
result.value;
|
|
741
|
+
const nextTotal =
|
|
742
|
+
totalBytes +
|
|
743
|
+
chunk.byteLength;
|
|
744
|
+
|
|
745
|
+
if (
|
|
746
|
+
normalizedLimit !== undefined &&
|
|
747
|
+
nextTotal >
|
|
748
|
+
normalizedLimit
|
|
749
|
+
) {
|
|
750
|
+
throw new StorageError(
|
|
751
|
+
"STREAM_TOO_LARGE",
|
|
752
|
+
`BCP Framework: storage stream exceeded ${normalizedLimit} bytes.`,
|
|
753
|
+
413
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
totalBytes =
|
|
758
|
+
nextTotal;
|
|
759
|
+
|
|
760
|
+
yield Buffer.from(
|
|
761
|
+
chunk
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
} catch (error) {
|
|
765
|
+
try {
|
|
766
|
+
await reader.cancel(
|
|
767
|
+
error
|
|
768
|
+
);
|
|
769
|
+
} catch {
|
|
770
|
+
// Preserve the source/upload failure.
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
throw error;
|
|
774
|
+
} finally {
|
|
775
|
+
signal?.removeEventListener(
|
|
776
|
+
"abort",
|
|
777
|
+
onAbort
|
|
778
|
+
);
|
|
779
|
+
reader.releaseLock();
|
|
780
|
+
}
|
|
781
|
+
})()
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function collectStorageStream(
|
|
786
|
+
stream: StorageReadableStream
|
|
787
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
788
|
+
const reader =
|
|
789
|
+
stream.getReader();
|
|
790
|
+
const chunks:
|
|
791
|
+
Uint8Array<ArrayBuffer>[] = [];
|
|
792
|
+
let totalBytes =
|
|
793
|
+
0;
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
while (true) {
|
|
797
|
+
const result =
|
|
798
|
+
await reader.read();
|
|
799
|
+
|
|
800
|
+
if (result.done) {
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const chunk =
|
|
805
|
+
result.value;
|
|
806
|
+
const owned =
|
|
807
|
+
new Uint8Array(
|
|
808
|
+
chunk.byteLength
|
|
809
|
+
);
|
|
810
|
+
owned.set(
|
|
811
|
+
chunk
|
|
812
|
+
);
|
|
813
|
+
chunks.push(
|
|
814
|
+
owned
|
|
815
|
+
);
|
|
816
|
+
totalBytes +=
|
|
817
|
+
owned.byteLength;
|
|
818
|
+
}
|
|
819
|
+
} finally {
|
|
820
|
+
reader.releaseLock();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const result =
|
|
824
|
+
new Uint8Array(
|
|
825
|
+
totalBytes
|
|
826
|
+
);
|
|
827
|
+
let offset =
|
|
828
|
+
0;
|
|
829
|
+
|
|
830
|
+
for (const chunk of chunks) {
|
|
831
|
+
result.set(
|
|
832
|
+
chunk,
|
|
833
|
+
offset
|
|
834
|
+
);
|
|
835
|
+
offset +=
|
|
836
|
+
chunk.byteLength;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function ownStorageStream(
|
|
843
|
+
source:
|
|
844
|
+
ReadableStream<Uint8Array>
|
|
845
|
+
): StorageReadableStream {
|
|
846
|
+
const reader =
|
|
847
|
+
source.getReader();
|
|
848
|
+
|
|
849
|
+
return new ReadableStream<
|
|
850
|
+
Uint8Array<ArrayBuffer>
|
|
851
|
+
>({
|
|
852
|
+
async pull(controller) {
|
|
853
|
+
try {
|
|
854
|
+
const result =
|
|
855
|
+
await reader.read();
|
|
856
|
+
|
|
857
|
+
if (result.done) {
|
|
858
|
+
reader.releaseLock();
|
|
859
|
+
controller.close();
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const owned =
|
|
864
|
+
new Uint8Array(
|
|
865
|
+
result.value.byteLength
|
|
866
|
+
);
|
|
867
|
+
owned.set(
|
|
868
|
+
result.value
|
|
869
|
+
);
|
|
870
|
+
controller.enqueue(
|
|
871
|
+
owned
|
|
872
|
+
);
|
|
873
|
+
} catch (error) {
|
|
874
|
+
try {
|
|
875
|
+
reader.releaseLock();
|
|
876
|
+
} catch {
|
|
877
|
+
// Ignore lock cleanup after failure.
|
|
878
|
+
}
|
|
879
|
+
controller.error(
|
|
880
|
+
error
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
async cancel(reason) {
|
|
886
|
+
try {
|
|
887
|
+
await reader.cancel(
|
|
888
|
+
reason
|
|
889
|
+
);
|
|
890
|
+
} finally {
|
|
891
|
+
try {
|
|
892
|
+
reader.releaseLock();
|
|
893
|
+
} catch {
|
|
894
|
+
// Ignore duplicate release.
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function emptyStorageStream(): StorageReadableStream {
|
|
902
|
+
return new ReadableStream<
|
|
903
|
+
Uint8Array<ArrayBuffer>
|
|
904
|
+
>({
|
|
905
|
+
start(controller) {
|
|
906
|
+
controller.close();
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function normalizeRemoteRange(
|
|
912
|
+
key: string,
|
|
913
|
+
size: number,
|
|
914
|
+
options: StorageReadOptions
|
|
915
|
+
): {
|
|
916
|
+
start: number;
|
|
917
|
+
end: number;
|
|
918
|
+
} | null {
|
|
919
|
+
if (
|
|
920
|
+
options.start === undefined &&
|
|
921
|
+
options.end === undefined
|
|
922
|
+
) {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const start =
|
|
927
|
+
options.start ??
|
|
928
|
+
0;
|
|
929
|
+
const end =
|
|
930
|
+
options.end ??
|
|
931
|
+
size - 1;
|
|
932
|
+
|
|
933
|
+
if (
|
|
934
|
+
!Number.isSafeInteger(
|
|
935
|
+
start
|
|
936
|
+
) ||
|
|
937
|
+
!Number.isSafeInteger(
|
|
938
|
+
end
|
|
939
|
+
) ||
|
|
940
|
+
start < 0 ||
|
|
941
|
+
end < start ||
|
|
942
|
+
start >= size ||
|
|
943
|
+
end >= size
|
|
944
|
+
) {
|
|
945
|
+
throw rangeNotSatisfiable(
|
|
946
|
+
key,
|
|
947
|
+
size
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return {
|
|
952
|
+
start,
|
|
953
|
+
end,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function createS3Metadata(
|
|
958
|
+
key: string,
|
|
959
|
+
size: number,
|
|
960
|
+
contentType: string,
|
|
961
|
+
lastModified: Date,
|
|
962
|
+
etag: string | undefined,
|
|
963
|
+
checksumSha256: string | undefined
|
|
964
|
+
): StorageObjectMetadata {
|
|
965
|
+
const normalizedChecksum =
|
|
966
|
+
typeof checksumSha256 ===
|
|
967
|
+
"string" &&
|
|
968
|
+
/^[a-f0-9]{64}$/i.test(
|
|
969
|
+
checksumSha256
|
|
970
|
+
)
|
|
971
|
+
? checksumSha256
|
|
972
|
+
.toLowerCase()
|
|
973
|
+
: undefined;
|
|
974
|
+
const normalizedEtag =
|
|
975
|
+
typeof etag === "string" &&
|
|
976
|
+
etag.trim().length > 0
|
|
977
|
+
? etag.trim()
|
|
978
|
+
: `W/"${size}-${Math.trunc(
|
|
979
|
+
lastModified.getTime()
|
|
980
|
+
)}"`;
|
|
981
|
+
|
|
982
|
+
return {
|
|
983
|
+
key,
|
|
984
|
+
size,
|
|
985
|
+
contentType:
|
|
986
|
+
normalizeContentType(
|
|
987
|
+
contentType
|
|
988
|
+
),
|
|
989
|
+
lastModified:
|
|
990
|
+
new Date(
|
|
991
|
+
lastModified.getTime()
|
|
992
|
+
),
|
|
993
|
+
etag:
|
|
994
|
+
normalizedEtag,
|
|
995
|
+
...(normalizedChecksum === undefined
|
|
996
|
+
? {}
|
|
997
|
+
: {
|
|
998
|
+
checksumSha256:
|
|
999
|
+
normalizedChecksum,
|
|
1000
|
+
}),
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function requireStoredMetadata(
|
|
1005
|
+
statObject:
|
|
1006
|
+
(
|
|
1007
|
+
key: string
|
|
1008
|
+
) => Promise<
|
|
1009
|
+
StorageObjectMetadata |
|
|
1010
|
+
null
|
|
1011
|
+
>,
|
|
1012
|
+
key: string
|
|
1013
|
+
): Promise<StorageObjectMetadata> {
|
|
1014
|
+
const metadata =
|
|
1015
|
+
await statObject(
|
|
1016
|
+
key
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
if (!metadata) {
|
|
1020
|
+
throw new Error(
|
|
1021
|
+
`BCP Framework: S3 object "${key}" was written but could not be read back with HeadObject.`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return metadata;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function mapWriteError(
|
|
1029
|
+
error: unknown,
|
|
1030
|
+
key: string
|
|
1031
|
+
): StorageError | null {
|
|
1032
|
+
if (error instanceof StorageError) {
|
|
1033
|
+
return error;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (
|
|
1037
|
+
isS3Status(
|
|
1038
|
+
error,
|
|
1039
|
+
409
|
|
1040
|
+
) ||
|
|
1041
|
+
isS3Status(
|
|
1042
|
+
error,
|
|
1043
|
+
412
|
|
1044
|
+
)
|
|
1045
|
+
) {
|
|
1046
|
+
return objectExists(
|
|
1047
|
+
key
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function objectNotFound(
|
|
1055
|
+
key: string
|
|
1056
|
+
): StorageError {
|
|
1057
|
+
return new StorageError(
|
|
1058
|
+
"OBJECT_NOT_FOUND",
|
|
1059
|
+
`BCP Framework: storage object "${key}" was not found.`,
|
|
1060
|
+
404
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function objectExists(
|
|
1065
|
+
key: string
|
|
1066
|
+
): StorageError {
|
|
1067
|
+
return new StorageError(
|
|
1068
|
+
"OBJECT_EXISTS",
|
|
1069
|
+
`BCP Framework: storage object "${key}" already exists.`,
|
|
1070
|
+
409
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function rangeNotSatisfiable(
|
|
1075
|
+
key: string,
|
|
1076
|
+
size: number
|
|
1077
|
+
): StorageError {
|
|
1078
|
+
return new StorageError(
|
|
1079
|
+
"RANGE_NOT_SATISFIABLE",
|
|
1080
|
+
`BCP Framework: requested storage range for "${key}" is invalid for ${size} bytes.`,
|
|
1081
|
+
416
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function isS3Status(
|
|
1086
|
+
error: unknown,
|
|
1087
|
+
status: number
|
|
1088
|
+
): boolean {
|
|
1089
|
+
return (
|
|
1090
|
+
error instanceof Error &&
|
|
1091
|
+
"$metadata" in error &&
|
|
1092
|
+
(
|
|
1093
|
+
error as Error & {
|
|
1094
|
+
$metadata?: {
|
|
1095
|
+
httpStatusCode?: number;
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
).$metadata
|
|
1099
|
+
?.httpStatusCode ===
|
|
1100
|
+
status
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function normalizeContentType(
|
|
1105
|
+
value: string
|
|
1106
|
+
): string {
|
|
1107
|
+
const normalized =
|
|
1108
|
+
value
|
|
1109
|
+
.trim()
|
|
1110
|
+
.toLowerCase();
|
|
1111
|
+
|
|
1112
|
+
if (
|
|
1113
|
+
normalized.length === 0 ||
|
|
1114
|
+
/[\r\n]/.test(
|
|
1115
|
+
normalized
|
|
1116
|
+
)
|
|
1117
|
+
) {
|
|
1118
|
+
return "application/octet-stream";
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
return normalized;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function normalizeOptionalByteLimit(
|
|
1125
|
+
value: number | undefined
|
|
1126
|
+
): number | undefined {
|
|
1127
|
+
if (value === undefined) {
|
|
1128
|
+
return undefined;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (
|
|
1132
|
+
!Number.isSafeInteger(
|
|
1133
|
+
value
|
|
1134
|
+
) ||
|
|
1135
|
+
value < 0
|
|
1136
|
+
) {
|
|
1137
|
+
throw new TypeError(
|
|
1138
|
+
"BCP Framework: S3 storage stream maxBytes must be a non-negative safe integer."
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return value;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function assertNonEmptyString(
|
|
1146
|
+
value: string,
|
|
1147
|
+
label: string
|
|
1148
|
+
): string {
|
|
1149
|
+
if (
|
|
1150
|
+
typeof value !== "string" ||
|
|
1151
|
+
value.trim().length === 0
|
|
1152
|
+
) {
|
|
1153
|
+
throw new TypeError(
|
|
1154
|
+
`BCP Framework: S3 storage ${label} must be a non-empty string.`
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return value.trim();
|
|
1159
|
+
}
|