@travetto/model-s3 7.1.4 → 8.0.0-alpha.1
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 +3 -2
- package/package.json +7 -7
- package/src/config.ts +3 -1
- package/src/service.ts +68 -70
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ export class Init {
|
|
|
37
37
|
}
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
where the [S3ModelConfig](https://github.com/travetto/travetto/tree/main/module/model-s3/src/config.ts#
|
|
40
|
+
where the [S3ModelConfig](https://github.com/travetto/travetto/tree/main/module/model-s3/src/config.ts#L13) is defined by:
|
|
41
41
|
|
|
42
42
|
**Code: Structure of S3ModelConfig**
|
|
43
43
|
```typescript
|
|
@@ -74,7 +74,8 @@ export class S3ModelConfig {
|
|
|
74
74
|
/**
|
|
75
75
|
* Produces the s3 config from the provide details, post construction
|
|
76
76
|
*/
|
|
77
|
-
|
|
77
|
+
@PostConstruct()
|
|
78
|
+
async finalizeConfig(): Promise<void> {
|
|
78
79
|
if (!Runtime.production) {
|
|
79
80
|
this.endpoint ??= 'http://localhost:4566'; // From docker
|
|
80
81
|
this.bucket ??= 'app';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/model-s3",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.0-alpha.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "S3 backing for the travetto model module.",
|
|
6
6
|
"keywords": [
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
"directory": "module/model-s3"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@aws-sdk/client-s3": "^3.
|
|
30
|
-
"@aws-sdk/credential-provider-ini": "^3.
|
|
31
|
-
"@aws-sdk/s3-request-presigner": "^3.
|
|
32
|
-
"@travetto/config": "^
|
|
33
|
-
"@travetto/model": "^
|
|
29
|
+
"@aws-sdk/client-s3": "^3.1004.0",
|
|
30
|
+
"@aws-sdk/credential-provider-ini": "^3.972.17",
|
|
31
|
+
"@aws-sdk/s3-request-presigner": "^3.1004.0",
|
|
32
|
+
"@travetto/config": "^8.0.0-alpha.1",
|
|
33
|
+
"@travetto/model": "^8.0.0-alpha.1"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@travetto/cli": "^
|
|
36
|
+
"@travetto/cli": "^8.0.0-alpha.1"
|
|
37
37
|
},
|
|
38
38
|
"peerDependenciesMeta": {
|
|
39
39
|
"@travetto/cli": {
|
package/src/config.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type s3 from '@aws-sdk/client-s3';
|
|
|
4
4
|
import { Config, EnvVar } from '@travetto/config';
|
|
5
5
|
import { Required } from '@travetto/schema';
|
|
6
6
|
import { Runtime } from '@travetto/runtime';
|
|
7
|
+
import { PostConstruct } from '@travetto/di';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* S3 Support as an Asset Source
|
|
@@ -41,7 +42,8 @@ export class S3ModelConfig {
|
|
|
41
42
|
/**
|
|
42
43
|
* Produces the s3 config from the provide details, post construction
|
|
43
44
|
*/
|
|
44
|
-
|
|
45
|
+
@PostConstruct()
|
|
46
|
+
async finalizeConfig(): Promise<void> {
|
|
45
47
|
if (!Runtime.production) {
|
|
46
48
|
this.endpoint ??= 'http://localhost:4566'; // From docker
|
|
47
49
|
this.bucket ??= 'app';
|
package/src/service.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { Readable } from 'node:stream';
|
|
2
|
-
import { text as toText, buffer as toBuffer } from 'node:stream/consumers';
|
|
3
1
|
import { Agent } from 'node:https';
|
|
4
2
|
|
|
5
3
|
import { S3, type CompletedPart, type CreateMultipartUploadRequest, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
@@ -9,12 +7,12 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
|
9
7
|
|
|
10
8
|
import {
|
|
11
9
|
type ModelCrudSupport, type ModelStorageSupport, type ModelType, ModelRegistryIndex, ExistsError, NotFoundError, type OptionalId,
|
|
12
|
-
type ModelBlobSupport, type ModelExpirySupport,
|
|
10
|
+
type ModelBlobSupport, type ModelExpirySupport, ModelCrudUtil, ModelExpiryUtil, ModelStorageUtil
|
|
13
11
|
} from '@travetto/model';
|
|
14
|
-
import { Injectable } from '@travetto/di';
|
|
12
|
+
import { Injectable, PostConstruct } from '@travetto/di';
|
|
15
13
|
import {
|
|
16
|
-
type Class,
|
|
17
|
-
type
|
|
14
|
+
type Class, RuntimeError, castTo, asFull, type BinaryMetadata, type ByteRange, type BinaryType,
|
|
15
|
+
BinaryUtil, type TimeSpan, TimeUtil, type BinaryArray, CodecUtil, BinaryMetadataUtil, TypedObject, JSONUtil
|
|
18
16
|
} from '@travetto/runtime';
|
|
19
17
|
|
|
20
18
|
import type { S3ModelConfig } from './config.ts';
|
|
@@ -27,7 +25,7 @@ function hasContentType<T>(value: T): value is T & { contenttype?: string } {
|
|
|
27
25
|
return value !== undefined && value !== null && Object.hasOwn(value, 'contenttype');
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
type
|
|
28
|
+
type S3Metadata = Pick<CreateMultipartUploadRequest,
|
|
31
29
|
'ContentType' | 'Metadata' | 'ContentEncoding' | 'ContentLanguage' | 'CacheControl' | 'ContentDisposition'
|
|
32
30
|
>;
|
|
33
31
|
|
|
@@ -43,16 +41,16 @@ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, Model
|
|
|
43
41
|
|
|
44
42
|
constructor(config: S3ModelConfig) { this.config = config; }
|
|
45
43
|
|
|
46
|
-
#
|
|
44
|
+
#getMetadata(metadata: BinaryMetadata): S3Metadata {
|
|
47
45
|
return {
|
|
48
|
-
ContentType:
|
|
49
|
-
...(
|
|
50
|
-
...(
|
|
51
|
-
...(
|
|
52
|
-
Metadata:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
ContentType: metadata.contentType,
|
|
47
|
+
...(metadata.contentEncoding ? { ContentEncoding: metadata.contentEncoding } : {}),
|
|
48
|
+
...(metadata.contentLanguage ? { ContentLanguage: metadata.contentLanguage } : {}),
|
|
49
|
+
...(metadata.cacheControl ? { CacheControl: metadata.cacheControl } : {}),
|
|
50
|
+
Metadata: TypedObject.fromEntries(
|
|
51
|
+
TypedObject.entries(metadata)
|
|
52
|
+
.map(([key, value]) => [key, typeof value === 'string' ? value : JSONUtil.toUTF8(value)] as const)
|
|
53
|
+
)
|
|
56
54
|
};
|
|
57
55
|
}
|
|
58
56
|
|
|
@@ -117,17 +115,17 @@ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, Model
|
|
|
117
115
|
/**
|
|
118
116
|
* Write multipart file upload, in chunks
|
|
119
117
|
*/
|
|
120
|
-
async #writeMultipart(id: string, input:
|
|
121
|
-
const { UploadId } = await this.client.createMultipartUpload(this.#queryBlob(id, this.#
|
|
118
|
+
async #writeMultipart(id: string, input: BinaryType, metadata: BinaryMetadata): Promise<void> {
|
|
119
|
+
const { UploadId } = await this.client.createMultipartUpload(this.#queryBlob(id, this.#getMetadata(metadata)));
|
|
122
120
|
|
|
123
121
|
const parts: CompletedPart[] = [];
|
|
124
|
-
let buffers:
|
|
122
|
+
let buffers: BinaryArray[] = [];
|
|
125
123
|
let total = 0;
|
|
126
124
|
let i = 1;
|
|
127
125
|
const flush = async (): Promise<void> => {
|
|
128
126
|
if (!total) { return; }
|
|
129
127
|
const part = await this.client.uploadPart(this.#queryBlob(id, {
|
|
130
|
-
Body:
|
|
128
|
+
Body: BinaryUtil.binaryArrayToUint8Array(BinaryUtil.combineBinaryArrays(buffers)),
|
|
131
129
|
PartNumber: i,
|
|
132
130
|
UploadId
|
|
133
131
|
}));
|
|
@@ -137,10 +135,10 @@ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, Model
|
|
|
137
135
|
total = 0;
|
|
138
136
|
};
|
|
139
137
|
try {
|
|
140
|
-
for await (const chunk of input) {
|
|
141
|
-
const chunked =
|
|
138
|
+
for await (const chunk of BinaryUtil.toBinaryStream(input)) {
|
|
139
|
+
const chunked = CodecUtil.readUtf8Chunk(chunk);
|
|
142
140
|
buffers.push(chunked);
|
|
143
|
-
total += chunked.
|
|
141
|
+
total += chunked.byteLength;
|
|
144
142
|
if (total > this.config.chunkSize) {
|
|
145
143
|
await flush();
|
|
146
144
|
}
|
|
@@ -180,7 +178,8 @@ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, Model
|
|
|
180
178
|
}
|
|
181
179
|
}
|
|
182
180
|
|
|
183
|
-
|
|
181
|
+
@PostConstruct()
|
|
182
|
+
async initializeClient(): Promise<void> {
|
|
184
183
|
this.client = new S3({
|
|
185
184
|
...this.config.config,
|
|
186
185
|
...('requestHandler' in this.config.config ? {
|
|
@@ -217,7 +216,7 @@ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, Model
|
|
|
217
216
|
try {
|
|
218
217
|
const result = await this.client.getObject(this.#query(cls, id));
|
|
219
218
|
if (result.Body) {
|
|
220
|
-
const body = await
|
|
219
|
+
const body = await BinaryUtil.toBinaryArray(result.Body);
|
|
221
220
|
const output = await ModelCrudUtil.load(cls, body);
|
|
222
221
|
if (output) {
|
|
223
222
|
const { expiresAt } = ModelRegistryIndex.getConfig(cls);
|
|
@@ -247,11 +246,11 @@ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, Model
|
|
|
247
246
|
if (preStore) {
|
|
248
247
|
prepped = await ModelCrudUtil.preStore(cls, item, this);
|
|
249
248
|
}
|
|
250
|
-
const content =
|
|
249
|
+
const content = JSONUtil.toBinaryArray(prepped);
|
|
251
250
|
await this.client.putObject(this.#query(cls, prepped.id, {
|
|
252
|
-
Body: content,
|
|
251
|
+
Body: BinaryUtil.binaryArrayToUint8Array(content),
|
|
253
252
|
ContentType: 'application/json',
|
|
254
|
-
ContentLength: content.
|
|
253
|
+
ContentLength: content.byteLength,
|
|
255
254
|
...this.#getExpiryConfig(cls, prepped)
|
|
256
255
|
}));
|
|
257
256
|
return prepped;
|
|
@@ -314,53 +313,52 @@ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, Model
|
|
|
314
313
|
}
|
|
315
314
|
|
|
316
315
|
// Blob support
|
|
317
|
-
async upsertBlob(location: string, input:
|
|
318
|
-
if (!overwrite && await this.
|
|
316
|
+
async upsertBlob(location: string, input: BinaryType, metadata?: BinaryMetadata, overwrite = true): Promise<void> {
|
|
317
|
+
if (!overwrite && await this.getBlobMetadata(location).then(() => true, () => false)) {
|
|
319
318
|
return;
|
|
320
319
|
}
|
|
321
320
|
|
|
322
|
-
const
|
|
321
|
+
const resolved = await BinaryMetadataUtil.compute(input, metadata);
|
|
322
|
+
|
|
323
|
+
const length = BinaryMetadataUtil.readLength(resolved);
|
|
323
324
|
|
|
324
|
-
if (
|
|
325
|
+
if (length && length < this.config.chunkSize) { // If smaller than chunk size
|
|
326
|
+
const blob = this.#queryBlob(location, {
|
|
327
|
+
Body: BinaryUtil.toReadable(input),
|
|
328
|
+
ContentLength: length,
|
|
329
|
+
...this.#getMetadata(resolved),
|
|
330
|
+
});
|
|
325
331
|
// Upload to s3
|
|
326
|
-
await this.client.putObject(
|
|
327
|
-
Body: await toBuffer(stream),
|
|
328
|
-
ContentLength: blobMeta.size,
|
|
329
|
-
...this.#getMetaBase(blobMeta),
|
|
330
|
-
}));
|
|
332
|
+
await this.client.putObject(blob);
|
|
331
333
|
} else {
|
|
332
|
-
await this.#writeMultipart(location,
|
|
334
|
+
await this.#writeMultipart(location, input, resolved);
|
|
333
335
|
}
|
|
334
336
|
}
|
|
335
337
|
|
|
336
|
-
async #getObject(location: string, range?: Required<ByteRange>): Promise<
|
|
338
|
+
async #getObject(location: string, range?: Required<ByteRange>): Promise<BinaryType> {
|
|
337
339
|
// Read from s3
|
|
338
340
|
const result = await this.client.getObject(this.#queryBlob(location, range ? {
|
|
339
341
|
Range: `bytes=${range.start}-${range.end}`
|
|
340
342
|
} : {}));
|
|
341
343
|
|
|
342
|
-
|
|
343
|
-
throw new AppError('Unable to read type: undefined');
|
|
344
|
-
}
|
|
344
|
+
const body: BinaryType | string | undefined = castTo(result.Body);
|
|
345
345
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return
|
|
346
|
+
switch (typeof body) {
|
|
347
|
+
case 'undefined': throw new RuntimeError('Unable to read type: undefined');
|
|
348
|
+
case 'string': return body.endsWith('=') ?
|
|
349
|
+
CodecUtil.fromBase64String(body) :
|
|
350
|
+
CodecUtil.fromUTF8String(body);
|
|
351
|
+
default: return body;
|
|
352
352
|
}
|
|
353
|
-
throw new AppError(`Unable to read type: ${typeof result.Body}`);
|
|
354
353
|
}
|
|
355
354
|
|
|
356
355
|
async getBlob(location: string, range?: ByteRange): Promise<Blob> {
|
|
357
|
-
const
|
|
358
|
-
const final = range ?
|
|
359
|
-
|
|
360
|
-
return BinaryUtil.readableBlob(result, { ...meta, range: final });
|
|
356
|
+
const metadata = await this.getBlobMetadata(location);
|
|
357
|
+
const final = range ? BinaryMetadataUtil.enforceRange(range, metadata) : undefined;
|
|
358
|
+
return BinaryMetadataUtil.makeBlob(() => this.#getObject(location, final), { ...metadata, range: final });
|
|
361
359
|
}
|
|
362
360
|
|
|
363
|
-
async headBlob(location: string): Promise<{ Metadata?:
|
|
361
|
+
async headBlob(location: string): Promise<{ Metadata?: BinaryMetadata, ContentLength?: number }> {
|
|
364
362
|
const query = this.#queryBlob(location);
|
|
365
363
|
try {
|
|
366
364
|
return (await this.client.headObject(query));
|
|
@@ -374,20 +372,20 @@ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, Model
|
|
|
374
372
|
}
|
|
375
373
|
}
|
|
376
374
|
|
|
377
|
-
async
|
|
375
|
+
async getBlobMetadata(location: string): Promise<BinaryMetadata> {
|
|
378
376
|
const blob = await this.headBlob(location);
|
|
379
377
|
|
|
380
378
|
if (blob) {
|
|
381
|
-
const
|
|
379
|
+
const metadata: BinaryMetadata = {
|
|
382
380
|
contentType: '',
|
|
383
381
|
...blob.Metadata,
|
|
384
382
|
size: blob.ContentLength!,
|
|
385
383
|
};
|
|
386
|
-
if (hasContentType(
|
|
387
|
-
|
|
388
|
-
delete
|
|
384
|
+
if (hasContentType(metadata)) {
|
|
385
|
+
metadata['contentType'] = metadata['contenttype']!;
|
|
386
|
+
delete metadata['contenttype'];
|
|
389
387
|
}
|
|
390
|
-
return
|
|
388
|
+
return metadata;
|
|
391
389
|
} else {
|
|
392
390
|
throw new NotFoundError('Blob', location);
|
|
393
391
|
}
|
|
@@ -397,38 +395,38 @@ export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, Model
|
|
|
397
395
|
await this.client.deleteObject(this.#queryBlob(location));
|
|
398
396
|
}
|
|
399
397
|
|
|
400
|
-
async
|
|
398
|
+
async updateBlobMetadata(location: string, metadata: BinaryMetadata): Promise<void> {
|
|
401
399
|
await this.client.copyObject({
|
|
402
400
|
Bucket: this.config.bucket,
|
|
403
401
|
Key: this.#basicKey(location),
|
|
404
402
|
CopySource: `/${this.config.bucket}/${this.#basicKey(location)}`,
|
|
405
|
-
...this.#
|
|
403
|
+
...this.#getMetadata(metadata),
|
|
406
404
|
MetadataDirective: 'REPLACE'
|
|
407
405
|
});
|
|
408
406
|
}
|
|
409
407
|
|
|
410
408
|
// Signed urls
|
|
411
|
-
async getBlobReadUrl(location: string,
|
|
409
|
+
async getBlobReadUrl(location: string, expiresIn: TimeSpan = '1h'): Promise<string> {
|
|
412
410
|
return await getSignedUrl(
|
|
413
411
|
this.client,
|
|
414
412
|
new GetObjectCommand(this.#queryBlob(location)),
|
|
415
|
-
{ expiresIn: TimeUtil.
|
|
413
|
+
{ expiresIn: TimeUtil.duration(expiresIn, 's') }
|
|
416
414
|
);
|
|
417
415
|
}
|
|
418
416
|
|
|
419
|
-
async getBlobWriteUrl(location: string,
|
|
420
|
-
const base = this.#
|
|
417
|
+
async getBlobWriteUrl(location: string, metadata: BinaryMetadata, expiresIn: TimeSpan = '1h'): Promise<string> {
|
|
418
|
+
const base = this.#getMetadata(metadata);
|
|
421
419
|
return await getSignedUrl(
|
|
422
420
|
this.client,
|
|
423
421
|
new PutObjectCommand({
|
|
424
422
|
...this.#queryBlob(location),
|
|
425
423
|
...base,
|
|
426
|
-
...(
|
|
427
|
-
...((
|
|
424
|
+
...(metadata.size ? { ContentLength: metadata.size } : {}),
|
|
425
|
+
...((metadata.hash && metadata.hash !== '-1') ? { ChecksumSHA256: metadata.hash } : {}),
|
|
428
426
|
}),
|
|
429
427
|
{
|
|
430
|
-
expiresIn: TimeUtil.
|
|
431
|
-
...(
|
|
428
|
+
expiresIn: TimeUtil.duration(expiresIn, 's'),
|
|
429
|
+
...(metadata.contentType ? { signableHeaders: new Set(['content-type']) } : {})
|
|
432
430
|
}
|
|
433
431
|
);
|
|
434
432
|
}
|