@beespoke/micro-server-minio 0.0.0-stage → 2.0.5

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,72 @@
1
- # Temporary Holding Version
1
+ # @beespoke/micro-server-minio
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
+ MinIO object storage, uploads, multipart sessions, downloads, and cleanup.
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-minio
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-minio'],
22
+ pluginOptions: {
23
+ '@beespoke/micro-server-minio': {
24
+ host: process.env.MINIO_HOST!,
25
+ port: Number(process.env.MINIO_PORT || 9000),
26
+ useSSL: false,
27
+ accessKey: process.env.MINIO_ACCESS_KEY!,
28
+ secretKey: process.env.MINIO_SECRET_KEY!,
29
+ },
30
+ },
31
+ });
32
+ ```
33
+
34
+ Requires a reachable MinIO server. `host` is a hostname, not a URL. Choose `useSSL` and `port` to match your server. Optional `region` selects the storage region. Buckets are supplied to storage operations, not to plugin configuration.
35
+
36
+ ## Storage API
37
+
38
+ The root exports `Uploader` and upload/session types. After bootstrap, use `Uploader.upload(bucket, directory, fileOrFiles)`, `Uploader.delete(bucket, objectName)`, and the multipart methods `initiateUpload`, `uploadChunk`, and `completeUpload`. `streamDownload` writes a download to an HTTP response. Storage input types are exported for application adapters.
39
+
40
+ For direct initialization, use `await Uploader.init({ minioHost, minioPort, minioUseSSL, minioAccessKey, minioSecretKey })` and `await Uploader.close()`. These direct option names differ from the shorter plugin option names above. Shutdown drains tracked operations and aborts unfinished multipart sessions.
41
+
42
+ ## Start the application
43
+
44
+ Save the configuration as `micro-server.config.ts` next to your application's `package.json`, then create `index.ts`:
45
+
46
+ ```ts
47
+ import { ApplicationServer } from '@beespoke/micro-server';
48
+
49
+ await ApplicationServer.bootstrap();
50
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
51
+ process.once(signal, () => {
52
+ void ApplicationServer.close().catch(error => {
53
+ console.error(error);
54
+ process.exitCode = 1;
55
+ });
56
+ });
57
+ }
58
+ ```
59
+
60
+ 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.
61
+
62
+ ## TypeScript and lifecycle
63
+
64
+ Add this type-only import to an application-owned `plugin-types.d.ts` for configuration autocomplete:
65
+
66
+ ```ts
67
+ import type {} from '@beespoke/micro-server-minio/plugin';
68
+ ```
69
+
70
+ 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.
71
+
72
+ 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,3 @@
1
+ export { default as Uploader } from './utils/Uploader.js';
2
+ export type { MinioOptions, ApplicationOptionType } from './types/MinioOptions.js';
3
+ export type { UploadedFile, UploadedFileType, InitUpload, UploadPartConfig, UploadSession, UploadChunk, CompleteUploadResult, UploadTypes, UploadPart } from './types/UploadedFileType.js';
package/dist/index.js ADDED
@@ -0,0 +1,384 @@
1
+ // src/utils/Uploader.ts
2
+ import { Agent as HttpAgent } from "node:http";
3
+ import { Agent as HttpsAgent } from "node:https";
4
+ import { finished } from "node:stream";
5
+
6
+ // src/support.ts
7
+ var consoleLogger = {
8
+ info: (...args) => console.info(...args),
9
+ warn: (...args) => console.warn(...args),
10
+ error: (...args) => console.error(...args),
11
+ debug: (...args) => console.debug(...args)
12
+ };
13
+
14
+ // src/utils/Uploader.ts
15
+ import * as minio from "minio";
16
+
17
+ // src/utils/multipart.ts
18
+ async function readAsString(response) {
19
+ const chunks = [];
20
+ for await (const chunk of response)
21
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
22
+ return Buffer.concat(chunks).toString();
23
+ }
24
+ function sanitizeETag(etag) {
25
+ return etag.replace(/^(?:"|"|")|(?:"|"|")$/g, "");
26
+ }
27
+ function uploadPartParser(body) {
28
+ if (/<(?:\w+:)?Error(?:\s|>)/.test(body))
29
+ throw new Error("MinIO multipart upload returned an XML error");
30
+ const etag = body.match(/<(?:\w+:)?ETag>([^<]*)<\/(?:\w+:)?ETag>/)?.[1];
31
+ if (!etag)
32
+ throw new Error("No ETag found in multipart upload response");
33
+ return { ETag: etag };
34
+ }
35
+
36
+ // src/utils/Uploader.ts
37
+ var Logger = consoleLogger;
38
+ var generateUploadId = () => Date.now().toString(16) + Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join("");
39
+ var uploadSessions = new Map;
40
+
41
+ class Uploader {
42
+ options;
43
+ name = "minio";
44
+ static agent;
45
+ static closing;
46
+ static pendingOperations = new Set;
47
+ static async runOperation(operation) {
48
+ if (Uploader.closing)
49
+ throw new Error("MinIO client is closing");
50
+ if (!Uploader.client)
51
+ throw new Error("MinIO client is not initialized. Call `Uploader.init()` first.");
52
+ let complete;
53
+ const pending = new Promise((resolve) => {
54
+ complete = resolve;
55
+ });
56
+ Uploader.pendingOperations.add(pending);
57
+ try {
58
+ return await operation();
59
+ } finally {
60
+ Uploader.pendingOperations.delete(pending);
61
+ complete();
62
+ }
63
+ }
64
+ static retainStream(stream) {
65
+ let complete;
66
+ const pending = new Promise((resolve) => {
67
+ complete = resolve;
68
+ });
69
+ Uploader.pendingOperations.add(pending);
70
+ const cleanup = finished(stream, () => {
71
+ cleanup();
72
+ Uploader.pendingOperations.delete(pending);
73
+ complete();
74
+ });
75
+ }
76
+ static client;
77
+ static clientEndpoint;
78
+ constructor(options = {}) {
79
+ this.options = options;
80
+ }
81
+ async setup(context) {
82
+ if (Uploader.client)
83
+ throw new Error("MinIO client is already initialized");
84
+ Logger = context.logger;
85
+ await Uploader.init(this.options);
86
+ context.onClose(() => Uploader.close());
87
+ }
88
+ static close() {
89
+ if (Uploader.closing)
90
+ return Uploader.closing;
91
+ const client = Uploader.client;
92
+ const agent = Uploader.agent;
93
+ Uploader.closing = (async () => {
94
+ try {
95
+ while (Uploader.pendingOperations.size)
96
+ await Promise.all([...Uploader.pendingOperations]);
97
+ const sessions = [...uploadSessions.values()];
98
+ const results = await Promise.allSettled(sessions.map((session) => client.abortMultipartUpload(session.bucket, session.fileName, session.uploadId)));
99
+ const failures = results.filter((result) => result.status === "rejected");
100
+ if (failures.length)
101
+ throw new AggregateError(failures.map((result) => result.reason), "Failed to abort MinIO uploads");
102
+ } finally {
103
+ try {
104
+ agent?.destroy();
105
+ } finally {
106
+ Uploader.client = undefined;
107
+ Uploader.clientEndpoint = undefined;
108
+ Uploader.agent = undefined;
109
+ uploadSessions.clear();
110
+ Logger = consoleLogger;
111
+ }
112
+ }
113
+ })().finally(() => {
114
+ Uploader.closing = undefined;
115
+ });
116
+ return Uploader.closing;
117
+ }
118
+ static async init(options) {
119
+ if (Uploader.closing)
120
+ throw new Error("MinIO client is closing");
121
+ try {
122
+ if (!Uploader.client && Uploader.hasValidConfig(options)) {
123
+ Logger.info("Connecting to minIO server...");
124
+ Uploader.agent = options.minioUseSSL !== false ? new HttpsAgent({ keepAlive: true }) : new HttpAgent({ keepAlive: true });
125
+ Uploader.client = new minio.Client({
126
+ transportAgent: Uploader.agent,
127
+ endPoint: options.minioHost,
128
+ port: options.minioPort,
129
+ useSSL: options.minioUseSSL,
130
+ accessKey: options.minioAccessKey,
131
+ secretKey: options.minioSecretKey,
132
+ region: options.minioRegion
133
+ });
134
+ this.clientEndpoint = options.minioHost;
135
+ Logger.info("Connecting to minIO server... SUCCESS!");
136
+ }
137
+ } catch (error) {
138
+ Uploader.agent?.destroy();
139
+ Uploader.agent = undefined;
140
+ Uploader.client = undefined;
141
+ Logger.error(new Error("Connecting to minIO server... ERROR!"));
142
+ throw error;
143
+ }
144
+ }
145
+ static hasValidConfig(options) {
146
+ if (options.minioHost && options.minioHost && options.minioAccessKey && options.minioSecretKey) {
147
+ return true;
148
+ } else {
149
+ throw new Error("Cannot initialize MinIO Client. Check your .env file");
150
+ }
151
+ }
152
+ static async upload(bucket, dir, uploads) {
153
+ return Uploader.runOperation(async () => {
154
+ try {
155
+ let files = [];
156
+ const isArray = Array.isArray(uploads);
157
+ if (isArray) {
158
+ files = uploads;
159
+ } else {
160
+ files.push(uploads);
161
+ }
162
+ let response = [];
163
+ for (let index = 0;index < files.length; index++) {
164
+ const file = files[index];
165
+ let filename = file.name.replace(/ /g, "_");
166
+ let newName = new Date;
167
+ let path = `/${bucket}/${dir}/${newName.getTime()}`;
168
+ let etag = await Uploader.client.putObject(bucket, `${dir}/${newName.getTime()}`, file.data, file.size, { "Content-type": file.mimetype });
169
+ response.push({
170
+ etag,
171
+ path,
172
+ bucket,
173
+ directory: dir,
174
+ objectName: filename,
175
+ mimeType: file.mimetype,
176
+ location: `https://${process.env.MINIO_ENDPOINT || Uploader.clientEndpoint}${path}`,
177
+ filesize: file.size
178
+ });
179
+ }
180
+ return isArray ? response : response[0];
181
+ } catch (error) {
182
+ throw error;
183
+ }
184
+ });
185
+ }
186
+ async preview(bucket, file) {
187
+ return Uploader.runOperation(async () => {
188
+ try {
189
+ return await Uploader.client.presignedUrl("GET", bucket, file, 24 * 60 * 60);
190
+ } catch (error) {
191
+ throw error;
192
+ }
193
+ });
194
+ }
195
+ static async delete(bucketName, objectName, removeOpts) {
196
+ return Uploader.runOperation(async () => {
197
+ try {
198
+ const result = await Uploader.client.removeObject(bucketName, objectName, removeOpts);
199
+ return result;
200
+ } catch (error) {
201
+ throw error;
202
+ }
203
+ });
204
+ }
205
+ static async clearAllMultipartUploads(bucketName) {
206
+ return Uploader.runOperation(async () => {
207
+ return new Promise((resolve, reject) => {
208
+ const stream = Uploader.client.listIncompleteUploads(bucketName, "", true);
209
+ const pending = [];
210
+ const finish = async (streamError) => {
211
+ const results = await Promise.all(pending);
212
+ const errors = results.filter((result) => ("error" in result)).map((result) => result.error);
213
+ if (streamError)
214
+ errors.unshift(streamError);
215
+ if (errors.length)
216
+ reject(new AggregateError(errors, "Failed to clear MinIO multipart uploads"));
217
+ else
218
+ resolve();
219
+ };
220
+ stream.on("data", (upload) => {
221
+ pending.push(Promise.resolve().then(() => Uploader.client.abortMultipartUpload(bucketName, upload.key, upload.uploadId)).then(() => {
222
+ for (const [id, session] of uploadSessions) {
223
+ if (session.bucket === bucketName && session.fileName === upload.key && session.uploadId === upload.uploadId)
224
+ uploadSessions.delete(id);
225
+ }
226
+ return {};
227
+ }, (error) => ({ error })));
228
+ });
229
+ stream.on("end", () => {
230
+ finish();
231
+ });
232
+ stream.on("error", (error) => {
233
+ finish(error);
234
+ });
235
+ });
236
+ });
237
+ }
238
+ static async initiateUpload(bucket, fileName, metadata = {}) {
239
+ return Uploader.runOperation(async () => {
240
+ try {
241
+ const uploadId = generateUploadId();
242
+ const pathName = `stream/${uploadId}`;
243
+ const uploadInfo = await Uploader.client.initiateNewMultipartUpload(bucket, pathName, metadata);
244
+ uploadSessions.set(uploadId, {
245
+ bucket,
246
+ uploadId: uploadInfo,
247
+ minioUploadId: uploadInfo,
248
+ fileName: pathName,
249
+ objectName: fileName,
250
+ parts: [],
251
+ metadata,
252
+ createdAt: new Date
253
+ });
254
+ return {
255
+ uploadId,
256
+ minioUploadId: uploadInfo,
257
+ pathName,
258
+ message: "Upload session initialized"
259
+ };
260
+ } catch (error) {
261
+ throw error;
262
+ }
263
+ });
264
+ }
265
+ static async uploadChunk(uploadSessionId, chunkNumber, chunkData) {
266
+ return Uploader.runOperation(async () => {
267
+ try {
268
+ const session = uploadSessions.get(uploadSessionId);
269
+ if (!session)
270
+ throw new Error(`Upload session ${uploadSessionId} not found`);
271
+ let binaryData;
272
+ if (typeof chunkData === "string")
273
+ binaryData = Buffer.from(chunkData, "binary");
274
+ else if (chunkData instanceof Buffer)
275
+ binaryData = chunkData;
276
+ else
277
+ binaryData = Buffer.from(chunkData);
278
+ const partConfig = {
279
+ bucketName: session.bucket,
280
+ objectName: session.fileName,
281
+ uploadID: session.uploadId,
282
+ partNumber: chunkNumber,
283
+ headers: {
284
+ "Content-Type": "application/octet-stream",
285
+ "Content-Length": binaryData.length.toString()
286
+ }
287
+ };
288
+ const result = await this.uploadPart(partConfig, binaryData);
289
+ session.parts = session.parts.filter((part) => part.part !== chunkNumber);
290
+ session.parts.push({ part: chunkNumber, etag: result.etag });
291
+ return {
292
+ uploadId: uploadSessionId,
293
+ chunkNumber,
294
+ etag: result.etag,
295
+ key: result.key,
296
+ part: result.part,
297
+ uploadedPart: session.parts.length,
298
+ message: `Chunk ${chunkNumber} uploaded successfully`
299
+ };
300
+ } catch (error) {
301
+ Logger.error(error);
302
+ throw error;
303
+ }
304
+ });
305
+ }
306
+ static async completeUpload(uploadSessionId) {
307
+ return Uploader.runOperation(async () => {
308
+ try {
309
+ const session = uploadSessions.get(uploadSessionId);
310
+ if (!session)
311
+ throw new Error("Upload session not found");
312
+ const sortedParts = session.parts.sort((a, b) => a.part - b.part);
313
+ const result = await Uploader.client.completeMultipartUpload(session.bucket, session.fileName, session.uploadId, sortedParts);
314
+ const currateResult = {
315
+ etag: result,
316
+ path: session.fileName,
317
+ bucket: session.bucket,
318
+ location: `https://${this.clientEndpoint}/${session.bucket}/${session.fileName}`,
319
+ fileSize: !isNaN(Number(session.metadata?.["X-Amz-meta-filesize"])) ? Number(session.metadata["X-Amz-meta-filesize"]) : 0,
320
+ mimeType: session.metadata["Content-Type"] || "application/octet-stream",
321
+ objectName: session.objectName
322
+ };
323
+ uploadSessions.delete(uploadSessionId);
324
+ return currateResult;
325
+ } catch (error) {
326
+ console.error("Error completing upload:", error);
327
+ throw error;
328
+ }
329
+ });
330
+ }
331
+ static async streamDownload(res, { bucket, objectKey, objectName }) {
332
+ return Uploader.runOperation(async () => {
333
+ try {
334
+ const stat = await Uploader.client.statObject(bucket, objectKey);
335
+ res.setHeader("Content-Disposition", `attachment; filename="${objectName}"`);
336
+ res.setHeader("Content-Type", stat.metaData["content-type"] || "application/octet-stream");
337
+ res.setHeader("Content-Length", stat.size);
338
+ const stream = await Uploader.client.getObject(bucket, objectKey);
339
+ Uploader.retainStream(stream);
340
+ stream.on("error", (err) => {
341
+ Logger.error(err);
342
+ if (!res.headersSent) {
343
+ res.status(500).json({ error: "Download failed" });
344
+ }
345
+ });
346
+ try {
347
+ stream.pipe(res);
348
+ } catch (error) {
349
+ stream.destroy();
350
+ throw error;
351
+ }
352
+ } catch (error) {
353
+ throw error;
354
+ }
355
+ });
356
+ }
357
+ static uploadPart = async (partConfig, payload) => {
358
+ return Uploader.runOperation(async () => {
359
+ const { bucketName, objectName, uploadID, partNumber, headers } = partConfig;
360
+ const method = "PUT";
361
+ const query = `uploadId=${encodeURIComponent(uploadID)}&partNumber=${partNumber}`;
362
+ const requestOptions = { method, bucketName, objectName, query, headers };
363
+ const res = await Uploader.client.makeRequestAsync(requestOptions, payload);
364
+ const body = await readAsString(res);
365
+ let partRes;
366
+ if (body && body.trim())
367
+ partRes = uploadPartParser(body);
368
+ else if (res.headers && res.headers.etag)
369
+ partRes = { ETag: res.headers.etag };
370
+ else
371
+ throw new Error("No ETag found in response body or headers");
372
+ if (!partRes?.ETag)
373
+ throw new Error("No ETag found in response body or headers");
374
+ return {
375
+ etag: sanitizeETag(partRes.ETag),
376
+ key: objectName,
377
+ part: partNumber
378
+ };
379
+ });
380
+ };
381
+ }
382
+ export {
383
+ Uploader
384
+ };
@@ -0,0 +1,16 @@
1
+ import type { PluginDefinition } from '@beespoke/micro-server';
2
+ export interface MinioPluginOptions {
3
+ host: string;
4
+ port?: number;
5
+ useSSL?: boolean;
6
+ accessKey: string;
7
+ secretKey: string;
8
+ region?: string;
9
+ }
10
+ declare module '@beespoke/micro-server' {
11
+ interface PluginOptionsRegistry {
12
+ '@beespoke/micro-server-minio': MinioPluginOptions;
13
+ }
14
+ }
15
+ declare const definition: PluginDefinition<MinioPluginOptions>;
16
+ export default definition;
@@ -0,0 +1,37 @@
1
+ // src/plugin/index.ts
2
+ import { Uploader } from "../index.js";
3
+ var definition = {
4
+ name: "@beespoke/micro-server-minio",
5
+ validate(options) {
6
+ if (!options || typeof options !== "object" || Array.isArray(options))
7
+ throw new Error("MinIO options must be an object");
8
+ const values = options;
9
+ const keys = ["host", "port", "useSSL", "accessKey", "secretKey", "region"];
10
+ if (Object.keys(values).some((key) => !keys.includes(key)))
11
+ throw new Error("MinIO options contain an unknown field");
12
+ for (const key of ["host", "accessKey", "secretKey"]) {
13
+ if (typeof values[key] !== "string" || !values[key].trim())
14
+ throw new Error(`MinIO ${key} must be a nonempty string`);
15
+ }
16
+ if (values.region !== undefined && (typeof values.region !== "string" || !values.region.trim()))
17
+ throw new Error("MinIO region must be a nonempty string");
18
+ if (values.port !== undefined && (!Number.isInteger(values.port) || values.port < 1 || values.port > 65535))
19
+ throw new Error("MinIO port must be an integer between 1 and 65535");
20
+ if (values.useSSL !== undefined && typeof values.useSSL !== "boolean")
21
+ throw new Error("MinIO useSSL must be a boolean");
22
+ },
23
+ create(options) {
24
+ return new Uploader({
25
+ minioHost: options.host,
26
+ minioPort: options.port,
27
+ minioUseSSL: options.useSSL,
28
+ minioAccessKey: options.accessKey,
29
+ minioSecretKey: options.secretKey,
30
+ minioRegion: options.region
31
+ });
32
+ }
33
+ };
34
+ var plugin_default = definition;
35
+ export {
36
+ plugin_default as default
37
+ };
@@ -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,10 @@
1
+ export interface MinioOptions {
2
+ minioHost?: string;
3
+ minioPort?: number;
4
+ minioAccessKey?: string;
5
+ minioSecretKey?: string;
6
+ minioUseSSL?: boolean;
7
+ minioRegion?: string;
8
+ }
9
+ /** The MinIO-owned subset of the legacy application options. */
10
+ export type ApplicationOptionType = MinioOptions;
@@ -0,0 +1,74 @@
1
+ export type UploadedFileType = {
2
+ etag?: string;
3
+ path?: string;
4
+ bucket?: string;
5
+ directory?: string;
6
+ objectName?: string;
7
+ mimeType?: string;
8
+ location?: string;
9
+ filesize?: number;
10
+ };
11
+ export type InitUpload = {
12
+ uploadId: string;
13
+ minioUploadId: string;
14
+ pathName: string;
15
+ message: string;
16
+ };
17
+ export type UploadPartConfig = {
18
+ bucketName: string;
19
+ objectName: string;
20
+ uploadID: string;
21
+ partNumber: number;
22
+ headers: unknown;
23
+ };
24
+ export type UploadSession = {
25
+ bucket: string;
26
+ uploadId: string;
27
+ fileName: string;
28
+ objectName: string;
29
+ minioUploadId: string;
30
+ parts: Array<{
31
+ part: number;
32
+ etag: string;
33
+ }>;
34
+ metadata?: Record<string, string>;
35
+ createdAt: Date;
36
+ };
37
+ export type UploadChunk = {
38
+ uploadId: string;
39
+ chunkNumber: number;
40
+ etag: string;
41
+ key: string;
42
+ part: number;
43
+ uploadedPart: number;
44
+ message: string;
45
+ };
46
+ export type CompleteUploadResult = {
47
+ etag: {
48
+ versionId: string;
49
+ etag: string;
50
+ };
51
+ path: string;
52
+ bucket: string;
53
+ location: string;
54
+ fileSize: number;
55
+ mimeType: string;
56
+ objectName: string;
57
+ };
58
+ export type UploadTypes = {
59
+ bucket: string;
60
+ objectKey: string;
61
+ objectName: string;
62
+ };
63
+ export type UploadPart = {
64
+ etag: string;
65
+ key: string;
66
+ part: number;
67
+ };
68
+ /** Minimal buffered file input; compatible with Express upload middleware. */
69
+ export type UploadedFile = {
70
+ name: string;
71
+ data: Buffer;
72
+ size: number;
73
+ mimetype: string;
74
+ };
@@ -0,0 +1,137 @@
1
+ import type { PluginContext, ServerPlugin } from '../support.js';
2
+ import type { MinioOptions as ApplicationOptionType } from '../types/MinioOptions.js';
3
+ import * as minio from 'minio';
4
+ import type { UploadedFile, UploadedFileType, InitUpload, UploadChunk, CompleteUploadResult, UploadTypes, UploadPart } from '../types/UploadedFileType.js';
5
+ import type { RemoveOptions } from 'minio';
6
+ export default class Uploader implements ServerPlugin {
7
+ private readonly options;
8
+ readonly name = "minio";
9
+ private static agent;
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
+ /** A piped download remains active after streamDownload has returned. */
15
+ private static retainStream;
16
+ static client: minio.Client;
17
+ static clientEndpoint: string;
18
+ constructor(options?: ApplicationOptionType);
19
+ setup(context: PluginContext): Promise<void>;
20
+ /** Abort locally tracked uploads and release only this client's HTTP agent. */
21
+ static close(): Promise<void>;
22
+ /**
23
+ * Initializes the minIO client instance
24
+ */
25
+ static init(options: ApplicationOptionType): Promise<void>;
26
+ /**
27
+ * checks .env variables if properly configured
28
+ */
29
+ static hasValidConfig(options: ApplicationOptionType): boolean;
30
+ /**
31
+ *
32
+ * @param bucket {string} The bucket name in minIO
33
+ * @param dir {string} The directory where the files are stored
34
+ * @param uploads {Array<UploadedFile> | UploadedFile} file(s) to be uploaded
35
+ * @returns
36
+ */
37
+ static upload(bucket: string, dir: string, uploads: Array<UploadedFile> | UploadedFile): Promise<Array<UploadedFileType> | UploadedFileType>;
38
+ /**
39
+ *
40
+ * @param bucket {string} bucket name
41
+ * @param file {string} file directory
42
+ */
43
+ preview(bucket: string, file: string): Promise<string>;
44
+ static delete(bucketName: string, objectName: string, removeOpts?: RemoveOptions): Promise<void>;
45
+ /**
46
+ * Permanently cancels all pending multipart uploads within a specified S3/MinIO bucket.
47
+ * @description
48
+ * This is a cleanup utility. Incomplete multipart uploads occupy storage space but are not visible as objects. This method iterates through the bucket's "incomplete" stream and issues an abort command for each. Use this to recover storage or clear stuck uploads after a service interruption.
49
+ * @param {string} bucketName - The unique identifier of the target MinIO bucket.
50
+ * @returns {Promise<void>} Resolves when the stream has finished processing all abort requests.
51
+ * @throws {Error} If the bucket is inaccessible or the list operation fails.
52
+ * @example
53
+ * await Uploader.clearAllMultipartUploads('temp-uploads');
54
+ */
55
+ static clearAllMultipartUploads(bucketName: string): Promise<void>;
56
+ /**
57
+ * Starts a new multipart upload session and registers it in the local session cache.
58
+ * @description
59
+ * This method performs two actions:
60
+ * 1. Requests a new Multipart Upload ID from the MinIO/S3 server.
61
+ * 2. Initializes an internal tracking session in `uploadSessions` for part management.
62
+ * @param {string} bucket - Target bucket name.
63
+ * @param {string} fileName - The final destination filename/path.
64
+ * @param {Record<string, string>} [metadata={}] - Key-value pairs for S3 metadata.
65
+ * - Standard headers: `Content-Type`, `Cache-Control`, etc.
66
+ * - Custom tags: Use `X-Amz-meta-` prefix for custom attributes.
67
+ * @returns {Promise<InitUpload>}
68
+ * Returns the internal session ID and the provider's upload ID.
69
+ * @throws {Error} If the connection to MinIO fails or the bucket does not exist.
70
+ */
71
+ static initiateUpload(bucket: string, fileName: string, metadata?: Record<string, string>): Promise<InitUpload>;
72
+ /**
73
+ * Processes and uploads a single data part to an active multipart session.
74
+ * @description
75
+ * This method normalizes various input formats (Buffer, string, or Uint8Array) into a binary Buffer, dispatches the upload to MinIO via `uploadPart`, and updates the internal session state with the returned ETag.
76
+ * @param {string} uploadSessionId - The internal UUID generated during `initiateUpload`.
77
+ * @param {number} chunkNumber - The 1-based index of the part.
78
+ * @param {Buffer | string | Uint8Array} chunkData - The raw binary data or encoded string.
79
+ * @returns {Promise<UploadChunk>} Returns metadata about the uploaded part, including:
80
+ * - `etag`: The S3 identifier for this specific part (required for completion).
81
+ * - `uploadedPart`: The total count of parts successfully tracked in this session so far.
82
+ */
83
+ static uploadChunk(uploadSessionId: string, chunkNumber: number, chunkData: Buffer | string | Uint8Array): Promise<UploadChunk>;
84
+ /**
85
+ * Finalizes the multipart upload by assembling all uploaded parts into a single object.
86
+ * @description
87
+ * This method performs the final handshake with MinIO/S3. It:
88
+ * 1. Retrieves and sorts the accumulated ETags by part number (mandatory for S3 compliance).
89
+ * 2. Signals the storage server to merge the data parts.
90
+ * 3. Extracts file metadata (size, mime-type) from the session.
91
+ * 4. Purges the internal `uploadSessions` cache to prevent memory leaks.
92
+ * @param {string} uploadSessionId - The internal tracking ID of the session to be closed.
93
+ * @returns {Promise<Object>} A curated result object containing:
94
+ * - `etag`: The final entity tag for the completed object.
95
+ * - `location`: The public/internal URL of the uploaded file.
96
+ * - `fileSize`: Parsed size from the 'X-Amz-meta-filesize' metadata.
97
+ * @throws {Error} If parts are missing, the session is not found, or S3 assembly fails.
98
+ */
99
+ static completeUpload(uploadSessionId: string): Promise<CompleteUploadResult>;
100
+ /**
101
+ * Streams an object from storage directly to an HTTP response.
102
+ * @description
103
+ * Fetches object metadata (stat) to populate necessary download headers before
104
+ * piping the data stream to the client. This avoids loading the entire file
105
+ * into memory, making it efficient for large file transfers.
106
+ * * @param {Response} res - The Express/Node.js response object.
107
+ * @param {UploadTypes} options - Configuration object containing:
108
+ * - `bucket`: Target bucket name.
109
+ * - `objectKey`: The internal storage path (e.g., 'stream/123').
110
+ * - `objectName`: The "friendly" filename for the user's browser.
111
+ * @returns {Promise<void>} Resolves once the stream has been successfully piped.
112
+ * @throws {Error} If the object does not exist or metadata cannot be retrieved.
113
+ */
114
+ static streamDownload(res: any, { bucket, objectKey, objectName }: UploadTypes): Promise<void>;
115
+ /**
116
+ * Executes a low-level Multipart Upload Part request.
117
+ * * @description
118
+ * This is a custom wrapper around the S3/MinIO PUT Object Part API. It manually constructs the query parameters and headers required for a part upload. Crucially, it handles inconsistent server responses by attempting to extract the ETag from both the response XML body and the HTTP headers.
119
+ * @param {Object} partConfig - Configuration for the specific part.
120
+ * @param {string} partConfig.bucketName - Target bucket.
121
+ * @param {string} partConfig.objectName - The internal path/key of the object.
122
+ * @param {string} partConfig.uploadID - The ID generated by `initiateNewMultipartUpload`.
123
+ * @param {number} partConfig.partNumber - 1-based index of the part.
124
+ * @param {unknown} partConfig.headers - HTTP headers (e.g., Content-Length, Content-MD5).
125
+ * @param {any} [payload] - The binary data buffer for this part.
126
+ * @returns {Promise<UploadPart>}
127
+ * Returns a sanitized ETag required for the final `completeUpload` call.
128
+ * @throws {Error} If no ETag is found in the response or the network request fails.
129
+ */
130
+ static uploadPart: (partConfig: {
131
+ bucketName: string;
132
+ objectName: string;
133
+ uploadID: string;
134
+ partNumber: number;
135
+ headers: unknown;
136
+ }, payload?: any) => Promise<UploadPart>;
137
+ }
@@ -0,0 +1,6 @@
1
+ import type { Readable } from 'node:stream';
2
+ export declare function readAsString(response: Readable): Promise<string>;
3
+ export declare function sanitizeETag(etag: string): string;
4
+ export declare function uploadPartParser(body: string): {
5
+ ETag: string;
6
+ };
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@beespoke/micro-server-minio",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.5",
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
+ "minio": "^8.0.0",
27
+ "@types/node": "^24.0.0"
28
+ },
29
+ "peerDependencies": {
30
+ "@beespoke/micro-server": "^2.0.5"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@beespoke/micro-server": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "license": "MIT"
38
+ }