@saws/files-client 2.0.0-beta.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/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@saws/files-client",
3
+ "version": "2.0.0-beta.3",
4
+ "description": "",
5
+ "license": "ISC",
6
+ "author": "",
7
+ "type": "module",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "@aws-sdk/client-s3": "^3.1081.0",
17
+ "@aws-sdk/s3-request-presigner": "^3.1081.0"
18
+ }
19
+ }
@@ -0,0 +1,316 @@
1
+ import {
2
+ CreateBucketCommand,
3
+ DeleteObjectCommand,
4
+ GetObjectCommand,
5
+ HeadBucketCommand,
6
+ HeadObjectCommand,
7
+ ListObjectsV2Command,
8
+ PutObjectCommand,
9
+ S3Client,
10
+ type GetObjectCommandOutput,
11
+ type ListObjectsV2CommandOutput,
12
+ type PutObjectCommandInput,
13
+ type S3ClientConfig,
14
+ } from "@aws-sdk/client-s3";
15
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
16
+
17
+ export interface FilesClientOptions
18
+ extends Omit<S3ClientConfig, "endpoint" | "credentials" | "region"> {
19
+ /** Explicit environment source for application adapters. */
20
+ environment?: Record<string, string | undefined>;
21
+ /** Override an injected value when connecting to an external S3-compatible service. */
22
+ endpoint?: string;
23
+ accessKeyId?: string;
24
+ secretAccessKey?: string;
25
+ region?: string;
26
+ bucket?: string;
27
+ }
28
+
29
+ export interface FilesClientConfiguration {
30
+ endpoint: string;
31
+ accessKeyId: string;
32
+ secretAccessKey: string;
33
+ region: string;
34
+ bucket: string;
35
+ }
36
+
37
+ export class FilesClient {
38
+ readonly serviceName: string;
39
+ readonly bucket: string;
40
+ readonly client: S3Client;
41
+ private bucketReady?: Promise<void>;
42
+
43
+ constructor(serviceName: string, options: FilesClientOptions = {}) {
44
+ const {
45
+ environment,
46
+ endpoint,
47
+ accessKeyId,
48
+ secretAccessKey,
49
+ region,
50
+ bucket,
51
+ ...clientOptions
52
+ } = options;
53
+ const resolved = resolveFilesClientConfiguration(serviceName, environment, {
54
+ endpoint,
55
+ accessKeyId,
56
+ secretAccessKey,
57
+ region,
58
+ bucket,
59
+ });
60
+
61
+ this.serviceName = serviceName;
62
+ this.bucket = resolved.bucket;
63
+ this.client = new S3Client({
64
+ ...clientOptions,
65
+ endpoint: resolved.endpoint,
66
+ credentials: {
67
+ accessKeyId: resolved.accessKeyId,
68
+ secretAccessKey: resolved.secretAccessKey,
69
+ },
70
+ region: resolved.region,
71
+ forcePathStyle: clientOptions.forcePathStyle ?? true,
72
+ });
73
+ }
74
+
75
+ async get(path: string): Promise<GetObjectCommandOutput> {
76
+ await this.ensureBucket();
77
+ return this.client.send(new GetObjectCommand({
78
+ Bucket: this.bucket,
79
+ Key: path,
80
+ }));
81
+ }
82
+
83
+ async read(path: string): Promise<Uint8Array> {
84
+ const response = await this.get(path);
85
+ if (response.Body == null) {
86
+ throw new Error(`File service returned an empty body for "${path}"`);
87
+ }
88
+ return response.Body.transformToByteArray();
89
+ }
90
+
91
+ async write(
92
+ path: string,
93
+ file: PutObjectCommandInput["Body"],
94
+ options: Omit<PutObjectCommandInput, "Bucket" | "Key" | "Body"> = {},
95
+ ) {
96
+ await this.ensureBucket();
97
+ return this.client.send(new PutObjectCommand({
98
+ ...options,
99
+ Bucket: this.bucket,
100
+ Key: path,
101
+ Body: file,
102
+ }));
103
+ }
104
+
105
+ async delete(path: string) {
106
+ await this.ensureBucket();
107
+ return this.client.send(new DeleteObjectCommand({
108
+ Bucket: this.bucket,
109
+ Key: path,
110
+ }));
111
+ }
112
+
113
+ async exists(path: string): Promise<boolean> {
114
+ await this.ensureBucket();
115
+ try {
116
+ await this.client.send(new HeadObjectCommand({
117
+ Bucket: this.bucket,
118
+ Key: path,
119
+ }));
120
+ return true;
121
+ } catch (error) {
122
+ if (isObjectNotFoundError(error)) {
123
+ return false;
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ async list(
130
+ path = "",
131
+ ): Promise<NonNullable<ListObjectsV2CommandOutput["Contents"]>> {
132
+ await this.ensureBucket();
133
+ const response = await this.client.send(new ListObjectsV2Command({
134
+ Bucket: this.bucket,
135
+ Prefix: path,
136
+ }));
137
+ return response.Contents ?? [];
138
+ }
139
+
140
+ async getUrl(path: string, expiresIn?: number) {
141
+ await this.ensureBucket();
142
+ return getSignedUrl(
143
+ this.client,
144
+ new GetObjectCommand({ Bucket: this.bucket, Key: path }),
145
+ expiresIn == null ? undefined : { expiresIn },
146
+ );
147
+ }
148
+
149
+ async getUploadUrl(path: string, expiresIn?: number) {
150
+ await this.ensureBucket();
151
+ return getSignedUrl(
152
+ this.client,
153
+ new PutObjectCommand({ Bucket: this.bucket, Key: path }),
154
+ expiresIn == null ? undefined : { expiresIn },
155
+ );
156
+ }
157
+
158
+ getFile(path: string) {
159
+ return this.get(path);
160
+ }
161
+
162
+ readFile(path: string) {
163
+ return this.read(path);
164
+ }
165
+
166
+ writeFile(
167
+ path: string,
168
+ file: PutObjectCommandInput["Body"],
169
+ options: Omit<PutObjectCommandInput, "Bucket" | "Key" | "Body"> = {},
170
+ ) {
171
+ return this.write(path, file, options);
172
+ }
173
+
174
+ deleteFile(path: string) {
175
+ return this.delete(path);
176
+ }
177
+
178
+ fileExists(path: string) {
179
+ return this.exists(path);
180
+ }
181
+
182
+ listFiles(path = "") {
183
+ return this.list(path);
184
+ }
185
+
186
+ getFileUrl(path: string, expiresIn?: number) {
187
+ return this.getUrl(path, expiresIn);
188
+ }
189
+
190
+ getFileUploadUrl(path: string, expiresIn?: number) {
191
+ return this.getUploadUrl(path, expiresIn);
192
+ }
193
+
194
+ private ensureBucket() {
195
+ this.bucketReady ??= ensureBucketExists(this.client, this.bucket).catch(
196
+ (error) => {
197
+ this.bucketReady = undefined;
198
+ throw error;
199
+ },
200
+ );
201
+ return this.bucketReady;
202
+ }
203
+ }
204
+
205
+ export function resolveFilesClientConfiguration(
206
+ serviceName: string,
207
+ environment?: Record<string, string | undefined>,
208
+ overrides: Partial<FilesClientConfiguration> = {},
209
+ ): FilesClientConfiguration {
210
+ const prefix = filesServiceEnvironmentPrefix(serviceName);
211
+ const source = {
212
+ ...getProcessEnvironment(),
213
+ ...getRuntimeEnvironment(),
214
+ ...environment,
215
+ };
216
+
217
+ return {
218
+ endpoint: requireValue(
219
+ serviceName,
220
+ `${prefix}_FILES_ENDPOINT`,
221
+ overrides.endpoint ??
222
+ source[`${prefix}_FILES_ENDPOINT`] ??
223
+ source[`${prefix}_RUSTFS_ENDPOINT`],
224
+ ),
225
+ accessKeyId: requireValue(
226
+ serviceName,
227
+ `${prefix}_FILES_ACCESS_KEY_ID`,
228
+ overrides.accessKeyId ??
229
+ source[`${prefix}_FILES_ACCESS_KEY_ID`] ??
230
+ source[`${prefix}_RUSTFS_ACCESS_KEY_ID`],
231
+ ),
232
+ secretAccessKey: requireValue(
233
+ serviceName,
234
+ `${prefix}_FILES_SECRET_ACCESS_KEY`,
235
+ overrides.secretAccessKey ??
236
+ source[`${prefix}_FILES_SECRET_ACCESS_KEY`] ??
237
+ source[`${prefix}_RUSTFS_SECRET_ACCESS_KEY`],
238
+ ),
239
+ region:
240
+ overrides.region ??
241
+ source[`${prefix}_FILES_REGION`] ??
242
+ source[`${prefix}_RUSTFS_REGION`] ??
243
+ "us-east-1",
244
+ bucket: requireValue(
245
+ serviceName,
246
+ `${prefix}_FILES_BUCKET`,
247
+ overrides.bucket ??
248
+ source[`${prefix}_FILES_BUCKET`] ??
249
+ source[`${prefix}_RUSTFS_BUCKET`],
250
+ ),
251
+ };
252
+ }
253
+
254
+ export function filesServiceEnvironmentPrefix(serviceName: string) {
255
+ return serviceName.replace(/[^a-zA-Z\d]/g, "_").toUpperCase();
256
+ }
257
+
258
+ async function ensureBucketExists(client: S3Client, bucket: string) {
259
+ try {
260
+ await client.send(new HeadBucketCommand({ Bucket: bucket }));
261
+ return;
262
+ } catch (error) {
263
+ const status = (error as { $metadata?: { httpStatusCode?: number } })
264
+ .$metadata?.httpStatusCode;
265
+ const name = (error as Error).name;
266
+ if (status !== 404 && name !== "NotFound" && name !== "NoSuchBucket") {
267
+ throw error;
268
+ }
269
+ }
270
+
271
+ try {
272
+ await client.send(new CreateBucketCommand({ Bucket: bucket }));
273
+ } catch (error) {
274
+ const name = (error as Error).name;
275
+ if (name !== "BucketAlreadyOwnedByYou") {
276
+ throw error;
277
+ }
278
+ }
279
+ }
280
+
281
+ function isObjectNotFoundError(error: unknown) {
282
+ const status = (error as { $metadata?: { httpStatusCode?: number } })
283
+ .$metadata?.httpStatusCode;
284
+ const name = (error as Error).name;
285
+ return status === 404 || name === "NotFound" || name === "NoSuchKey";
286
+ }
287
+
288
+ function requireValue(
289
+ serviceName: string,
290
+ variableName: string,
291
+ value: string | undefined,
292
+ ) {
293
+ if (value == null || value.trim().length === 0) {
294
+ throw new Error(
295
+ `File service "${serviceName}" is not configured: ` +
296
+ `${variableName} must be present in the SAWS environment`,
297
+ );
298
+ }
299
+ return value;
300
+ }
301
+
302
+ function getRuntimeEnvironment() {
303
+ return (
304
+ globalThis as typeof globalThis & {
305
+ ENV?: Record<string, string | undefined>;
306
+ }
307
+ ).ENV;
308
+ }
309
+
310
+ function getProcessEnvironment() {
311
+ return (
312
+ globalThis as typeof globalThis & {
313
+ process?: { env?: Record<string, string | undefined> };
314
+ }
315
+ ).process?.env;
316
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./FilesClient.js";
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig-node.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "tsBuildInfoFile": "./dist/.tsbuildinfo"
7
+ }
8
+ }