@travetto/model-s3 5.0.0-rc.8 → 5.0.0
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 +1 -1
- package/package.json +5 -5
- package/src/service.ts +56 -59
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ This module provides an [s3](https://aws.amazon.com/documentation/s3/)-based imp
|
|
|
17
17
|
|
|
18
18
|
Supported features:
|
|
19
19
|
* [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/service/crud.ts#L11)
|
|
20
|
-
* [
|
|
20
|
+
* [Blob](https://github.com/travetto/travetto/tree/main/module/model/src/service/blob.ts#L8)
|
|
21
21
|
* [Expiry](https://github.com/travetto/travetto/tree/main/module/model/src/service/expiry.ts#L11)
|
|
22
22
|
Out of the box, by installing the module, everything should be wired up by default.If you need to customize any aspect of the source or config, you can override and register it with the [Dependency Injection](https://github.com/travetto/travetto/tree/main/module/di#readme "Dependency registration/management and injection support.") module.
|
|
23
23
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/model-s3",
|
|
3
|
-
"version": "5.0.0
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "S3 backing for the travetto model module.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"s3",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"directory": "module/model-s3"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aws-sdk/client-s3": "^3.
|
|
28
|
+
"@aws-sdk/client-s3": "^3.631.0",
|
|
29
29
|
"@aws-sdk/credential-provider-ini": "^3.609.0",
|
|
30
|
-
"@travetto/config": "^5.0.0
|
|
31
|
-
"@travetto/model": "^5.0.0
|
|
30
|
+
"@travetto/config": "^5.0.0",
|
|
31
|
+
"@travetto/model": "^5.0.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@travetto/command": "^5.0.0
|
|
34
|
+
"@travetto/command": "^5.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependenciesMeta": {
|
|
37
37
|
"@travetto/command": {
|
package/src/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Readable } from 'node:stream';
|
|
2
|
-
import {
|
|
2
|
+
import { text as toText } from 'node:stream/consumers';
|
|
3
3
|
import { Agent } from 'node:https';
|
|
4
4
|
|
|
5
5
|
import { S3, CompletedPart, type CreateMultipartUploadRequest } from '@aws-sdk/client-s3';
|
|
@@ -7,20 +7,19 @@ import type { MetadataBearer } from '@aws-sdk/types';
|
|
|
7
7
|
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
ModelCrudSupport,
|
|
11
|
-
|
|
12
|
-
StreamRange
|
|
10
|
+
ModelCrudSupport, ModelStorageSupport, ModelType, ModelRegistry, ExistsError, NotFoundError, OptionalId,
|
|
11
|
+
ModelBlobSupport
|
|
13
12
|
} from '@travetto/model';
|
|
14
13
|
import { Injectable } from '@travetto/di';
|
|
15
|
-
import { Class, AppError } from '@travetto/runtime';
|
|
14
|
+
import { Class, AppError, castTo, asFull, BlobMeta, ByteRange, BinaryInput, BinaryUtil } from '@travetto/runtime';
|
|
16
15
|
|
|
16
|
+
import { MODEL_BLOB, ModelBlobUtil } from '@travetto/model/src/internal/service/blob';
|
|
17
17
|
import { ModelCrudUtil } from '@travetto/model/src/internal/service/crud';
|
|
18
18
|
import { ModelExpirySupport } from '@travetto/model/src/service/expiry';
|
|
19
19
|
import { ModelExpiryUtil } from '@travetto/model/src/internal/service/expiry';
|
|
20
20
|
import { ModelStorageUtil } from '@travetto/model/src/internal/service/storage';
|
|
21
21
|
|
|
22
22
|
import { S3ModelConfig } from './config';
|
|
23
|
-
import { enforceRange } from '@travetto/model/src/internal/service/stream';
|
|
24
23
|
|
|
25
24
|
function isMetadataBearer(o: unknown): o is MetadataBearer {
|
|
26
25
|
return !!o && typeof o === 'object' && '$metadata' in o;
|
|
@@ -30,8 +29,6 @@ function hasContentType<T>(o: T): o is T & { contenttype?: string } {
|
|
|
30
29
|
return o !== undefined && o !== null && Object.hasOwn(o, 'contenttype');
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
const STREAM_SPACE = '@travetto/model-s3:stream';
|
|
34
|
-
|
|
35
32
|
type MetaBase = Pick<CreateMultipartUploadRequest,
|
|
36
33
|
'ContentType' | 'Metadata' | 'ContentEncoding' | 'ContentLanguage' | 'CacheControl' | 'ContentDisposition'
|
|
37
34
|
>;
|
|
@@ -40,14 +37,14 @@ type MetaBase = Pick<CreateMultipartUploadRequest,
|
|
|
40
37
|
* Asset source backed by S3
|
|
41
38
|
*/
|
|
42
39
|
@Injectable()
|
|
43
|
-
export class S3ModelService implements ModelCrudSupport,
|
|
40
|
+
export class S3ModelService implements ModelCrudSupport, ModelBlobSupport, ModelStorageSupport, ModelExpirySupport {
|
|
44
41
|
|
|
45
42
|
idSource = ModelCrudUtil.uuidSource();
|
|
46
43
|
client: S3;
|
|
47
44
|
|
|
48
45
|
constructor(public readonly config: S3ModelConfig) { }
|
|
49
46
|
|
|
50
|
-
#getMetaBase(meta:
|
|
47
|
+
#getMetaBase({ range, size, ...meta }: BlobMeta): MetaBase {
|
|
51
48
|
return {
|
|
52
49
|
ContentType: meta.contentType,
|
|
53
50
|
...(meta.contentEncoding ? { ContentEncoding: meta.contentEncoding } : {}),
|
|
@@ -55,14 +52,14 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
55
52
|
...(meta.cacheControl ? { CacheControl: meta.cacheControl } : {}),
|
|
56
53
|
Metadata: {
|
|
57
54
|
...meta,
|
|
58
|
-
size: `${
|
|
55
|
+
...(size ? { size: `${size}` } : {})
|
|
59
56
|
}
|
|
60
57
|
};
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
#resolveKey(cls: Class | string, id?: string): string {
|
|
64
61
|
let key: string;
|
|
65
|
-
if (cls ===
|
|
62
|
+
if (cls === MODEL_BLOB) { // If we are streaming, treat as primary use case
|
|
66
63
|
key = id!; // Store it directly at root
|
|
67
64
|
} else {
|
|
68
65
|
key = typeof cls === 'string' ? cls : ModelRegistry.getStore(cls);
|
|
@@ -77,8 +74,7 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
77
74
|
return key;
|
|
78
75
|
}
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
#q<U extends object>(cls: string | Class, id: string, extra: U = {} as U): (U & { Key: string, Bucket: string }) {
|
|
77
|
+
#q<U extends object>(cls: string | Class, id: string, extra: U = asFull({})): (U & { Key: string, Bucket: string }) {
|
|
82
78
|
const key = this.#resolveKey(cls, id);
|
|
83
79
|
return { Key: key, Bucket: this.config.bucket, ...extra };
|
|
84
80
|
}
|
|
@@ -111,8 +107,8 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
111
107
|
/**
|
|
112
108
|
* Write multipart file upload, in chunks
|
|
113
109
|
*/
|
|
114
|
-
async #writeMultipart(id: string, input: Readable, meta:
|
|
115
|
-
const { UploadId } = await this.client.createMultipartUpload(this.#q(
|
|
110
|
+
async #writeMultipart(id: string, input: Readable, meta: BlobMeta): Promise<void> {
|
|
111
|
+
const { UploadId } = await this.client.createMultipartUpload(this.#q(MODEL_BLOB, id, this.#getMetaBase(meta)));
|
|
116
112
|
|
|
117
113
|
const parts: CompletedPart[] = [];
|
|
118
114
|
let buffers: Buffer[] = [];
|
|
@@ -120,7 +116,7 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
120
116
|
let n = 1;
|
|
121
117
|
const flush = async (): Promise<void> => {
|
|
122
118
|
if (!total) { return; }
|
|
123
|
-
const part = await this.client.uploadPart(this.#q(
|
|
119
|
+
const part = await this.client.uploadPart(this.#q(MODEL_BLOB, id, {
|
|
124
120
|
Body: Buffer.concat(buffers),
|
|
125
121
|
PartNumber: n,
|
|
126
122
|
UploadId
|
|
@@ -132,20 +128,21 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
132
128
|
};
|
|
133
129
|
try {
|
|
134
130
|
for await (const chunk of input) {
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
const chunked = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
132
|
+
buffers.push(chunked);
|
|
133
|
+
total += chunked.length;
|
|
137
134
|
if (total > this.config.chunkSize) {
|
|
138
135
|
await flush();
|
|
139
136
|
}
|
|
140
137
|
}
|
|
141
138
|
await flush();
|
|
142
139
|
|
|
143
|
-
await this.client.completeMultipartUpload(this.#q(
|
|
140
|
+
await this.client.completeMultipartUpload(this.#q(MODEL_BLOB, id, {
|
|
144
141
|
UploadId,
|
|
145
142
|
MultipartUpload: { Parts: parts }
|
|
146
143
|
}));
|
|
147
144
|
} catch (err) {
|
|
148
|
-
await this.client.abortMultipartUpload(this.#q(
|
|
145
|
+
await this.client.abortMultipartUpload(this.#q(MODEL_BLOB, id, { UploadId }));
|
|
149
146
|
throw err;
|
|
150
147
|
}
|
|
151
148
|
}
|
|
@@ -203,8 +200,7 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
203
200
|
try {
|
|
204
201
|
const result = await this.client.getObject(this.#q(cls, id));
|
|
205
202
|
if (result.Body) {
|
|
206
|
-
|
|
207
|
-
const body = (await toBuffer(result.Body as Readable)).toString('utf8');
|
|
203
|
+
const body = await toText(castTo(result.Body));
|
|
208
204
|
const output = await ModelCrudUtil.load(cls, body);
|
|
209
205
|
if (output) {
|
|
210
206
|
const { expiresAt } = ModelRegistry.get(cls);
|
|
@@ -230,8 +226,7 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
230
226
|
}
|
|
231
227
|
|
|
232
228
|
async store<T extends ModelType>(cls: Class<T>, item: OptionalId<T>, preStore = true): Promise<T> {
|
|
233
|
-
|
|
234
|
-
let prepped: T = item as T;
|
|
229
|
+
let prepped: T = castTo(item);
|
|
235
230
|
if (preStore) {
|
|
236
231
|
prepped = await ModelCrudUtil.preStore(cls, item, this);
|
|
237
232
|
}
|
|
@@ -299,22 +294,29 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
299
294
|
return -1;
|
|
300
295
|
}
|
|
301
296
|
|
|
302
|
-
|
|
303
|
-
|
|
297
|
+
// Blob support
|
|
298
|
+
async upsertBlob(location: string, input: BinaryInput, meta?: BlobMeta, overwrite = true): Promise<void> {
|
|
299
|
+
if (!overwrite && await this.describeBlob(location).then(() => true, () => false)) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const [stream, blobMeta] = await ModelBlobUtil.getInput(input, meta);
|
|
304
|
+
|
|
305
|
+
if (blobMeta.size && blobMeta.size < this.config.chunkSize) { // If smaller than chunk size
|
|
304
306
|
// Upload to s3
|
|
305
|
-
await this.client.putObject(this.#q(
|
|
306
|
-
Body:
|
|
307
|
-
ContentLength:
|
|
308
|
-
...this.#getMetaBase(
|
|
307
|
+
await this.client.putObject(this.#q(MODEL_BLOB, location, {
|
|
308
|
+
Body: stream,
|
|
309
|
+
ContentLength: blobMeta.size,
|
|
310
|
+
...this.#getMetaBase(blobMeta),
|
|
309
311
|
}));
|
|
310
312
|
} else {
|
|
311
|
-
await this.#writeMultipart(location,
|
|
313
|
+
await this.#writeMultipart(location, stream, blobMeta);
|
|
312
314
|
}
|
|
313
315
|
}
|
|
314
316
|
|
|
315
|
-
async #getObject(location: string, range
|
|
317
|
+
async #getObject(location: string, range?: Required<ByteRange>): Promise<Readable> {
|
|
316
318
|
// Read from s3
|
|
317
|
-
const res = await this.client.getObject(this.#q(
|
|
319
|
+
const res = await this.client.getObject(this.#q(MODEL_BLOB, location, range ? {
|
|
318
320
|
Range: `bytes=${range.start}-${range.end}`
|
|
319
321
|
} : {}));
|
|
320
322
|
|
|
@@ -323,49 +325,43 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
323
325
|
}
|
|
324
326
|
|
|
325
327
|
if (typeof res.Body === 'string') { // string
|
|
326
|
-
|
|
327
|
-
return Readable.from(res.Body, { encoding: (res.Body as string).endsWith('=') ? 'base64' : 'utf8' });
|
|
328
|
+
return Readable.from(res.Body, { encoding: castTo<string>(res.Body).endsWith('=') ? 'base64' : 'utf8' });
|
|
328
329
|
} else if (res.Body instanceof Buffer) { // Buffer
|
|
329
330
|
return Readable.from(res.Body);
|
|
330
331
|
} else if ('pipe' in res.Body) { // Stream
|
|
331
|
-
|
|
332
|
-
return res.Body as Readable;
|
|
332
|
+
return castTo<Readable>(res.Body);
|
|
333
333
|
}
|
|
334
334
|
throw new AppError(`Unable to read type: ${typeof res.Body}`);
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
-
async
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
343
|
-
return this.#getObject(location, range as Required<StreamRange>);
|
|
337
|
+
async getBlob(location: string, range?: ByteRange): Promise<Blob> {
|
|
338
|
+
const meta = await this.describeBlob(location);
|
|
339
|
+
const final = range ? ModelBlobUtil.enforceRange(range, meta.size!) : undefined;
|
|
340
|
+
const res = (): Promise<Readable> => this.#getObject(location, final);
|
|
341
|
+
return BinaryUtil.readableBlob(res, { ...meta, range: final });
|
|
344
342
|
}
|
|
345
343
|
|
|
346
|
-
async
|
|
347
|
-
const query = this.#q(
|
|
344
|
+
async headBlob(location: string): Promise<{ Metadata?: BlobMeta, ContentLength?: number }> {
|
|
345
|
+
const query = this.#q(MODEL_BLOB, location);
|
|
348
346
|
try {
|
|
349
347
|
return (await this.client.headObject(query));
|
|
350
348
|
} catch (err) {
|
|
351
349
|
if (isMetadataBearer(err)) {
|
|
352
350
|
if (err.$metadata.httpStatusCode === 404) {
|
|
353
|
-
err = new NotFoundError(
|
|
351
|
+
err = new NotFoundError(MODEL_BLOB, location);
|
|
354
352
|
}
|
|
355
353
|
}
|
|
356
354
|
throw err;
|
|
357
355
|
}
|
|
358
356
|
}
|
|
359
357
|
|
|
360
|
-
async
|
|
361
|
-
const obj = await this.
|
|
358
|
+
async describeBlob(location: string): Promise<BlobMeta> {
|
|
359
|
+
const obj = await this.headBlob(location);
|
|
362
360
|
|
|
363
361
|
if (obj) {
|
|
364
|
-
const ret:
|
|
365
|
-
// @ts-expect-error
|
|
362
|
+
const ret: BlobMeta = {
|
|
366
363
|
contentType: '',
|
|
367
|
-
|
|
368
|
-
...obj.Metadata as StreamMeta,
|
|
364
|
+
...obj.Metadata,
|
|
369
365
|
size: obj.ContentLength!,
|
|
370
366
|
};
|
|
371
367
|
if (hasContentType(ret)) {
|
|
@@ -374,20 +370,21 @@ export class S3ModelService implements ModelCrudSupport, ModelStreamSupport, Mod
|
|
|
374
370
|
}
|
|
375
371
|
return ret;
|
|
376
372
|
} else {
|
|
377
|
-
throw new NotFoundError(
|
|
373
|
+
throw new NotFoundError(MODEL_BLOB, location);
|
|
378
374
|
}
|
|
379
375
|
}
|
|
380
376
|
|
|
377
|
+
async deleteBlob(location: string): Promise<void> {
|
|
378
|
+
await this.client.deleteObject(this.#q(MODEL_BLOB, location));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Storage
|
|
381
382
|
async truncateModel<T extends ModelType>(model: Class<T>): Promise<void> {
|
|
382
383
|
for await (const items of this.#iterateBucket(model)) {
|
|
383
384
|
await this.#deleteKeys(items);
|
|
384
385
|
}
|
|
385
386
|
}
|
|
386
387
|
|
|
387
|
-
async deleteStream(location: string): Promise<void> {
|
|
388
|
-
await this.client.deleteObject(this.#q(STREAM_SPACE, location));
|
|
389
|
-
}
|
|
390
|
-
|
|
391
388
|
async createStorage(): Promise<void> {
|
|
392
389
|
try {
|
|
393
390
|
await this.client.headBucket({ Bucket: this.config.bucket });
|