@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 CHANGED
@@ -6,4 +6,4 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  export { s3StorageProvider } from './s3-storage-provider.js';
9
- export type { S3StorageConfig } from './s3-storage-provider.js';
9
+ export type { S3MetadataSupplier, S3StorageConfig } from './s3-storage-provider.js';
@@ -5,7 +5,15 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { IStorageProvider } from '@byline/core';
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
- /** AWS Access Key ID (or equivalent credential for compatible providers). */
18
- accessKeyId: string;
19
- /** AWS Secret Access Key (or equivalent credential for compatible providers). */
20
- secretAccessKey: string;
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
- * @example AWS S3
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
- this.client = new S3Client({
54
- region: config.region,
55
- credentials: {
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 = buildObjectKey(this.pathPrefix, options.collection, options.filename);
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
- * @example AWS S3
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.2.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.2.1"
46
+ "@byline/core": "1.3.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@biomejs/biome": "2.4.14",