@beespoke/micro-server-s3 0.0.0-stage → 2.0.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/README.md CHANGED
@@ -1,3 +1,71 @@
1
- # Temporary Holding Version
1
+ # @beespoke/micro-server-s3
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ S3-compatible uploads, multipart sessions, object deletion, and XML/stream helpers.
4
+
5
+ Supports Node 24+ and Bun 1.3.14+.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @beespoke/micro-server @beespoke/micro-server-s3
11
+ ```
12
+
13
+ Use `bun add` in place of `npm install` for Bun projects.
14
+
15
+ ## Configure the plugin
16
+
17
+ ```ts
18
+ import { defineConfig } from '@beespoke/micro-server';
19
+
20
+ export default defineConfig({
21
+ plugins: ['@beespoke/micro-server-s3'],
22
+ pluginOptions: {
23
+ '@beespoke/micro-server-s3': {
24
+ endpoint: process.env.S3_ENDPOINT!,
25
+ region: process.env.S3_REGION!,
26
+ accessKeyId: process.env.S3_ACCESS_KEY_ID!,
27
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
28
+ },
29
+ },
30
+ });
31
+ ```
32
+
33
+ Requires an S3-compatible endpoint and credentials. `endpoint` must be an HTTP or HTTPS URL. Optional `forcePathStyle` controls path-style addressing; `publicUrl` provides a public base URL. Include a bucket segment in `publicUrl` when required by your public URL layout. Buckets remain operation arguments.
34
+
35
+ ## Storage API
36
+
37
+ The root exports `UploaderS3`, `S3Options`, `DeleteObjectRequest`, attachment types, and `mimeTypes`. `UploaderS3` provides upload/multipart operations and `deleteFile`. Utility exports include `getMimeType`, `readAsBuffer`, `readAsString`, `parseXml`, `uploadPartParser`, and `sanitizeETag`.
38
+
39
+ Direct initialization uses `await UploaderS3.init({ s3Endpoint, s3region, s3AccessKeyId, s3SecretAccessKey })`; the spelling `s3region` is lowercase in the direct API. Close with `await UploaderS3.close()`. Shutdown drains tracked operations and aborts unfinished multipart uploads. Plugin configuration uses the shorter names shown above.
40
+
41
+ ## Start the application
42
+
43
+ Save the configuration as `micro-server.config.ts` next to your application's `package.json`, then create `index.ts`:
44
+
45
+ ```ts
46
+ import { ApplicationServer } from '@beespoke/micro-server';
47
+
48
+ await ApplicationServer.bootstrap();
49
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
50
+ process.once(signal, () => {
51
+ void ApplicationServer.close().catch(error => {
52
+ console.error(error);
53
+ process.exitCode = 1;
54
+ });
55
+ });
56
+ }
57
+ ```
58
+
59
+ Run `bun index.ts` or `node --env-file-if-exists=.env index.ts`. Node requires ESM (`"type": "module"` in package.json). When using Bun, load configuration secrets from the application environment or its `.env` file.
60
+
61
+ ## TypeScript and lifecycle
62
+
63
+ Add this type-only import to an application-owned `plugin-types.d.ts` for configuration autocomplete:
64
+
65
+ ```ts
66
+ import type {} from '@beespoke/micro-server-s3/plugin';
67
+ ```
68
+
69
+ Core validates plugin options before setup and owns cleanup after successful startup or startup failure. Await `ApplicationServer.bootstrap()` before using the configured client, and call `ApplicationServer.close()` during application shutdown. Importing the package does not open a connection. Relative plugin paths resolve from the selected configuration file's directory.
70
+
71
+ The root export can also be used without HTTP core. In that case, explicitly initialize and close the client. The `/plugin` entry is the integration adapter for `@beespoke/micro-server`.
@@ -0,0 +1,5 @@
1
+ export { default as UploaderS3 } from './utils/UploaderS3.js';
2
+ export type { S3Options, ApplicationOptionType } from './types/S3Options.js';
3
+ export { mimeTypes } from './types/AttachmentMetadataTypes.js';
4
+ export type { AttachmentMetadataType, DeleteObjectRequest } from './types/AttachmentMetadataTypes.js';
5
+ export { getMimeType, readAsBuffer, readAsString, parseXml, uploadPartParser, sanitizeETag } from './utils/helpers.js';
package/dist/index.js ADDED
@@ -0,0 +1,404 @@
1
+ // src/support.ts
2
+ var consoleLogger = {
3
+ info: (...args) => console.info(...args),
4
+ warn: (...args) => console.warn(...args),
5
+ error: (...args) => console.error(...args),
6
+ debug: (...args) => console.debug(...args)
7
+ };
8
+
9
+ // src/utils/UploaderS3.ts
10
+ import { S3, ListBucketsCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, ListMultipartUploadsCommand, PutObjectCommand, DeleteObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
11
+
12
+ // src/types/AttachmentMetadataTypes.ts
13
+ var mimeTypes = {
14
+ jpg: "image/jpeg",
15
+ jpeg: "image/jpeg",
16
+ png: "image/png",
17
+ gif: "image/gif",
18
+ webp: "image/webp",
19
+ bmp: "image/bmp",
20
+ svg: "image/svg+xml",
21
+ mp4: "video/mp4",
22
+ mov: "video/quicktime",
23
+ avi: "video/x-msvideo",
24
+ mkv: "video/x-matroska",
25
+ mp3: "audio/mpeg",
26
+ wav: "audio/wav",
27
+ pdf: "application/pdf",
28
+ doc: "application/msword",
29
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
30
+ xls: "application/vnd.ms-excel",
31
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
32
+ txt: "text/plain",
33
+ json: "application/json",
34
+ html: "text/html",
35
+ css: "text/css",
36
+ js: "application/javascript",
37
+ ts: "application/typescript"
38
+ };
39
+
40
+ // src/utils/helpers.ts
41
+ import { XMLParser } from "fast-xml-parser";
42
+ var getMimeType = (fileName) => {
43
+ const extension = fileName.split(".").pop()?.toLowerCase();
44
+ return extension ? mimeTypes[extension] || null : null;
45
+ };
46
+ async function readAsBuffer(res) {
47
+ return new Promise((resolve, reject) => {
48
+ const body = [];
49
+ res.on("data", (chunk) => body.push(chunk)).on("error", (e) => reject(e)).on("end", () => resolve(Buffer.concat(body)));
50
+ });
51
+ }
52
+ async function readAsString(res) {
53
+ const body = await readAsBuffer(res);
54
+ return body.toString();
55
+ }
56
+ var parseXml = (xml) => {
57
+ const fxp = new XMLParser({ numberParseOptions: { eNotation: false, hex: true, leadingZeros: true } });
58
+ const result = fxp.parse(xml);
59
+ if (result.Error) {
60
+ throw result.Error;
61
+ }
62
+ return result;
63
+ };
64
+ var uploadPartParser = (xml) => {
65
+ const xmlObj = parseXml(xml);
66
+ return xmlObj.CopyPartResult;
67
+ };
68
+ var sanitizeETag = (etag) => {
69
+ const replaceChars = {
70
+ '"': "",
71
+ """: "",
72
+ """: "",
73
+ """: "",
74
+ "&#x00022": ""
75
+ };
76
+ return etag.replace(/^("|"|")|("|"|")$/g, (m) => replaceChars[m]);
77
+ };
78
+
79
+ // src/utils/UploaderS3.ts
80
+ var Logger = consoleLogger;
81
+
82
+ class UploaderS3 {
83
+ options;
84
+ name = "s3";
85
+ static options = {};
86
+ static sessions = new Map;
87
+ static closing;
88
+ static pendingOperations = new Set;
89
+ static async runOperation(operation) {
90
+ if (UploaderS3.closing)
91
+ throw new Error("S3 client is closing");
92
+ if (!UploaderS3.client)
93
+ throw new Error("S3 client is not initialized. Call `UploaderS3.init()` first.");
94
+ let complete;
95
+ const pending = new Promise((resolve) => {
96
+ complete = resolve;
97
+ });
98
+ UploaderS3.pendingOperations.add(pending);
99
+ try {
100
+ return await operation();
101
+ } finally {
102
+ UploaderS3.pendingOperations.delete(pending);
103
+ complete();
104
+ }
105
+ }
106
+ constructor(options = {}) {
107
+ this.options = options;
108
+ }
109
+ async setup(context) {
110
+ if (UploaderS3.client)
111
+ throw new Error("S3 client is already initialized");
112
+ Logger = context.logger;
113
+ await UploaderS3.init(this.options);
114
+ context.onClose(() => UploaderS3.close());
115
+ }
116
+ static close() {
117
+ if (UploaderS3.closing)
118
+ return UploaderS3.closing;
119
+ const client = UploaderS3.client;
120
+ UploaderS3.closing = (async () => {
121
+ try {
122
+ while (UploaderS3.pendingOperations.size)
123
+ await Promise.all([...UploaderS3.pendingOperations]);
124
+ const sessions = [...UploaderS3.sessions.values()];
125
+ const results = await Promise.allSettled(sessions.map((session) => client.send(new AbortMultipartUploadCommand({
126
+ Bucket: session.bucket,
127
+ Key: session.key,
128
+ UploadId: session.uploadId
129
+ }))));
130
+ const failures = results.filter((result) => result.status === "rejected");
131
+ if (failures.length)
132
+ throw new AggregateError(failures.map((result) => result.reason), "Failed to abort S3 uploads");
133
+ } finally {
134
+ try {
135
+ client?.destroy();
136
+ } finally {
137
+ UploaderS3.client = undefined;
138
+ UploaderS3.options = {};
139
+ UploaderS3.uploadSessions = {};
140
+ UploaderS3.sessions.clear();
141
+ Logger = consoleLogger;
142
+ }
143
+ }
144
+ })().finally(() => {
145
+ UploaderS3.closing = undefined;
146
+ });
147
+ return UploaderS3.closing;
148
+ }
149
+ static client;
150
+ static uploadSessions = {};
151
+ static async init(options) {
152
+ if (UploaderS3.closing)
153
+ throw new Error("S3 client is closing");
154
+ if (!UploaderS3.hasValidConfig(options))
155
+ throw new Error("Cannot initialize S3 client: endpoint, region and credentials are required");
156
+ try {
157
+ if (!UploaderS3.client) {
158
+ Logger.info("Connecting to S3 server...");
159
+ UploaderS3.client = new S3({
160
+ forcePathStyle: options.s3ForcePathStyle,
161
+ endpoint: options.s3Endpoint,
162
+ region: options.s3region,
163
+ credentials: {
164
+ accessKeyId: options.s3AccessKeyId,
165
+ secretAccessKey: options.s3SecretAccessKey
166
+ }
167
+ });
168
+ UploaderS3.options = { ...options };
169
+ Logger.info("Connected to S3 server successfully!");
170
+ }
171
+ } catch (error) {
172
+ UploaderS3.client?.destroy();
173
+ UploaderS3.client = undefined;
174
+ UploaderS3.options = {};
175
+ Logger.error(error);
176
+ throw error;
177
+ }
178
+ }
179
+ static hasValidConfig(options) {
180
+ return Boolean(options.s3AccessKeyId && options.s3Endpoint && options.s3SecretAccessKey && options.s3region);
181
+ }
182
+ static async listBuckets() {
183
+ return UploaderS3.runOperation(async () => {
184
+ if (!UploaderS3.client) {
185
+ throw new Error("S3 client is not initialized. Call `UploaderS3.init()` first.");
186
+ }
187
+ try {
188
+ Logger.info("Fetching list of buckets...");
189
+ const data = await UploaderS3.client.send(new ListBucketsCommand({}));
190
+ Logger.info("Buckets:", data.Buckets);
191
+ } catch (error) {
192
+ Logger.error(error);
193
+ throw error;
194
+ }
195
+ });
196
+ }
197
+ static async startMultipartUpload(bucketName, key) {
198
+ return UploaderS3.runOperation(async () => {
199
+ if (!UploaderS3.client) {
200
+ throw new Error("S3 client is not initialized. Call `UploaderS3.init()` first.");
201
+ }
202
+ try {
203
+ const upload = await UploaderS3.client.send(new CreateMultipartUploadCommand({
204
+ Bucket: bucketName,
205
+ ACL: "public-read-write",
206
+ Key: key
207
+ }));
208
+ Logger.info("upload", upload);
209
+ if (!upload.UploadId)
210
+ throw new Error("S3 did not return a multipart upload ID");
211
+ UploaderS3.uploadSessions[key] = upload.UploadId;
212
+ UploaderS3.sessions.set(`${bucketName}\x00${key}\x00${upload.UploadId}`, { bucket: bucketName, key, uploadId: upload.UploadId });
213
+ return upload.UploadId;
214
+ } catch (error) {
215
+ Logger.error(error);
216
+ throw error;
217
+ }
218
+ });
219
+ }
220
+ static async uploadPart(bucketName, key, uploadId, partNumber, chunk) {
221
+ return UploaderS3.runOperation(async () => {
222
+ if (!UploaderS3.client) {
223
+ throw new Error("S3 client is not initialized. Call `UploaderS3.init()` first.");
224
+ }
225
+ try {
226
+ Logger.info("Uploading part", { bucketName, partNumber });
227
+ const response = await UploaderS3.client.send(new UploadPartCommand({
228
+ Bucket: bucketName,
229
+ Key: key,
230
+ UploadId: uploadId,
231
+ PartNumber: partNumber,
232
+ Body: chunk
233
+ }));
234
+ Logger.info("Part uploaded successfully:", response);
235
+ return response.ETag ?? null;
236
+ } catch (error) {
237
+ Logger.error(error);
238
+ throw error;
239
+ }
240
+ });
241
+ }
242
+ static async completeMultipartUpload(bucketName, key, uploadId, parts) {
243
+ return UploaderS3.runOperation(async () => {
244
+ if (!UploaderS3.client) {
245
+ throw new Error("S3 client is not initialized. Call `UploaderS3.init()` first.");
246
+ }
247
+ try {
248
+ Logger.info("Completing multipart upload for key:", key);
249
+ const response = await UploaderS3.client.send(new CompleteMultipartUploadCommand({
250
+ Bucket: bucketName,
251
+ Key: key,
252
+ UploadId: uploadId,
253
+ MultipartUpload: { Parts: parts }
254
+ }));
255
+ const metadata = {
256
+ Bucket: response.Bucket,
257
+ Etag: response.ETag,
258
+ Key: response.Key,
259
+ Location: response.Location ? /^https?:\/\//.test(response.Location) ? response.Location : `https://${response.Location}` : UploaderS3.objectUrl(bucketName, key),
260
+ MimeType: getMimeType(response.Key ?? "") ?? ""
261
+ };
262
+ UploaderS3.sessions.delete(`${bucketName}\x00${key}\x00${uploadId}`);
263
+ if (UploaderS3.uploadSessions[key] === uploadId)
264
+ delete UploaderS3.uploadSessions[key];
265
+ Logger.info("Multipart upload completed successfully.");
266
+ return metadata;
267
+ } catch (error) {
268
+ Logger.error(error);
269
+ throw error;
270
+ }
271
+ });
272
+ }
273
+ static async abortMultipartUpload(bucketName, key, uploadId) {
274
+ return UploaderS3.runOperation(async () => {
275
+ if (!UploaderS3.client) {
276
+ throw new Error("S3 client is not initialized. Call `UploaderS3.init()` first.");
277
+ }
278
+ try {
279
+ await UploaderS3.client.send(new AbortMultipartUploadCommand({
280
+ Bucket: bucketName,
281
+ Key: key,
282
+ UploadId: uploadId
283
+ }));
284
+ UploaderS3.sessions.delete(`${bucketName}\x00${key}\x00${uploadId}`);
285
+ if (UploaderS3.uploadSessions[key] === uploadId)
286
+ delete UploaderS3.uploadSessions[key];
287
+ Logger.info("Multipart upload aborted successfully.");
288
+ } catch (error) {
289
+ Logger.error(error);
290
+ throw error;
291
+ }
292
+ });
293
+ }
294
+ static async uploadFile(bucketName, key, fileContent, contentType) {
295
+ return UploaderS3.runOperation(async () => {
296
+ if (!UploaderS3.client) {
297
+ throw new Error("S3 client is not initialized. Call `UploaderS3.init()` first.");
298
+ }
299
+ try {
300
+ Logger.info("Uploading file directly to S3:", { bucketName, key });
301
+ const response = await UploaderS3.client.send(new PutObjectCommand({
302
+ Bucket: bucketName,
303
+ Key: key,
304
+ Body: fileContent,
305
+ ACL: "public-read-write",
306
+ ContentType: contentType
307
+ }));
308
+ Logger.info("File uploaded successfully:", { key, response });
309
+ const metadata = {
310
+ Bucket: bucketName,
311
+ Key: key,
312
+ Etag: response.ETag,
313
+ Location: UploaderS3.objectUrl(bucketName, key),
314
+ MimeType: contentType
315
+ };
316
+ return metadata;
317
+ } catch (error) {
318
+ Logger.error(error);
319
+ throw error;
320
+ }
321
+ });
322
+ }
323
+ static async deleteIncompleteUploads(bucketName) {
324
+ return UploaderS3.runOperation(async () => {
325
+ if (!UploaderS3.client) {
326
+ throw new Error("S3 client is not initialized. Call `UploaderS3.init()` first.");
327
+ }
328
+ try {
329
+ let isTruncated = true;
330
+ let marker;
331
+ let uploadIdMarker;
332
+ while (isTruncated) {
333
+ const listUploadsResponse = await UploaderS3.client.send(new ListMultipartUploadsCommand({
334
+ Bucket: bucketName,
335
+ KeyMarker: marker,
336
+ UploadIdMarker: uploadIdMarker
337
+ }));
338
+ for (const upload of listUploadsResponse.Uploads || []) {
339
+ const uploadId = upload.UploadId;
340
+ const key = upload.Key;
341
+ await UploaderS3.client.send(new AbortMultipartUploadCommand({
342
+ Bucket: bucketName,
343
+ Key: key,
344
+ UploadId: uploadId
345
+ }));
346
+ UploaderS3.sessions.delete(`${bucketName}\x00${key}\x00${uploadId}`);
347
+ if (key && UploaderS3.uploadSessions[key] === uploadId)
348
+ delete UploaderS3.uploadSessions[key];
349
+ Logger.info(`Aborted incomplete upload: ${key} with UploadId: ${uploadId}`);
350
+ }
351
+ marker = listUploadsResponse.NextKeyMarker;
352
+ uploadIdMarker = listUploadsResponse.NextUploadIdMarker;
353
+ isTruncated = listUploadsResponse.IsTruncated ?? false;
354
+ }
355
+ Logger.info("Completed deleting all incomplete uploads.");
356
+ } catch (error) {
357
+ Logger.error(error);
358
+ throw error;
359
+ }
360
+ });
361
+ }
362
+ static objectUrl(bucket, key) {
363
+ const options = UploaderS3.options;
364
+ if (options.s3PublicUrl)
365
+ return `${options.s3PublicUrl.replace(/\/$/, "")}/${key}`;
366
+ if (process.env.S3_REGION_CODE && process.env.S3_DOMAIN)
367
+ return `https://${bucket}.${process.env.S3_REGION_CODE}.cdn.${process.env.S3_DOMAIN}.com/${key}`;
368
+ const endpoint = new URL(options.s3Endpoint);
369
+ if (options.s3ForcePathStyle)
370
+ endpoint.pathname = `${endpoint.pathname.replace(/\/$/, "")}/${bucket}/${key}`;
371
+ else {
372
+ endpoint.hostname = `${bucket}.${endpoint.hostname}`;
373
+ endpoint.pathname = `${endpoint.pathname.replace(/\/$/, "")}/${key}`;
374
+ }
375
+ return endpoint.toString();
376
+ }
377
+ static async deleteFile(request) {
378
+ return UploaderS3.runOperation(async () => {
379
+ if (!UploaderS3.client)
380
+ throw new Error("S3 client is not initialized. Call `UploaderS3.init()` first.");
381
+ try {
382
+ const headCommand = new HeadObjectCommand({ Bucket: request.Bucket, Key: request.Key });
383
+ const { VersionId } = await UploaderS3.client.send(headCommand);
384
+ const payload = { ...request, VersionId: request.VersionId ?? VersionId };
385
+ const data = await UploaderS3.client.send(new DeleteObjectCommand(payload));
386
+ Logger.info(`deleted key : ${request.Key} `);
387
+ Logger.info(`data : ${JSON.stringify(data.$metadata)}`);
388
+ } catch (error) {
389
+ Logger.error(error);
390
+ throw error;
391
+ }
392
+ });
393
+ }
394
+ }
395
+ export {
396
+ uploadPartParser,
397
+ sanitizeETag,
398
+ readAsString,
399
+ readAsBuffer,
400
+ parseXml,
401
+ mimeTypes,
402
+ getMimeType,
403
+ UploaderS3
404
+ };
@@ -0,0 +1,16 @@
1
+ import type { PluginDefinition } from '@beespoke/micro-server';
2
+ export interface S3PluginOptions {
3
+ endpoint: string;
4
+ region: string;
5
+ accessKeyId: string;
6
+ secretAccessKey: string;
7
+ forcePathStyle?: boolean;
8
+ publicUrl?: string;
9
+ }
10
+ declare module '@beespoke/micro-server' {
11
+ interface PluginOptionsRegistry {
12
+ '@beespoke/micro-server-s3': S3PluginOptions;
13
+ }
14
+ }
15
+ declare const definition: PluginDefinition<S3PluginOptions>;
16
+ export default definition;
@@ -0,0 +1,46 @@
1
+ // src/plugin/index.ts
2
+ import { UploaderS3 } from "../index.js";
3
+ var definition = {
4
+ name: "@beespoke/micro-server-s3",
5
+ validate(options) {
6
+ if (!options || typeof options !== "object" || Array.isArray(options))
7
+ throw new Error("S3 options must be an object");
8
+ const values = options;
9
+ const keys = ["endpoint", "region", "accessKeyId", "secretAccessKey", "forcePathStyle", "publicUrl"];
10
+ if (Object.keys(values).some((key) => !keys.includes(key)))
11
+ throw new Error("S3 options contain an unknown field");
12
+ for (const key of ["endpoint", "region", "accessKeyId", "secretAccessKey"]) {
13
+ if (typeof values[key] !== "string" || !values[key].trim())
14
+ throw new Error(`S3 ${key} must be a nonempty string`);
15
+ }
16
+ for (const key of ["endpoint", "publicUrl"]) {
17
+ if (values[key] === undefined)
18
+ continue;
19
+ try {
20
+ if (typeof values[key] !== "string")
21
+ throw new Error;
22
+ const url = new URL(values[key]);
23
+ if (!["http:", "https:"].includes(url.protocol) || !url.hostname)
24
+ throw new Error;
25
+ } catch {
26
+ throw new Error(`S3 ${key} must be an HTTP or HTTPS URL`);
27
+ }
28
+ }
29
+ if (values.forcePathStyle !== undefined && typeof values.forcePathStyle !== "boolean")
30
+ throw new Error("S3 forcePathStyle must be a boolean");
31
+ },
32
+ create(options) {
33
+ return new UploaderS3({
34
+ s3Endpoint: options.endpoint,
35
+ s3region: options.region,
36
+ s3AccessKeyId: options.accessKeyId,
37
+ s3SecretAccessKey: options.secretAccessKey,
38
+ s3ForcePathStyle: options.forcePathStyle,
39
+ s3PublicUrl: options.publicUrl
40
+ });
41
+ }
42
+ };
43
+ var plugin_default = definition;
44
+ export {
45
+ plugin_default as default
46
+ };
@@ -0,0 +1,18 @@
1
+ export interface Logger {
2
+ info(...args: unknown[]): void;
3
+ warn(...args: unknown[]): void;
4
+ error(...args: unknown[]): void;
5
+ debug(...args: unknown[]): void;
6
+ }
7
+ export type LifecycleHook = () => void | Promise<void>;
8
+ export interface PluginContext {
9
+ readonly logger: Logger;
10
+ readonly configDir: string;
11
+ onStart(hook: LifecycleHook): void;
12
+ onClose(hook: LifecycleHook): void;
13
+ }
14
+ export interface ServerPlugin {
15
+ readonly name: string;
16
+ setup(context: PluginContext): void | Promise<void>;
17
+ }
18
+ export declare const consoleLogger: Logger;
@@ -0,0 +1,22 @@
1
+ export type AttachmentMetadataType = {
2
+ Bucket?: string;
3
+ Etag?: string;
4
+ Key?: string;
5
+ Location?: string;
6
+ MimeType?: string;
7
+ Thumbnail?: string;
8
+ isHls?: boolean;
9
+ };
10
+ export type DeleteObjectRequest = {
11
+ Bucket: string;
12
+ Key: string;
13
+ MFA?: string;
14
+ VersionId?: string;
15
+ RequestPayer?: 'requester';
16
+ BypassGovernanceRetention?: boolean;
17
+ ExpectedBucketOwner?: string;
18
+ IfMatch?: string;
19
+ IfMatchLastModifiedTime?: Date;
20
+ IfMatchSize?: number;
21
+ };
22
+ export declare const mimeTypes: Record<string, string>;
@@ -0,0 +1,11 @@
1
+ export interface S3Options {
2
+ s3ForcePathStyle?: boolean;
3
+ s3Endpoint?: string;
4
+ s3region?: string;
5
+ s3AccessKeyId?: string;
6
+ s3SecretAccessKey?: string;
7
+ /** Optional public base URL; object keys are appended to it. */
8
+ s3PublicUrl?: string;
9
+ }
10
+ /** The S3-owned subset of the legacy application options. */
11
+ export type ApplicationOptionType = S3Options;
@@ -0,0 +1,79 @@
1
+ import type { S3Options as ApplicationOptionType } from "../types/S3Options.js";
2
+ import type { PluginContext, ServerPlugin } from "../support.js";
3
+ import { S3 } from "@aws-sdk/client-s3";
4
+ import type { AttachmentMetadataType, DeleteObjectRequest } from "../types/AttachmentMetadataTypes.js";
5
+ export default class UploaderS3 implements ServerPlugin {
6
+ private readonly options;
7
+ readonly name = "s3";
8
+ private static options;
9
+ private static sessions;
10
+ private static closing;
11
+ private static readonly pendingOperations;
12
+ /** Reserve before invoking the operation so close can drain every accepted call. */
13
+ private static runOperation;
14
+ constructor(options?: ApplicationOptionType);
15
+ setup(context: PluginContext): Promise<void>;
16
+ static close(): Promise<void>;
17
+ static client: S3;
18
+ static uploadSessions: Record<string, string>;
19
+ /**
20
+ * Initializes the S3 client instance
21
+ */
22
+ static init(options: ApplicationOptionType): Promise<void>;
23
+ /**
24
+ * Validates configuration options
25
+ */
26
+ static hasValidConfig(options: ApplicationOptionType): boolean;
27
+ /**
28
+ * Lists all available S3 buckets
29
+ */
30
+ static listBuckets(): Promise<void>;
31
+ /**
32
+ * Starts a new multipart upload session
33
+ * @param bucketName S3 bucket name
34
+ * @param key Key for the object in S3
35
+ */
36
+ static startMultipartUpload(bucketName: string, key: string): Promise<string>;
37
+ /**
38
+ * Uploads a file part to S3
39
+ * @param bucketName S3 bucket name
40
+ * @param key Key for the object in S3
41
+ * @param uploadId Multipart upload session ID
42
+ * @param partNumber Part number for the upload
43
+ * @param chunk Chunk of the file to upload
44
+ */
45
+ static uploadPart(bucketName: string, key: string, uploadId: string, partNumber: number, chunk: Buffer): Promise<string | null>;
46
+ /**
47
+ * Completes a multipart upload
48
+ * @param bucketName S3 bucket name
49
+ * @param key Key for the object in S3
50
+ * @param uploadId Multipart upload session ID
51
+ * @param parts List of uploaded parts
52
+ */
53
+ static completeMultipartUpload(bucketName: string, key: string, uploadId: string, parts: {
54
+ PartNumber: number;
55
+ ETag: string;
56
+ }[]): Promise<AttachmentMetadataType>;
57
+ /**
58
+ * Aborts a multipart upload
59
+ * @param bucketName S3 bucket name
60
+ * @param key Key for the object in S3
61
+ * @param uploadId Multipart upload session ID
62
+ */
63
+ static abortMultipartUpload(bucketName: string, key: string, uploadId: string): Promise<void>;
64
+ /**
65
+ * Directly uploads a file to S3 without chunking
66
+ * @param bucketName S3 bucket name
67
+ * @param key Key for the object in S3
68
+ * @param fileContent The content of the file to upload
69
+ * @param contentType The MIME type of the file
70
+ */
71
+ static uploadFile(bucketName: string, key: string, fileContent: Buffer | Uint8Array | Blob | string, contentType: string): Promise<AttachmentMetadataType>;
72
+ /**
73
+ * Deletes all incomplete multipart uploads in the specified bucket
74
+ * @param bucketName S3 bucket name
75
+ */
76
+ static deleteIncompleteUploads(bucketName: string): Promise<void>;
77
+ private static objectUrl;
78
+ static deleteFile(request: DeleteObjectRequest): Promise<void>;
79
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Retrieves the MIME type for a given file name based on its extension.
3
+ * @param fileName - The name of the file to extract the MIME type for.
4
+ * @returns The MIME type if found, or null if not found.
5
+ */
6
+ export declare const getMimeType: (fileName: string) => string | null;
7
+ /**
8
+ * Consumes a readable stream and aggregates all data chunks into a single Buffer.
9
+ * * @param {stream.Readable} res - The incoming data stream.
10
+ * @returns {Promise<Buffer>} A promise that resolves to the complete binary data.
11
+ */
12
+ export declare function readAsBuffer(res: any): Promise<Buffer>;
13
+ /**
14
+ * Reads an HTTP response body in its entirety and converts it to a string.
15
+ * Useful for parsing XML error messages or small metadata responses.
16
+ * * @param {http.IncomingMessage} res - The HTTP response object.
17
+ * @returns {Promise<string>} The body content decoded as a string.
18
+ */
19
+ export declare function readAsString(res: any): Promise<string>;
20
+ /**
21
+ * Parses an XML string into a JavaScript object using fast-xml-parser.
22
+ * * @throws {S3XmlError} If the XML contains an <Error> tag, it throws the error body.
23
+ */
24
+ export declare const parseXml: (xml: string) => any;
25
+ /**
26
+ * Specifically extracts the CopyPartResult from an S3 XML response.
27
+ * Used during multipart copy operations or part uploads that return XML.
28
+ */
29
+ export declare const uploadPartParser: (xml: string) => any;
30
+ /**
31
+ * Removes various encodings of double quotes from the start and end of an ETag.
32
+ * * @description
33
+ * S3-compatible storage often returns ETags wrapped in literal quotes or
34
+ * HTML entities. This regex-based sanitizer ensures only the raw hash remains.
35
+ * @param {string} [etag=''] - The raw ETag string from headers or XML.
36
+ * @returns {string} The cleaned ETag.
37
+ */
38
+ export declare const sanitizeETag: (etag: string) => string;
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@beespoke/micro-server-s3",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.3",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./plugin": {
13
+ "types": "./dist/plugin/index.d.ts",
14
+ "import": "./dist/plugin/index.js",
15
+ "default": "./dist/plugin/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "bun ../../scripts/build-package.ts",
23
+ "test": "bun test tests"
24
+ },
25
+ "dependencies": {
26
+ "@aws-sdk/client-s3": "^3.699.0",
27
+ "fast-xml-parser": "^5.3.4",
28
+ "@types/node": "^24.0.0"
29
+ },
30
+ "peerDependencies": {
31
+ "@beespoke/micro-server": "^2.0.3"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "@beespoke/micro-server": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "license": "MIT"
39
+ }