@directus/storage-driver-s3 9.22.4 → 9.23.3

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/dist/index.js CHANGED
@@ -4,64 +4,70 @@ import { normalizePath } from '@directus/utils';
4
4
  import { isReadableStream } from '@directus/utils/node';
5
5
  import { join } from 'node:path';
6
6
  export class DriverS3 {
7
- root;
7
+ config;
8
8
  client;
9
- bucket;
10
- acl;
11
- serverSideEncryption;
9
+ root;
12
10
  constructor(config) {
11
+ this.config = config;
12
+ this.client = this.getClient();
13
+ this.root = this.config.root ? normalizePath(this.config.root, { removeLeading: true }) : '';
14
+ }
15
+ getClient() {
13
16
  const s3ClientConfig = {};
14
- if ((config.key && !config.secret) || (config.secret && !config.key)) {
17
+ if ((this.config.key && !this.config.secret) || (this.config.secret && !this.config.key)) {
15
18
  throw new Error('Both `key` and `secret` are required when defined');
16
19
  }
17
- if (config.key && config.secret) {
20
+ if (this.config.key && this.config.secret) {
18
21
  s3ClientConfig.credentials = {
19
- accessKeyId: config.key,
20
- secretAccessKey: config.secret,
22
+ accessKeyId: this.config.key,
23
+ secretAccessKey: this.config.secret,
21
24
  };
22
25
  }
23
- if (config.endpoint) {
24
- const protocol = config.endpoint.startsWith('https://') ? 'https:' : 'http:';
25
- const hostname = config.endpoint.replace('https://', '').replace('http://', '');
26
+ if (this.config.endpoint) {
27
+ const protocol = this.config.endpoint.startsWith('https://') ? 'https:' : 'http:';
28
+ const hostname = this.config.endpoint.replace('https://', '').replace('http://', '');
26
29
  s3ClientConfig.endpoint = {
27
30
  hostname,
28
31
  protocol,
29
32
  path: '/',
30
33
  };
31
34
  }
32
- if (config.region) {
33
- s3ClientConfig.region = config.region;
35
+ if (this.config.region) {
36
+ s3ClientConfig.region = this.config.region;
34
37
  }
35
- if (config.forcePathStyle !== undefined) {
36
- s3ClientConfig.forcePathStyle = config.forcePathStyle;
38
+ if (this.config.forcePathStyle !== undefined) {
39
+ s3ClientConfig.forcePathStyle = this.config.forcePathStyle;
37
40
  }
38
- this.client = new S3Client(s3ClientConfig);
39
- this.bucket = config.bucket;
40
- this.acl = config.acl;
41
- this.serverSideEncryption = config.serverSideEncryption;
42
- this.root = config.root ? normalizePath(config.root, { removeLeading: true }) : '';
41
+ return new S3Client(s3ClientConfig);
43
42
  }
44
43
  fullPath(filepath) {
45
44
  return normalizePath(join(this.root, filepath));
46
45
  }
47
46
  async read(filepath, range) {
47
+ /*
48
+ * AWS' client default socket reusing and keepalive can cause performance issues when using it
49
+ * very often in rapid succession. For reads, where it's more likely to hit this limitation,
50
+ * we'll use a new non-shared S3 client to get around this.
51
+ */
52
+ const client = this.getClient();
48
53
  const commandInput = {
49
54
  Key: this.fullPath(filepath),
50
- Bucket: this.bucket,
55
+ Bucket: this.config.bucket,
51
56
  };
52
57
  if (range) {
53
58
  commandInput.Range = `bytes=${range.start ?? ''}-${range.end ?? ''}`;
54
59
  }
55
- const { Body: stream } = await this.client.send(new GetObjectCommand(commandInput));
60
+ const { Body: stream } = await client.send(new GetObjectCommand(commandInput));
56
61
  if (!stream || !isReadableStream(stream)) {
57
62
  throw new Error(`No stream returned for file "${filepath}"`);
58
63
  }
64
+ stream.on('finished', () => client.destroy());
59
65
  return stream;
60
66
  }
61
67
  async stat(filepath) {
62
68
  const { ContentLength, LastModified } = await this.client.send(new HeadObjectCommand({
63
69
  Key: this.fullPath(filepath),
64
- Bucket: this.bucket,
70
+ Bucket: this.config.bucket,
65
71
  }));
66
72
  return {
67
73
  size: ContentLength,
@@ -84,14 +90,14 @@ export class DriverS3 {
84
90
  async copy(src, dest) {
85
91
  const params = {
86
92
  Key: this.fullPath(dest),
87
- Bucket: this.bucket,
88
- CopySource: `/${this.bucket}/${this.fullPath(src)}`,
93
+ Bucket: this.config.bucket,
94
+ CopySource: `/${this.config.bucket}/${this.fullPath(src)}`,
89
95
  };
90
- if (this.serverSideEncryption) {
91
- params.ServerSideEncryption = this.serverSideEncryption;
96
+ if (this.config.serverSideEncryption) {
97
+ params.ServerSideEncryption = this.config.serverSideEncryption;
92
98
  }
93
- if (this.acl) {
94
- params.ACL = this.acl;
99
+ if (this.config.acl) {
100
+ params.ACL = this.config.acl;
95
101
  }
96
102
  await this.client.send(new CopyObjectCommand(params));
97
103
  }
@@ -99,16 +105,16 @@ export class DriverS3 {
99
105
  const params = {
100
106
  Key: this.fullPath(filepath),
101
107
  Body: content,
102
- Bucket: this.bucket,
108
+ Bucket: this.config.bucket,
103
109
  };
104
110
  if (type) {
105
111
  params.ContentType = type;
106
112
  }
107
- if (this.acl) {
108
- params.ACL = this.acl;
113
+ if (this.config.acl) {
114
+ params.ACL = this.config.acl;
109
115
  }
110
- if (this.serverSideEncryption) {
111
- params.ServerSideEncryption = this.serverSideEncryption;
116
+ if (this.config.serverSideEncryption) {
117
+ params.ServerSideEncryption = this.config.serverSideEncryption;
112
118
  }
113
119
  const upload = new Upload({
114
120
  client: this.client,
@@ -117,13 +123,13 @@ export class DriverS3 {
117
123
  await upload.done();
118
124
  }
119
125
  async delete(filepath) {
120
- await this.client.send(new DeleteObjectCommand({ Key: this.fullPath(filepath), Bucket: this.bucket }));
126
+ await this.client.send(new DeleteObjectCommand({ Key: this.fullPath(filepath), Bucket: this.config.bucket }));
121
127
  }
122
128
  async *list(prefix = '') {
123
129
  let continuationToken = undefined;
124
130
  do {
125
131
  const listObjectsV2CommandInput = {
126
- Bucket: this.bucket,
132
+ Bucket: this.config.bucket,
127
133
  Prefix: this.fullPath(prefix),
128
134
  MaxKeys: 1000,
129
135
  };
@@ -66,6 +66,43 @@ afterEach(() => {
66
66
  vi.resetAllMocks();
67
67
  });
68
68
  describe('#constructor', () => {
69
+ let getClientBackup;
70
+ let sampleClient;
71
+ beforeEach(() => {
72
+ getClientBackup = DriverS3.prototype['getClient'];
73
+ sampleClient = {};
74
+ DriverS3.prototype['getClient'] = vi.fn().mockReturnValue(sampleClient);
75
+ });
76
+ afterEach(() => {
77
+ DriverS3.prototype['getClient'] = getClientBackup;
78
+ });
79
+ test('Saves passed config to local property', () => {
80
+ const driver = new DriverS3(sample.config);
81
+ expect(driver['config']).toBe(sample.config);
82
+ });
83
+ test('Creates shared client', () => {
84
+ const driver = new DriverS3(sample.config);
85
+ expect(driver['getClient']).toHaveBeenCalledOnce();
86
+ expect(driver['client']).toBe(sampleClient);
87
+ });
88
+ test('Defaults root to empty string', () => {
89
+ expect(driver['root']).toBe('');
90
+ });
91
+ test('Normalizes config path when root is given', () => {
92
+ const mockRoot = randDirectoryPath();
93
+ vi.mocked(normalizePath).mockReturnValue(mockRoot);
94
+ const driver = new DriverS3({
95
+ key: sample.config.key,
96
+ secret: sample.config.secret,
97
+ bucket: sample.config.bucket,
98
+ root: sample.config.root,
99
+ });
100
+ expect(normalizePath).toHaveBeenCalledWith(sample.config.root, { removeLeading: true });
101
+ expect(driver['root']).toBe(mockRoot);
102
+ });
103
+ });
104
+ describe('#getClient', () => {
105
+ // The constructor calls getClient(), so we don't have to call it separately
69
106
  test('Throws error if key defined but secret missing', () => {
70
107
  try {
71
108
  new DriverS3({ key: 'key', bucket: 'bucket' });
@@ -98,30 +135,6 @@ describe('#constructor', () => {
98
135
  });
99
136
  expect(driver['client']).toBeInstanceOf(S3Client);
100
137
  });
101
- test('Sets private bucket reference based on config', () => {
102
- expect(driver['bucket']).toBe(sample.config.bucket);
103
- });
104
- test('Sets private acl reference based on config', () => {
105
- const driver = new DriverS3({
106
- key: sample.config.key,
107
- secret: sample.config.secret,
108
- bucket: sample.config.bucket,
109
- acl: sample.config.acl,
110
- });
111
- expect(driver['acl']).toBe(sample.config.acl);
112
- });
113
- test('Sets private serverSideEncryption reference based on config', () => {
114
- const driver = new DriverS3({
115
- key: sample.config.key,
116
- secret: sample.config.secret,
117
- bucket: sample.config.bucket,
118
- serverSideEncryption: sample.config.serverSideEncryption,
119
- });
120
- expect(driver['serverSideEncryption']).toBe(sample.config.serverSideEncryption);
121
- });
122
- test('Defaults root to empty string', () => {
123
- expect(driver['root']).toBe('');
124
- });
125
138
  test('Sets http endpoints', () => {
126
139
  const sampleDomain = randDomainName();
127
140
  const sampleHttpEndpoint = `http://${sampleDomain}`;
@@ -194,18 +207,6 @@ describe('#constructor', () => {
194
207
  },
195
208
  });
196
209
  });
197
- test('Normalizes config path when root is given', () => {
198
- const mockRoot = randDirectoryPath();
199
- vi.mocked(normalizePath).mockReturnValue(mockRoot);
200
- const driver = new DriverS3({
201
- key: sample.config.key,
202
- secret: sample.config.secret,
203
- bucket: sample.config.bucket,
204
- root: sample.config.root,
205
- });
206
- expect(normalizePath).toHaveBeenCalledWith(sample.config.root, { removeLeading: true });
207
- expect(driver['root']).toBe(mockRoot);
208
- });
209
210
  });
210
211
  describe('#fullPath', () => {
211
212
  test('Returns normalized joined path', () => {
@@ -350,7 +351,7 @@ describe('#copy', () => {
350
351
  });
351
352
  });
352
353
  test('Optionally sets ServerSideEncryption', async () => {
353
- driver['serverSideEncryption'] = sample.config.serverSideEncryption;
354
+ driver['config'].serverSideEncryption = sample.config.serverSideEncryption;
354
355
  await driver.copy(sample.path.src, sample.path.dest);
355
356
  expect(CopyObjectCommand).toHaveBeenCalledWith({
356
357
  Key: sample.path.destFull,
@@ -360,7 +361,7 @@ describe('#copy', () => {
360
361
  });
361
362
  });
362
363
  test('Optionally sets ACL', async () => {
363
- driver['acl'] = sample.config.acl;
364
+ driver['config'].acl = sample.config.acl;
364
365
  await driver.copy(sample.path.src, sample.path.dest);
365
366
  expect(CopyObjectCommand).toHaveBeenCalledWith({
366
367
  Key: sample.path.destFull,
@@ -401,7 +402,7 @@ describe('#write', () => {
401
402
  });
402
403
  });
403
404
  test('Optionally sets ServerSideEncryption', async () => {
404
- driver['serverSideEncryption'] = sample.config.serverSideEncryption;
405
+ driver['config'].serverSideEncryption = sample.config.serverSideEncryption;
405
406
  await driver.write(sample.path.input, sample.stream);
406
407
  expect(Upload).toHaveBeenCalledWith({
407
408
  client: driver['client'],
@@ -414,7 +415,7 @@ describe('#write', () => {
414
415
  });
415
416
  });
416
417
  test('Optionally sets ACL', async () => {
417
- driver['acl'] = sample.config.acl;
418
+ driver['config'].acl = sample.config.acl;
418
419
  await driver.write(sample.path.input, sample.stream);
419
420
  expect(Upload).toHaveBeenCalledWith({
420
421
  client: driver['client'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directus/storage-driver-s3",
3
- "version": "9.22.4",
3
+ "version": "9.23.3",
4
4
  "type": "module",
5
5
  "description": "S3 file storage abstraction for `@directus/storage`",
6
6
  "repository": {
@@ -24,18 +24,18 @@
24
24
  "access": "public"
25
25
  },
26
26
  "dependencies": {
27
- "@aws-sdk/abort-controller": "3.226.0",
28
- "@aws-sdk/client-s3": "3.236.0",
29
- "@aws-sdk/lib-storage": "3.236.0",
30
- "@directus/storage": "9.22.4",
31
- "@directus/utils": "9.22.4"
27
+ "@aws-sdk/abort-controller": "3.292.0",
28
+ "@aws-sdk/client-s3": "3.292.0",
29
+ "@aws-sdk/lib-storage": "3.292.0",
30
+ "@directus/storage": "9.23.3",
31
+ "@directus/utils": "9.23.3"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@directus/tsconfig": "0.0.6",
35
- "@ngneat/falso": "6.3.2",
36
- "@vitest/coverage-c8": "0.26.2",
37
- "typescript": "4.9.4",
38
- "vitest": "0.26.2"
35
+ "@ngneat/falso": "6.4.0",
36
+ "@vitest/coverage-c8": "0.29.3",
37
+ "typescript": "4.9.5",
38
+ "vitest": "0.29.3"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "tsc --build",