@byline/storage-s3 1.2.1 → 1.3.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/dist/index.d.ts +1 -1
- package/dist/s3-storage-provider.d.ts +96 -6
- package/dist/s3-storage-provider.js +63 -7
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import type
|
|
8
|
+
import { type ObjectCannedACL, type S3ClientConfig } from '@aws-sdk/client-s3';
|
|
9
|
+
import type { IStorageProvider, UploadFileOptions } from '@byline/core';
|
|
10
|
+
/**
|
|
11
|
+
* Per-upload S3 metadata supplier. Receives the same `UploadFileOptions`
|
|
12
|
+
* passed to `storage.upload()` so callers can derive metadata from the
|
|
13
|
+
* collection / filename / mime type / target path. Return value is
|
|
14
|
+
* forwarded as S3 user-metadata (`x-amz-meta-*` headers).
|
|
15
|
+
*/
|
|
16
|
+
export type S3MetadataSupplier = Record<string, string> | ((options: UploadFileOptions) => Record<string, string> | undefined);
|
|
9
17
|
export interface S3StorageConfig {
|
|
10
18
|
/** S3 bucket name. */
|
|
11
19
|
bucket: string;
|
|
@@ -14,10 +22,26 @@ export interface S3StorageConfig {
|
|
|
14
22
|
* For region-agnostic providers (e.g. Cloudflare R2) use `'auto'`.
|
|
15
23
|
*/
|
|
16
24
|
region: string;
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
/**
|
|
26
|
+
* AWS Access Key ID (or equivalent credential for compatible providers).
|
|
27
|
+
*
|
|
28
|
+
* Optional — when omitted (along with `secretAccessKey`), the AWS SDK
|
|
29
|
+
* resolves credentials via its default provider chain (IAM role / instance
|
|
30
|
+
* profile, SSO, environment variables, `~/.aws/credentials`, etc.). This
|
|
31
|
+
* is the recommended path for production AWS deployments.
|
|
32
|
+
*/
|
|
33
|
+
accessKeyId?: string;
|
|
34
|
+
/**
|
|
35
|
+
* AWS Secret Access Key (or equivalent credential for compatible providers).
|
|
36
|
+
* Must be set together with `accessKeyId`. See `accessKeyId` for the
|
|
37
|
+
* default-credential-chain fallback.
|
|
38
|
+
*/
|
|
39
|
+
secretAccessKey?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Optional session token, for temporary credentials issued by STS / SSO.
|
|
42
|
+
* Only meaningful when `accessKeyId` and `secretAccessKey` are also set.
|
|
43
|
+
*/
|
|
44
|
+
sessionToken?: string;
|
|
21
45
|
/**
|
|
22
46
|
* Custom endpoint for S3-compatible providers.
|
|
23
47
|
*
|
|
@@ -45,9 +69,50 @@ export interface S3StorageConfig {
|
|
|
45
69
|
* Optional key prefix (folder) prepended to all object keys inside the
|
|
46
70
|
* bucket. No leading slash; trailing slash is added automatically.
|
|
47
71
|
*
|
|
72
|
+
* Ignored when `UploadFileOptions.targetStoragePath` is set — variant
|
|
73
|
+
* uploads compute their key from the original's `storagePath`, which
|
|
74
|
+
* already carries the prefix.
|
|
75
|
+
*
|
|
48
76
|
* @example `'byline'` → keys stored as `byline/<collection>/<year>/...`
|
|
49
77
|
*/
|
|
50
78
|
pathPrefix?: string;
|
|
79
|
+
/**
|
|
80
|
+
* S3 canned ACL applied to every uploaded object.
|
|
81
|
+
*
|
|
82
|
+
* Many modern S3 setups (AWS S3 Block-Public-Access, Cloudflare R2) reject
|
|
83
|
+
* ACL headers entirely — leave this unset on those buckets and grant
|
|
84
|
+
* read access via bucket policy instead.
|
|
85
|
+
*
|
|
86
|
+
* @example `'public-read'` — legacy public-read bucket
|
|
87
|
+
*/
|
|
88
|
+
acl?: ObjectCannedACL;
|
|
89
|
+
/**
|
|
90
|
+
* Default `Cache-Control` header written to every uploaded object's
|
|
91
|
+
* metadata, used by S3/CDNs when serving the file. Long-lived
|
|
92
|
+
* immutable URLs are common for content with UUID-prefixed keys.
|
|
93
|
+
*
|
|
94
|
+
* @example `'public, max-age=31536000, immutable'`
|
|
95
|
+
*/
|
|
96
|
+
cacheControl?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Static or per-upload S3 user-metadata. Static keys are merged with the
|
|
99
|
+
* per-upload result if both are provided; per-upload values win on key
|
|
100
|
+
* collision.
|
|
101
|
+
*
|
|
102
|
+
* Keys must be ASCII; values are quoted by the SDK as needed. Each entry
|
|
103
|
+
* is sent as an `x-amz-meta-<key>` header.
|
|
104
|
+
*
|
|
105
|
+
* @example `{ uploader: 'byline-cms' }`
|
|
106
|
+
*/
|
|
107
|
+
metadata?: S3MetadataSupplier;
|
|
108
|
+
/**
|
|
109
|
+
* Escape hatch for advanced S3 client tuning — `requestHandler`,
|
|
110
|
+
* `maxAttempts`, `retryMode`, custom `httpAgent`, `useArnRegion`, etc.
|
|
111
|
+
* Merged into the underlying `S3Client` config; explicit named fields
|
|
112
|
+
* on `S3StorageConfig` (`region`, `endpoint`, `forcePathStyle`,
|
|
113
|
+
* credentials) take precedence on key collision.
|
|
114
|
+
*/
|
|
115
|
+
clientConfig?: Partial<S3ClientConfig>;
|
|
51
116
|
}
|
|
52
117
|
/**
|
|
53
118
|
* Create an S3-compatible storage provider.
|
|
@@ -59,7 +124,11 @@ export interface S3StorageConfig {
|
|
|
59
124
|
* Uploaded files are stored at:
|
|
60
125
|
* `[pathPrefix/]<collection>/<year>/<month>/<uuid>-<filename>`
|
|
61
126
|
*
|
|
62
|
-
*
|
|
127
|
+
* Image variants (thumbnail / card / etc.) are written as siblings of the
|
|
128
|
+
* original under the same prefix, e.g.:
|
|
129
|
+
* `<...>-<filename>-<variantName>.<format>`
|
|
130
|
+
*
|
|
131
|
+
* @example AWS S3 with explicit keys
|
|
63
132
|
* ```ts
|
|
64
133
|
* import { s3StorageProvider } from '@byline/storage-s3'
|
|
65
134
|
*
|
|
@@ -69,6 +138,17 @@ export interface S3StorageConfig {
|
|
|
69
138
|
* accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
70
139
|
* secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
71
140
|
* publicUrl: 'https://cdn.example.com',
|
|
141
|
+
* cacheControl: 'public, max-age=31536000, immutable',
|
|
142
|
+
* })
|
|
143
|
+
* ```
|
|
144
|
+
*
|
|
145
|
+
* @example AWS S3 with the default credential chain (IAM role / SSO / env)
|
|
146
|
+
* ```ts
|
|
147
|
+
* storage: s3StorageProvider({
|
|
148
|
+
* bucket: 'my-cms-bucket',
|
|
149
|
+
* region: 'eu-west-1',
|
|
150
|
+
* // accessKeyId / secretAccessKey omitted — the AWS SDK resolves
|
|
151
|
+
* // credentials via its default provider chain.
|
|
72
152
|
* })
|
|
73
153
|
* ```
|
|
74
154
|
*
|
|
@@ -96,5 +176,15 @@ export interface S3StorageConfig {
|
|
|
96
176
|
* publicUrl: 'http://localhost:9000/byline',
|
|
97
177
|
* })
|
|
98
178
|
* ```
|
|
179
|
+
*
|
|
180
|
+
* @example Per-upload metadata + retry tuning
|
|
181
|
+
* ```ts
|
|
182
|
+
* storage: s3StorageProvider({
|
|
183
|
+
* bucket: 'my-cms-bucket',
|
|
184
|
+
* region: 'eu-west-1',
|
|
185
|
+
* metadata: (opts) => ({ collection: opts.collection ?? 'uploads' }),
|
|
186
|
+
* clientConfig: { maxAttempts: 5, retryMode: 'adaptive' },
|
|
187
|
+
* })
|
|
188
|
+
* ```
|
|
99
189
|
*/
|
|
100
190
|
export declare function s3StorageProvider(config: S3StorageConfig): IStorageProvider;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { Readable } from 'node:stream';
|
|
10
|
-
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
|
10
|
+
import { DeleteObjectCommand, S3Client, } from '@aws-sdk/client-s3';
|
|
11
11
|
import { Upload } from '@aws-sdk/lib-storage';
|
|
12
12
|
import { v4 as uuidv4 } from 'uuid';
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
@@ -40,6 +40,15 @@ function toReadable(stream) {
|
|
|
40
40
|
// NodeJS.ReadableStream is structurally compatible with Readable.
|
|
41
41
|
return stream;
|
|
42
42
|
}
|
|
43
|
+
function resolveMetadata(supplier, options) {
|
|
44
|
+
if (!supplier)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (typeof supplier === 'function') {
|
|
47
|
+
const value = supplier(options);
|
|
48
|
+
return value && Object.keys(value).length > 0 ? value : undefined;
|
|
49
|
+
}
|
|
50
|
+
return Object.keys(supplier).length > 0 ? supplier : undefined;
|
|
51
|
+
}
|
|
43
52
|
// ---------------------------------------------------------------------------
|
|
44
53
|
// Provider implementation
|
|
45
54
|
// ---------------------------------------------------------------------------
|
|
@@ -49,25 +58,42 @@ class S3StorageProvider {
|
|
|
49
58
|
bucket;
|
|
50
59
|
publicUrl;
|
|
51
60
|
pathPrefix;
|
|
61
|
+
acl;
|
|
62
|
+
cacheControl;
|
|
63
|
+
metadata;
|
|
52
64
|
constructor(config) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
// Pass an explicit `credentials` block only when both halves of a
|
|
66
|
+
// long-lived key pair are present. Otherwise leave it absent so the
|
|
67
|
+
// SDK falls back to its default credential provider chain (IAM role,
|
|
68
|
+
// SSO, env, ~/.aws/credentials).
|
|
69
|
+
const explicitCredentials = config.accessKeyId && config.secretAccessKey
|
|
70
|
+
? {
|
|
56
71
|
accessKeyId: config.accessKeyId,
|
|
57
72
|
secretAccessKey: config.secretAccessKey,
|
|
58
|
-
|
|
73
|
+
...(config.sessionToken ? { sessionToken: config.sessionToken } : {}),
|
|
74
|
+
}
|
|
75
|
+
: undefined;
|
|
76
|
+
this.client = new S3Client({
|
|
77
|
+
...config.clientConfig,
|
|
78
|
+
region: config.region,
|
|
79
|
+
...(explicitCredentials ? { credentials: explicitCredentials } : {}),
|
|
59
80
|
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
60
81
|
...(config.forcePathStyle ? { forcePathStyle: true } : {}),
|
|
61
82
|
});
|
|
62
83
|
this.bucket = config.bucket;
|
|
63
84
|
this.pathPrefix = config.pathPrefix;
|
|
85
|
+
this.acl = config.acl;
|
|
86
|
+
this.cacheControl = config.cacheControl;
|
|
87
|
+
this.metadata = config.metadata;
|
|
64
88
|
// Derive a default public URL if not explicitly provided.
|
|
65
89
|
this.publicUrl =
|
|
66
90
|
config.publicUrl?.replace(/\/$/, '') ??
|
|
67
91
|
`https://${config.bucket}.s3.${config.region}.amazonaws.com`;
|
|
68
92
|
}
|
|
69
93
|
async upload(stream, options) {
|
|
70
|
-
const objectKey =
|
|
94
|
+
const objectKey = options.targetStoragePath ??
|
|
95
|
+
buildObjectKey(this.pathPrefix, options.collection, options.filename);
|
|
96
|
+
const userMetadata = resolveMetadata(this.metadata, options);
|
|
71
97
|
const upload = new Upload({
|
|
72
98
|
client: this.client,
|
|
73
99
|
params: {
|
|
@@ -76,6 +102,9 @@ class S3StorageProvider {
|
|
|
76
102
|
Body: toReadable(stream),
|
|
77
103
|
ContentType: options.mimeType,
|
|
78
104
|
ContentLength: options.size,
|
|
105
|
+
...(this.acl ? { ACL: this.acl } : {}),
|
|
106
|
+
...(this.cacheControl ? { CacheControl: this.cacheControl } : {}),
|
|
107
|
+
...(userMetadata ? { Metadata: userMetadata } : {}),
|
|
79
108
|
},
|
|
80
109
|
});
|
|
81
110
|
await upload.done();
|
|
@@ -86,6 +115,8 @@ class S3StorageProvider {
|
|
|
86
115
|
};
|
|
87
116
|
}
|
|
88
117
|
async delete(storagePath) {
|
|
118
|
+
// S3 DeleteObject is idempotent — succeeds (204) whether the key
|
|
119
|
+
// exists or not — so no need for a NotFound branch here.
|
|
89
120
|
await this.client.send(new DeleteObjectCommand({
|
|
90
121
|
Bucket: this.bucket,
|
|
91
122
|
Key: storagePath,
|
|
@@ -108,7 +139,11 @@ class S3StorageProvider {
|
|
|
108
139
|
* Uploaded files are stored at:
|
|
109
140
|
* `[pathPrefix/]<collection>/<year>/<month>/<uuid>-<filename>`
|
|
110
141
|
*
|
|
111
|
-
*
|
|
142
|
+
* Image variants (thumbnail / card / etc.) are written as siblings of the
|
|
143
|
+
* original under the same prefix, e.g.:
|
|
144
|
+
* `<...>-<filename>-<variantName>.<format>`
|
|
145
|
+
*
|
|
146
|
+
* @example AWS S3 with explicit keys
|
|
112
147
|
* ```ts
|
|
113
148
|
* import { s3StorageProvider } from '@byline/storage-s3'
|
|
114
149
|
*
|
|
@@ -118,6 +153,17 @@ class S3StorageProvider {
|
|
|
118
153
|
* accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
119
154
|
* secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
120
155
|
* publicUrl: 'https://cdn.example.com',
|
|
156
|
+
* cacheControl: 'public, max-age=31536000, immutable',
|
|
157
|
+
* })
|
|
158
|
+
* ```
|
|
159
|
+
*
|
|
160
|
+
* @example AWS S3 with the default credential chain (IAM role / SSO / env)
|
|
161
|
+
* ```ts
|
|
162
|
+
* storage: s3StorageProvider({
|
|
163
|
+
* bucket: 'my-cms-bucket',
|
|
164
|
+
* region: 'eu-west-1',
|
|
165
|
+
* // accessKeyId / secretAccessKey omitted — the AWS SDK resolves
|
|
166
|
+
* // credentials via its default provider chain.
|
|
121
167
|
* })
|
|
122
168
|
* ```
|
|
123
169
|
*
|
|
@@ -145,6 +191,16 @@ class S3StorageProvider {
|
|
|
145
191
|
* publicUrl: 'http://localhost:9000/byline',
|
|
146
192
|
* })
|
|
147
193
|
* ```
|
|
194
|
+
*
|
|
195
|
+
* @example Per-upload metadata + retry tuning
|
|
196
|
+
* ```ts
|
|
197
|
+
* storage: s3StorageProvider({
|
|
198
|
+
* bucket: 'my-cms-bucket',
|
|
199
|
+
* region: 'eu-west-1',
|
|
200
|
+
* metadata: (opts) => ({ collection: opts.collection ?? 'uploads' }),
|
|
201
|
+
* clientConfig: { maxAttempts: 5, retryMode: 'adaptive' },
|
|
202
|
+
* })
|
|
203
|
+
* ```
|
|
148
204
|
*/
|
|
149
205
|
export function s3StorageProvider(config) {
|
|
150
206
|
return new S3StorageProvider(config);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/storage-s3",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.3.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@aws-sdk/lib-storage": "^3.1041.0",
|
|
44
44
|
"npm-run-all": "^4.1.5",
|
|
45
45
|
"uuid": "^14.0.0",
|
|
46
|
-
"@byline/core": "1.
|
|
46
|
+
"@byline/core": "1.3.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@biomejs/biome": "2.4.14",
|