@better-media/adapter-storage-s3 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 abenezeratnafu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS
20
+ ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @better-media/adapter-storage-s3
2
+
3
+ Amazon S3 and S3-compatible storage adapter for the Better Media framework.
4
+
5
+ ## Features
6
+
7
+ - **S3 Support**: Works with AWS S3, Minio, DigitalOcean Spaces, Cloudflare R2, etc.
8
+ - **Presigned URLs**: Securely upload from or download to the browser without exposing credentials.
9
+ - **Streaming**: Supports high-performance streaming for large media files.
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { S3StorageAdapter } from "@better-media/adapter-storage-s3";
15
+
16
+ const storage = new S3StorageAdapter({
17
+ region: "us-east-1",
18
+ bucket: "media-bucket",
19
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
20
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
21
+ });
22
+ ```
23
+
24
+ See [better-media.dev/docs/adapters/storage-s3](https://better-media.dev/docs/adapters/storage-s3) for more details.
@@ -0,0 +1,57 @@
1
+ import { StorageAdapter, GetUrlOptions, PresignedUploadOptions, PresignedUploadResult } from '@better-media/core';
2
+
3
+ interface S3StorageConfig {
4
+ /** AWS region (e.g. "us-east-1") */
5
+ region: string;
6
+ /** S3 bucket name. Can be a string, or a resolver function that determines the bucket based on the key. */
7
+ bucket: string | ((key: string) => string);
8
+ /** AWS access key ID */
9
+ accessKeyId: string;
10
+ /** AWS secret access key */
11
+ secretAccessKey: string;
12
+ /**
13
+ * Custom endpoint for S3-compatible storage (e.g. MinIO).
14
+ * Omit for standard AWS S3.
15
+ */
16
+ endpoint?: string;
17
+ /**
18
+ * Use path-style bucket URLs (bucket.endpoint vs endpoint/bucket).
19
+ * Required for MinIO and some S3-compatible services.
20
+ */
21
+ forcePathStyle?: boolean;
22
+ }
23
+
24
+ /**
25
+ * S3 storage adapter for production deployments.
26
+ * Supports AWS S3 and S3-compatible object storage (MinIO, etc.).
27
+ */
28
+ declare class S3StorageAdapter implements StorageAdapter {
29
+ private readonly client;
30
+ private readonly bucketResolver;
31
+ constructor(config: S3StorageConfig);
32
+ private isNotFoundError;
33
+ private getBucket;
34
+ get(key: string): Promise<Buffer | null>;
35
+ put(key: string, value: Buffer): Promise<void>;
36
+ delete(key: string): Promise<void>;
37
+ exists(key: string): Promise<boolean>;
38
+ getSize(key: string): Promise<number | null>;
39
+ getStream(key: string): Promise<ReadableStream<Uint8Array> | null>;
40
+ getUrl(key: string, options?: GetUrlOptions): Promise<string>;
41
+ /**
42
+ * Create a presigned upload for direct-to-storage upload.
43
+ *
44
+ * - **POST**: Uses an S3 Policy to strictly enforce `Content-Type` and
45
+ * `content-length-range` server-side. S3 rejects any upload violating these.
46
+ * Best for web/browser clients using multipart forms.
47
+ *
48
+ * - **PUT**: Signs `ContentType`, `ContentLength`, and metadata headers into the URL.
49
+ * S3 rejects any request that presents mismatched header values.
50
+ * Best for mobile/API clients doing direct binary body uploads.
51
+ */
52
+ createPresignedUpload(key: string, options: PresignedUploadOptions): Promise<PresignedUploadResult>;
53
+ private _createPresignedPost;
54
+ private _createPresignedPut;
55
+ }
56
+
57
+ export { S3StorageAdapter, type S3StorageConfig };
@@ -0,0 +1,57 @@
1
+ import { StorageAdapter, GetUrlOptions, PresignedUploadOptions, PresignedUploadResult } from '@better-media/core';
2
+
3
+ interface S3StorageConfig {
4
+ /** AWS region (e.g. "us-east-1") */
5
+ region: string;
6
+ /** S3 bucket name. Can be a string, or a resolver function that determines the bucket based on the key. */
7
+ bucket: string | ((key: string) => string);
8
+ /** AWS access key ID */
9
+ accessKeyId: string;
10
+ /** AWS secret access key */
11
+ secretAccessKey: string;
12
+ /**
13
+ * Custom endpoint for S3-compatible storage (e.g. MinIO).
14
+ * Omit for standard AWS S3.
15
+ */
16
+ endpoint?: string;
17
+ /**
18
+ * Use path-style bucket URLs (bucket.endpoint vs endpoint/bucket).
19
+ * Required for MinIO and some S3-compatible services.
20
+ */
21
+ forcePathStyle?: boolean;
22
+ }
23
+
24
+ /**
25
+ * S3 storage adapter for production deployments.
26
+ * Supports AWS S3 and S3-compatible object storage (MinIO, etc.).
27
+ */
28
+ declare class S3StorageAdapter implements StorageAdapter {
29
+ private readonly client;
30
+ private readonly bucketResolver;
31
+ constructor(config: S3StorageConfig);
32
+ private isNotFoundError;
33
+ private getBucket;
34
+ get(key: string): Promise<Buffer | null>;
35
+ put(key: string, value: Buffer): Promise<void>;
36
+ delete(key: string): Promise<void>;
37
+ exists(key: string): Promise<boolean>;
38
+ getSize(key: string): Promise<number | null>;
39
+ getStream(key: string): Promise<ReadableStream<Uint8Array> | null>;
40
+ getUrl(key: string, options?: GetUrlOptions): Promise<string>;
41
+ /**
42
+ * Create a presigned upload for direct-to-storage upload.
43
+ *
44
+ * - **POST**: Uses an S3 Policy to strictly enforce `Content-Type` and
45
+ * `content-length-range` server-side. S3 rejects any upload violating these.
46
+ * Best for web/browser clients using multipart forms.
47
+ *
48
+ * - **PUT**: Signs `ContentType`, `ContentLength`, and metadata headers into the URL.
49
+ * S3 rejects any request that presents mismatched header values.
50
+ * Best for mobile/API clients doing direct binary body uploads.
51
+ */
52
+ createPresignedUpload(key: string, options: PresignedUploadOptions): Promise<PresignedUploadResult>;
53
+ private _createPresignedPost;
54
+ private _createPresignedPut;
55
+ }
56
+
57
+ export { S3StorageAdapter, type S3StorageConfig };
package/dist/index.js ADDED
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+
3
+ var clientS3 = require('@aws-sdk/client-s3');
4
+ var s3RequestPresigner = require('@aws-sdk/s3-request-presigner');
5
+ var s3PresignedPost = require('@aws-sdk/s3-presigned-post');
6
+ var stream = require('stream');
7
+
8
+ // src/adapters/s3-storage.adapter.ts
9
+ var DEFAULT_EXPIRES_IN = 3600;
10
+ var DEFAULT_MAX_SIZE_BYTES = 100 * 1024 * 1024;
11
+ var DEFAULT_MIN_SIZE_BYTES = 1;
12
+ var S3StorageAdapter = class {
13
+ client;
14
+ bucketResolver;
15
+ constructor(config) {
16
+ this.bucketResolver = typeof config.bucket === "function" ? config.bucket : () => config.bucket;
17
+ this.client = new clientS3.S3Client({
18
+ region: config.region,
19
+ credentials: {
20
+ accessKeyId: config.accessKeyId,
21
+ secretAccessKey: config.secretAccessKey
22
+ },
23
+ ...config.endpoint && {
24
+ endpoint: config.endpoint,
25
+ forcePathStyle: config.forcePathStyle ?? true
26
+ }
27
+ });
28
+ }
29
+ isNotFoundError(err) {
30
+ if (!(err instanceof Error)) return false;
31
+ const e = err;
32
+ return e.name === "NoSuchKey" || e.name === "NotFound" || e.$metadata?.httpStatusCode === 404;
33
+ }
34
+ getBucket(key) {
35
+ return this.bucketResolver(key);
36
+ }
37
+ async get(key) {
38
+ try {
39
+ const response = await this.client.send(
40
+ new clientS3.GetObjectCommand({ Bucket: this.getBucket(key), Key: key })
41
+ );
42
+ const body = response.Body;
43
+ if (body == null) return null;
44
+ const chunks = [];
45
+ for await (const chunk of body) {
46
+ chunks.push(chunk);
47
+ }
48
+ return Buffer.concat(chunks);
49
+ } catch (err) {
50
+ if (this.isNotFoundError(err)) return null;
51
+ throw err;
52
+ }
53
+ }
54
+ async put(key, value) {
55
+ await this.client.send(
56
+ new clientS3.PutObjectCommand({
57
+ Bucket: this.getBucket(key),
58
+ Key: key,
59
+ Body: value
60
+ })
61
+ );
62
+ }
63
+ async delete(key) {
64
+ try {
65
+ await this.client.send(new clientS3.DeleteObjectCommand({ Bucket: this.getBucket(key), Key: key }));
66
+ } catch (err) {
67
+ if (this.isNotFoundError(err)) return;
68
+ throw err;
69
+ }
70
+ }
71
+ async exists(key) {
72
+ try {
73
+ await this.client.send(new clientS3.HeadObjectCommand({ Bucket: this.getBucket(key), Key: key }));
74
+ return true;
75
+ } catch (err) {
76
+ if (this.isNotFoundError(err)) return false;
77
+ throw err;
78
+ }
79
+ }
80
+ async getSize(key) {
81
+ try {
82
+ const response = await this.client.send(
83
+ new clientS3.HeadObjectCommand({ Bucket: this.getBucket(key), Key: key })
84
+ );
85
+ const size = response.ContentLength;
86
+ return size != null ? size : null;
87
+ } catch (err) {
88
+ if (this.isNotFoundError(err)) return null;
89
+ throw err;
90
+ }
91
+ }
92
+ async getStream(key) {
93
+ try {
94
+ const response = await this.client.send(
95
+ new clientS3.GetObjectCommand({ Bucket: this.getBucket(key), Key: key })
96
+ );
97
+ const body = response.Body;
98
+ if (body == null) return null;
99
+ if (body instanceof stream.Readable) {
100
+ return stream.Readable.toWeb(body);
101
+ }
102
+ if (body instanceof ReadableStream) {
103
+ return body;
104
+ }
105
+ if (typeof body.stream === "function") {
106
+ return body.stream();
107
+ }
108
+ return null;
109
+ } catch (err) {
110
+ if (this.isNotFoundError(err)) return null;
111
+ throw err;
112
+ }
113
+ }
114
+ async getUrl(key, options) {
115
+ const expiresIn = options?.expiresIn ?? DEFAULT_EXPIRES_IN;
116
+ const command = new clientS3.GetObjectCommand({
117
+ Bucket: this.getBucket(key),
118
+ Key: key
119
+ });
120
+ return s3RequestPresigner.getSignedUrl(this.client, command, { expiresIn });
121
+ }
122
+ /**
123
+ * Create a presigned upload for direct-to-storage upload.
124
+ *
125
+ * - **POST**: Uses an S3 Policy to strictly enforce `Content-Type` and
126
+ * `content-length-range` server-side. S3 rejects any upload violating these.
127
+ * Best for web/browser clients using multipart forms.
128
+ *
129
+ * - **PUT**: Signs `ContentType`, `ContentLength`, and metadata headers into the URL.
130
+ * S3 rejects any request that presents mismatched header values.
131
+ * Best for mobile/API clients doing direct binary body uploads.
132
+ */
133
+ async createPresignedUpload(key, options) {
134
+ const {
135
+ method = "PUT",
136
+ contentType,
137
+ expiresIn = DEFAULT_EXPIRES_IN,
138
+ maxSizeBytes = DEFAULT_MAX_SIZE_BYTES,
139
+ minSizeBytes = DEFAULT_MIN_SIZE_BYTES,
140
+ metadata = {}
141
+ } = options;
142
+ const bucket = this.getBucket(key);
143
+ const metaHeaders = Object.fromEntries(
144
+ Object.entries(metadata).map(([k, v]) => [`x-amz-meta-${k}`, String(v)])
145
+ );
146
+ if (method === "POST") {
147
+ return this._createPresignedPost({
148
+ key,
149
+ bucket,
150
+ contentType,
151
+ expiresIn,
152
+ maxSizeBytes,
153
+ minSizeBytes,
154
+ metadata,
155
+ metaHeaders
156
+ });
157
+ }
158
+ return this._createPresignedPut({
159
+ key,
160
+ bucket,
161
+ contentType,
162
+ expiresIn,
163
+ maxSizeBytes,
164
+ metadata,
165
+ metaHeaders
166
+ });
167
+ }
168
+ async _createPresignedPost(params) {
169
+ const {
170
+ key,
171
+ bucket,
172
+ contentType,
173
+ expiresIn,
174
+ maxSizeBytes,
175
+ minSizeBytes,
176
+ metadata,
177
+ metaHeaders
178
+ } = params;
179
+ const conditions = [
180
+ // Enforce exact Content-Type — S3 rejects mismatches
181
+ { "Content-Type": contentType },
182
+ // Enforce file size range — S3 rejects files outside [min, max]
183
+ ["content-length-range", minSizeBytes, maxSizeBytes],
184
+ // Enforce each metadata k/v — S3 rejects if any value differs
185
+ ...Object.entries(metadata).map(([k, v]) => ({
186
+ [`x-amz-meta-${k}`]: v
187
+ }))
188
+ ];
189
+ const { url, fields } = await s3PresignedPost.createPresignedPost(this.client, {
190
+ Bucket: bucket,
191
+ Key: key,
192
+ Expires: expiresIn,
193
+ Conditions: conditions,
194
+ Fields: {
195
+ "Content-Type": contentType,
196
+ ...metaHeaders
197
+ }
198
+ });
199
+ return { method: "POST", url, fields };
200
+ }
201
+ async _createPresignedPut(params) {
202
+ const { key, bucket, contentType, expiresIn, maxSizeBytes, metadata, metaHeaders } = params;
203
+ const command = new clientS3.PutObjectCommand({
204
+ Bucket: bucket,
205
+ Key: key,
206
+ ContentType: contentType,
207
+ // Encoding ContentLength ties the signature to an exact body size.
208
+ // The client MUST send a Content-Length header that matches this value.
209
+ ContentLength: maxSizeBytes,
210
+ // User metadata is signed in — mismatches cause 403.
211
+ Metadata: Object.keys(metadata).length > 0 ? metadata : void 0
212
+ });
213
+ const url = await s3RequestPresigner.getSignedUrl(this.client, command, { expiresIn });
214
+ const headers = {
215
+ "Content-Type": contentType,
216
+ "Content-Length": String(maxSizeBytes),
217
+ ...metaHeaders
218
+ };
219
+ return { method: "PUT", url, headers };
220
+ }
221
+ };
222
+
223
+ exports.S3StorageAdapter = S3StorageAdapter;
224
+ //# sourceMappingURL=index.js.map
225
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/adapters/s3-storage.adapter.ts"],"names":["S3Client","GetObjectCommand","PutObjectCommand","DeleteObjectCommand","HeadObjectCommand","Readable","getSignedUrl","createPresignedPost"],"mappings":"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","file":"index.js","sourcesContent":["import {\n S3Client,\n GetObjectCommand,\n PutObjectCommand,\n DeleteObjectCommand,\n HeadObjectCommand,\n} from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport { createPresignedPost } from \"@aws-sdk/s3-presigned-post\";\nimport type { PresignedPostOptions } from \"@aws-sdk/s3-presigned-post\";\nimport { Readable } from \"node:stream\";\nimport type {\n StorageAdapter,\n GetUrlOptions,\n PresignedUploadOptions,\n PresignedUploadResult,\n} from \"@better-media/core\";\nimport type { S3StorageConfig } from \"../interfaces/s3-storage-config.interface\";\n\nconst DEFAULT_EXPIRES_IN = 3600; // 1 hour\nconst DEFAULT_MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB\nconst DEFAULT_MIN_SIZE_BYTES = 1; // at least 1 byte\n\n/**\n * S3 storage adapter for production deployments.\n * Supports AWS S3 and S3-compatible object storage (MinIO, etc.).\n */\nexport class S3StorageAdapter implements StorageAdapter {\n private readonly client: S3Client;\n private readonly bucketResolver: (key: string) => string;\n\n constructor(config: S3StorageConfig) {\n this.bucketResolver =\n typeof config.bucket === \"function\" ? config.bucket : () => config.bucket as string;\n this.client = new S3Client({\n region: config.region,\n credentials: {\n accessKeyId: config.accessKeyId,\n secretAccessKey: config.secretAccessKey,\n },\n ...(config.endpoint && {\n endpoint: config.endpoint,\n forcePathStyle: config.forcePathStyle ?? true,\n }),\n });\n }\n\n private isNotFoundError(err: unknown): boolean {\n if (!(err instanceof Error)) return false;\n const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };\n return e.name === \"NoSuchKey\" || e.name === \"NotFound\" || e.$metadata?.httpStatusCode === 404;\n }\n\n private getBucket(key: string): string {\n return this.bucketResolver(key);\n }\n\n async get(key: string): Promise<Buffer | null> {\n try {\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.getBucket(key), Key: key })\n );\n const body = response.Body;\n if (body == null) return null;\n const chunks: Uint8Array[] = [];\n for await (const chunk of body as AsyncIterable<Uint8Array>) {\n chunks.push(chunk);\n }\n return Buffer.concat(chunks);\n } catch (err) {\n if (this.isNotFoundError(err)) return null;\n throw err;\n }\n }\n\n async put(key: string, value: Buffer): Promise<void> {\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.getBucket(key),\n Key: key,\n Body: value,\n })\n );\n }\n\n async delete(key: string): Promise<void> {\n try {\n await this.client.send(new DeleteObjectCommand({ Bucket: this.getBucket(key), Key: key }));\n } catch (err) {\n if (this.isNotFoundError(err)) return;\n throw err;\n }\n }\n\n async exists(key: string): Promise<boolean> {\n try {\n await this.client.send(new HeadObjectCommand({ Bucket: this.getBucket(key), Key: key }));\n return true;\n } catch (err) {\n if (this.isNotFoundError(err)) return false;\n throw err;\n }\n }\n\n async getSize(key: string): Promise<number | null> {\n try {\n const response = await this.client.send(\n new HeadObjectCommand({ Bucket: this.getBucket(key), Key: key })\n );\n const size = response.ContentLength;\n return size != null ? size : null;\n } catch (err) {\n if (this.isNotFoundError(err)) return null;\n throw err;\n }\n }\n\n async getStream(key: string): Promise<ReadableStream<Uint8Array> | null> {\n try {\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.getBucket(key), Key: key })\n );\n const body = response.Body;\n if (body == null) return null;\n if (body instanceof Readable) {\n return Readable.toWeb(body) as ReadableStream<Uint8Array>;\n }\n if (body instanceof ReadableStream) {\n return body as ReadableStream<Uint8Array>;\n }\n if (typeof (body as Blob).stream === \"function\") {\n return (body as Blob).stream() as ReadableStream<Uint8Array>;\n }\n return null;\n } catch (err) {\n if (this.isNotFoundError(err)) return null;\n throw err;\n }\n }\n\n async getUrl(key: string, options?: GetUrlOptions): Promise<string> {\n const expiresIn = options?.expiresIn ?? DEFAULT_EXPIRES_IN;\n const command = new GetObjectCommand({\n Bucket: this.getBucket(key),\n Key: key,\n });\n return getSignedUrl(this.client, command, { expiresIn });\n }\n\n /**\n * Create a presigned upload for direct-to-storage upload.\n *\n * - **POST**: Uses an S3 Policy to strictly enforce `Content-Type` and\n * `content-length-range` server-side. S3 rejects any upload violating these.\n * Best for web/browser clients using multipart forms.\n *\n * - **PUT**: Signs `ContentType`, `ContentLength`, and metadata headers into the URL.\n * S3 rejects any request that presents mismatched header values.\n * Best for mobile/API clients doing direct binary body uploads.\n */\n async createPresignedUpload(\n key: string,\n options: PresignedUploadOptions\n ): Promise<PresignedUploadResult> {\n const {\n method = \"PUT\",\n contentType,\n expiresIn = DEFAULT_EXPIRES_IN,\n maxSizeBytes = DEFAULT_MAX_SIZE_BYTES,\n minSizeBytes = DEFAULT_MIN_SIZE_BYTES,\n metadata = {},\n } = options;\n\n const bucket = this.getBucket(key);\n // Build x-amz-meta-* header map for user metadata\n const metaHeaders: Record<string, string> = Object.fromEntries(\n Object.entries(metadata).map(([k, v]): [string, string] => [`x-amz-meta-${k}`, String(v)])\n );\n\n if (method === \"POST\") {\n return this._createPresignedPost({\n key,\n bucket,\n contentType,\n expiresIn,\n maxSizeBytes,\n minSizeBytes,\n metadata,\n metaHeaders,\n });\n }\n\n return this._createPresignedPut({\n key,\n bucket,\n contentType,\n expiresIn,\n maxSizeBytes,\n metadata,\n metaHeaders,\n });\n }\n\n private async _createPresignedPost(params: {\n key: string;\n bucket: string;\n contentType: string;\n expiresIn: number;\n maxSizeBytes: number;\n minSizeBytes: number;\n metadata: Record<string, string>;\n metaHeaders: Record<string, string>;\n }): Promise<PresignedUploadResult> {\n const {\n key,\n bucket,\n contentType,\n expiresIn,\n maxSizeBytes,\n minSizeBytes,\n metadata,\n metaHeaders,\n } = params;\n\n // Build the conditions array using the SDK's element type\n const conditions: NonNullable<PresignedPostOptions[\"Conditions\"]> = [\n // Enforce exact Content-Type — S3 rejects mismatches\n { \"Content-Type\": contentType },\n // Enforce file size range — S3 rejects files outside [min, max]\n [\"content-length-range\", minSizeBytes, maxSizeBytes],\n // Enforce each metadata k/v — S3 rejects if any value differs\n ...Object.entries(metadata).map(([k, v]) => ({\n [`x-amz-meta-${k}`]: v,\n })),\n ];\n\n const { url, fields } = await createPresignedPost(this.client, {\n Bucket: bucket,\n Key: key,\n Expires: expiresIn,\n Conditions: conditions,\n Fields: {\n \"Content-Type\": contentType,\n ...metaHeaders,\n },\n });\n\n return { method: \"POST\", url, fields };\n }\n\n private async _createPresignedPut(params: {\n key: string;\n bucket: string;\n contentType: string;\n expiresIn: number;\n maxSizeBytes: number;\n metadata: Record<string, string>;\n metaHeaders: Record<string, string>;\n }): Promise<PresignedUploadResult> {\n const { key, bucket, contentType, expiresIn, maxSizeBytes, metadata, metaHeaders } = params;\n\n // All fields set on PutObjectCommand are hashed into the signature.\n // S3 will return 403 for any request that presents different header values.\n const command = new PutObjectCommand({\n Bucket: bucket,\n Key: key,\n ContentType: contentType,\n // Encoding ContentLength ties the signature to an exact body size.\n // The client MUST send a Content-Length header that matches this value.\n ContentLength: maxSizeBytes,\n // User metadata is signed in — mismatches cause 403.\n Metadata: Object.keys(metadata).length > 0 ? metadata : undefined,\n });\n\n const url = await getSignedUrl(this.client, command, { expiresIn });\n\n // Surface the required headers so callers know exactly what to send.\n const headers: Record<string, string> = {\n \"Content-Type\": contentType,\n \"Content-Length\": String(maxSizeBytes),\n ...metaHeaders,\n };\n\n return { method: \"PUT\", url, headers };\n }\n}\n"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,223 @@
1
+ import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
2
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3
+ import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
4
+ import { Readable } from 'stream';
5
+
6
+ // src/adapters/s3-storage.adapter.ts
7
+ var DEFAULT_EXPIRES_IN = 3600;
8
+ var DEFAULT_MAX_SIZE_BYTES = 100 * 1024 * 1024;
9
+ var DEFAULT_MIN_SIZE_BYTES = 1;
10
+ var S3StorageAdapter = class {
11
+ client;
12
+ bucketResolver;
13
+ constructor(config) {
14
+ this.bucketResolver = typeof config.bucket === "function" ? config.bucket : () => config.bucket;
15
+ this.client = new S3Client({
16
+ region: config.region,
17
+ credentials: {
18
+ accessKeyId: config.accessKeyId,
19
+ secretAccessKey: config.secretAccessKey
20
+ },
21
+ ...config.endpoint && {
22
+ endpoint: config.endpoint,
23
+ forcePathStyle: config.forcePathStyle ?? true
24
+ }
25
+ });
26
+ }
27
+ isNotFoundError(err) {
28
+ if (!(err instanceof Error)) return false;
29
+ const e = err;
30
+ return e.name === "NoSuchKey" || e.name === "NotFound" || e.$metadata?.httpStatusCode === 404;
31
+ }
32
+ getBucket(key) {
33
+ return this.bucketResolver(key);
34
+ }
35
+ async get(key) {
36
+ try {
37
+ const response = await this.client.send(
38
+ new GetObjectCommand({ Bucket: this.getBucket(key), Key: key })
39
+ );
40
+ const body = response.Body;
41
+ if (body == null) return null;
42
+ const chunks = [];
43
+ for await (const chunk of body) {
44
+ chunks.push(chunk);
45
+ }
46
+ return Buffer.concat(chunks);
47
+ } catch (err) {
48
+ if (this.isNotFoundError(err)) return null;
49
+ throw err;
50
+ }
51
+ }
52
+ async put(key, value) {
53
+ await this.client.send(
54
+ new PutObjectCommand({
55
+ Bucket: this.getBucket(key),
56
+ Key: key,
57
+ Body: value
58
+ })
59
+ );
60
+ }
61
+ async delete(key) {
62
+ try {
63
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.getBucket(key), Key: key }));
64
+ } catch (err) {
65
+ if (this.isNotFoundError(err)) return;
66
+ throw err;
67
+ }
68
+ }
69
+ async exists(key) {
70
+ try {
71
+ await this.client.send(new HeadObjectCommand({ Bucket: this.getBucket(key), Key: key }));
72
+ return true;
73
+ } catch (err) {
74
+ if (this.isNotFoundError(err)) return false;
75
+ throw err;
76
+ }
77
+ }
78
+ async getSize(key) {
79
+ try {
80
+ const response = await this.client.send(
81
+ new HeadObjectCommand({ Bucket: this.getBucket(key), Key: key })
82
+ );
83
+ const size = response.ContentLength;
84
+ return size != null ? size : null;
85
+ } catch (err) {
86
+ if (this.isNotFoundError(err)) return null;
87
+ throw err;
88
+ }
89
+ }
90
+ async getStream(key) {
91
+ try {
92
+ const response = await this.client.send(
93
+ new GetObjectCommand({ Bucket: this.getBucket(key), Key: key })
94
+ );
95
+ const body = response.Body;
96
+ if (body == null) return null;
97
+ if (body instanceof Readable) {
98
+ return Readable.toWeb(body);
99
+ }
100
+ if (body instanceof ReadableStream) {
101
+ return body;
102
+ }
103
+ if (typeof body.stream === "function") {
104
+ return body.stream();
105
+ }
106
+ return null;
107
+ } catch (err) {
108
+ if (this.isNotFoundError(err)) return null;
109
+ throw err;
110
+ }
111
+ }
112
+ async getUrl(key, options) {
113
+ const expiresIn = options?.expiresIn ?? DEFAULT_EXPIRES_IN;
114
+ const command = new GetObjectCommand({
115
+ Bucket: this.getBucket(key),
116
+ Key: key
117
+ });
118
+ return getSignedUrl(this.client, command, { expiresIn });
119
+ }
120
+ /**
121
+ * Create a presigned upload for direct-to-storage upload.
122
+ *
123
+ * - **POST**: Uses an S3 Policy to strictly enforce `Content-Type` and
124
+ * `content-length-range` server-side. S3 rejects any upload violating these.
125
+ * Best for web/browser clients using multipart forms.
126
+ *
127
+ * - **PUT**: Signs `ContentType`, `ContentLength`, and metadata headers into the URL.
128
+ * S3 rejects any request that presents mismatched header values.
129
+ * Best for mobile/API clients doing direct binary body uploads.
130
+ */
131
+ async createPresignedUpload(key, options) {
132
+ const {
133
+ method = "PUT",
134
+ contentType,
135
+ expiresIn = DEFAULT_EXPIRES_IN,
136
+ maxSizeBytes = DEFAULT_MAX_SIZE_BYTES,
137
+ minSizeBytes = DEFAULT_MIN_SIZE_BYTES,
138
+ metadata = {}
139
+ } = options;
140
+ const bucket = this.getBucket(key);
141
+ const metaHeaders = Object.fromEntries(
142
+ Object.entries(metadata).map(([k, v]) => [`x-amz-meta-${k}`, String(v)])
143
+ );
144
+ if (method === "POST") {
145
+ return this._createPresignedPost({
146
+ key,
147
+ bucket,
148
+ contentType,
149
+ expiresIn,
150
+ maxSizeBytes,
151
+ minSizeBytes,
152
+ metadata,
153
+ metaHeaders
154
+ });
155
+ }
156
+ return this._createPresignedPut({
157
+ key,
158
+ bucket,
159
+ contentType,
160
+ expiresIn,
161
+ maxSizeBytes,
162
+ metadata,
163
+ metaHeaders
164
+ });
165
+ }
166
+ async _createPresignedPost(params) {
167
+ const {
168
+ key,
169
+ bucket,
170
+ contentType,
171
+ expiresIn,
172
+ maxSizeBytes,
173
+ minSizeBytes,
174
+ metadata,
175
+ metaHeaders
176
+ } = params;
177
+ const conditions = [
178
+ // Enforce exact Content-Type — S3 rejects mismatches
179
+ { "Content-Type": contentType },
180
+ // Enforce file size range — S3 rejects files outside [min, max]
181
+ ["content-length-range", minSizeBytes, maxSizeBytes],
182
+ // Enforce each metadata k/v — S3 rejects if any value differs
183
+ ...Object.entries(metadata).map(([k, v]) => ({
184
+ [`x-amz-meta-${k}`]: v
185
+ }))
186
+ ];
187
+ const { url, fields } = await createPresignedPost(this.client, {
188
+ Bucket: bucket,
189
+ Key: key,
190
+ Expires: expiresIn,
191
+ Conditions: conditions,
192
+ Fields: {
193
+ "Content-Type": contentType,
194
+ ...metaHeaders
195
+ }
196
+ });
197
+ return { method: "POST", url, fields };
198
+ }
199
+ async _createPresignedPut(params) {
200
+ const { key, bucket, contentType, expiresIn, maxSizeBytes, metadata, metaHeaders } = params;
201
+ const command = new PutObjectCommand({
202
+ Bucket: bucket,
203
+ Key: key,
204
+ ContentType: contentType,
205
+ // Encoding ContentLength ties the signature to an exact body size.
206
+ // The client MUST send a Content-Length header that matches this value.
207
+ ContentLength: maxSizeBytes,
208
+ // User metadata is signed in — mismatches cause 403.
209
+ Metadata: Object.keys(metadata).length > 0 ? metadata : void 0
210
+ });
211
+ const url = await getSignedUrl(this.client, command, { expiresIn });
212
+ const headers = {
213
+ "Content-Type": contentType,
214
+ "Content-Length": String(maxSizeBytes),
215
+ ...metaHeaders
216
+ };
217
+ return { method: "PUT", url, headers };
218
+ }
219
+ };
220
+
221
+ export { S3StorageAdapter };
222
+ //# sourceMappingURL=index.mjs.map
223
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/adapters/s3-storage.adapter.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["import {\n S3Client,\n GetObjectCommand,\n PutObjectCommand,\n DeleteObjectCommand,\n HeadObjectCommand,\n} from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport { createPresignedPost } from \"@aws-sdk/s3-presigned-post\";\nimport type { PresignedPostOptions } from \"@aws-sdk/s3-presigned-post\";\nimport { Readable } from \"node:stream\";\nimport type {\n StorageAdapter,\n GetUrlOptions,\n PresignedUploadOptions,\n PresignedUploadResult,\n} from \"@better-media/core\";\nimport type { S3StorageConfig } from \"../interfaces/s3-storage-config.interface\";\n\nconst DEFAULT_EXPIRES_IN = 3600; // 1 hour\nconst DEFAULT_MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB\nconst DEFAULT_MIN_SIZE_BYTES = 1; // at least 1 byte\n\n/**\n * S3 storage adapter for production deployments.\n * Supports AWS S3 and S3-compatible object storage (MinIO, etc.).\n */\nexport class S3StorageAdapter implements StorageAdapter {\n private readonly client: S3Client;\n private readonly bucketResolver: (key: string) => string;\n\n constructor(config: S3StorageConfig) {\n this.bucketResolver =\n typeof config.bucket === \"function\" ? config.bucket : () => config.bucket as string;\n this.client = new S3Client({\n region: config.region,\n credentials: {\n accessKeyId: config.accessKeyId,\n secretAccessKey: config.secretAccessKey,\n },\n ...(config.endpoint && {\n endpoint: config.endpoint,\n forcePathStyle: config.forcePathStyle ?? true,\n }),\n });\n }\n\n private isNotFoundError(err: unknown): boolean {\n if (!(err instanceof Error)) return false;\n const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };\n return e.name === \"NoSuchKey\" || e.name === \"NotFound\" || e.$metadata?.httpStatusCode === 404;\n }\n\n private getBucket(key: string): string {\n return this.bucketResolver(key);\n }\n\n async get(key: string): Promise<Buffer | null> {\n try {\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.getBucket(key), Key: key })\n );\n const body = response.Body;\n if (body == null) return null;\n const chunks: Uint8Array[] = [];\n for await (const chunk of body as AsyncIterable<Uint8Array>) {\n chunks.push(chunk);\n }\n return Buffer.concat(chunks);\n } catch (err) {\n if (this.isNotFoundError(err)) return null;\n throw err;\n }\n }\n\n async put(key: string, value: Buffer): Promise<void> {\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.getBucket(key),\n Key: key,\n Body: value,\n })\n );\n }\n\n async delete(key: string): Promise<void> {\n try {\n await this.client.send(new DeleteObjectCommand({ Bucket: this.getBucket(key), Key: key }));\n } catch (err) {\n if (this.isNotFoundError(err)) return;\n throw err;\n }\n }\n\n async exists(key: string): Promise<boolean> {\n try {\n await this.client.send(new HeadObjectCommand({ Bucket: this.getBucket(key), Key: key }));\n return true;\n } catch (err) {\n if (this.isNotFoundError(err)) return false;\n throw err;\n }\n }\n\n async getSize(key: string): Promise<number | null> {\n try {\n const response = await this.client.send(\n new HeadObjectCommand({ Bucket: this.getBucket(key), Key: key })\n );\n const size = response.ContentLength;\n return size != null ? size : null;\n } catch (err) {\n if (this.isNotFoundError(err)) return null;\n throw err;\n }\n }\n\n async getStream(key: string): Promise<ReadableStream<Uint8Array> | null> {\n try {\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.getBucket(key), Key: key })\n );\n const body = response.Body;\n if (body == null) return null;\n if (body instanceof Readable) {\n return Readable.toWeb(body) as ReadableStream<Uint8Array>;\n }\n if (body instanceof ReadableStream) {\n return body as ReadableStream<Uint8Array>;\n }\n if (typeof (body as Blob).stream === \"function\") {\n return (body as Blob).stream() as ReadableStream<Uint8Array>;\n }\n return null;\n } catch (err) {\n if (this.isNotFoundError(err)) return null;\n throw err;\n }\n }\n\n async getUrl(key: string, options?: GetUrlOptions): Promise<string> {\n const expiresIn = options?.expiresIn ?? DEFAULT_EXPIRES_IN;\n const command = new GetObjectCommand({\n Bucket: this.getBucket(key),\n Key: key,\n });\n return getSignedUrl(this.client, command, { expiresIn });\n }\n\n /**\n * Create a presigned upload for direct-to-storage upload.\n *\n * - **POST**: Uses an S3 Policy to strictly enforce `Content-Type` and\n * `content-length-range` server-side. S3 rejects any upload violating these.\n * Best for web/browser clients using multipart forms.\n *\n * - **PUT**: Signs `ContentType`, `ContentLength`, and metadata headers into the URL.\n * S3 rejects any request that presents mismatched header values.\n * Best for mobile/API clients doing direct binary body uploads.\n */\n async createPresignedUpload(\n key: string,\n options: PresignedUploadOptions\n ): Promise<PresignedUploadResult> {\n const {\n method = \"PUT\",\n contentType,\n expiresIn = DEFAULT_EXPIRES_IN,\n maxSizeBytes = DEFAULT_MAX_SIZE_BYTES,\n minSizeBytes = DEFAULT_MIN_SIZE_BYTES,\n metadata = {},\n } = options;\n\n const bucket = this.getBucket(key);\n // Build x-amz-meta-* header map for user metadata\n const metaHeaders: Record<string, string> = Object.fromEntries(\n Object.entries(metadata).map(([k, v]): [string, string] => [`x-amz-meta-${k}`, String(v)])\n );\n\n if (method === \"POST\") {\n return this._createPresignedPost({\n key,\n bucket,\n contentType,\n expiresIn,\n maxSizeBytes,\n minSizeBytes,\n metadata,\n metaHeaders,\n });\n }\n\n return this._createPresignedPut({\n key,\n bucket,\n contentType,\n expiresIn,\n maxSizeBytes,\n metadata,\n metaHeaders,\n });\n }\n\n private async _createPresignedPost(params: {\n key: string;\n bucket: string;\n contentType: string;\n expiresIn: number;\n maxSizeBytes: number;\n minSizeBytes: number;\n metadata: Record<string, string>;\n metaHeaders: Record<string, string>;\n }): Promise<PresignedUploadResult> {\n const {\n key,\n bucket,\n contentType,\n expiresIn,\n maxSizeBytes,\n minSizeBytes,\n metadata,\n metaHeaders,\n } = params;\n\n // Build the conditions array using the SDK's element type\n const conditions: NonNullable<PresignedPostOptions[\"Conditions\"]> = [\n // Enforce exact Content-Type — S3 rejects mismatches\n { \"Content-Type\": contentType },\n // Enforce file size range — S3 rejects files outside [min, max]\n [\"content-length-range\", minSizeBytes, maxSizeBytes],\n // Enforce each metadata k/v — S3 rejects if any value differs\n ...Object.entries(metadata).map(([k, v]) => ({\n [`x-amz-meta-${k}`]: v,\n })),\n ];\n\n const { url, fields } = await createPresignedPost(this.client, {\n Bucket: bucket,\n Key: key,\n Expires: expiresIn,\n Conditions: conditions,\n Fields: {\n \"Content-Type\": contentType,\n ...metaHeaders,\n },\n });\n\n return { method: \"POST\", url, fields };\n }\n\n private async _createPresignedPut(params: {\n key: string;\n bucket: string;\n contentType: string;\n expiresIn: number;\n maxSizeBytes: number;\n metadata: Record<string, string>;\n metaHeaders: Record<string, string>;\n }): Promise<PresignedUploadResult> {\n const { key, bucket, contentType, expiresIn, maxSizeBytes, metadata, metaHeaders } = params;\n\n // All fields set on PutObjectCommand are hashed into the signature.\n // S3 will return 403 for any request that presents different header values.\n const command = new PutObjectCommand({\n Bucket: bucket,\n Key: key,\n ContentType: contentType,\n // Encoding ContentLength ties the signature to an exact body size.\n // The client MUST send a Content-Length header that matches this value.\n ContentLength: maxSizeBytes,\n // User metadata is signed in — mismatches cause 403.\n Metadata: Object.keys(metadata).length > 0 ? metadata : undefined,\n });\n\n const url = await getSignedUrl(this.client, command, { expiresIn });\n\n // Surface the required headers so callers know exactly what to send.\n const headers: Record<string, string> = {\n \"Content-Type\": contentType,\n \"Content-Length\": String(maxSizeBytes),\n ...metaHeaders,\n };\n\n return { method: \"PUT\", url, headers };\n }\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@better-media/adapter-storage-s3",
3
+ "version": "0.1.0",
4
+ "description": "S3 storage adapter for Better Media",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "dependencies": {
20
+ "@aws-sdk/client-s3": "^3.700.0",
21
+ "@aws-sdk/s3-presigned-post": "^3.700.0",
22
+ "@aws-sdk/s3-request-presigner": "^3.700.0",
23
+ "@better-media/core": "0.1.0"
24
+ },
25
+ "keywords": [
26
+ "better-media",
27
+ "storage",
28
+ "s3",
29
+ "aws",
30
+ "minio",
31
+ "adapter"
32
+ ],
33
+ "author": "Abenezer Atnafu",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/AbenezerAtnafu/better-media.git"
38
+ },
39
+ "homepage": "https://better-media.dev",
40
+ "bugs": {
41
+ "url": "https://github.com/AbenezerAtnafu/better-media/issues"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "tsup",
48
+ "dev": "tsup --watch",
49
+ "typecheck": "tsc --noEmit",
50
+ "lint": "eslint .",
51
+ "test": "jest"
52
+ }
53
+ }