@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 +70 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +404 -0
- package/dist/plugin/index.d.ts +16 -0
- package/dist/plugin/index.js +46 -0
- package/dist/support.d.ts +18 -0
- package/dist/types/AttachmentMetadataTypes.d.ts +22 -0
- package/dist/types/S3Options.d.ts +11 -0
- package/dist/utils/UploaderS3.d.ts +79 -0
- package/dist/utils/helpers.d.ts +38 -0
- package/package.json +37 -4
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @beespoke/micro-server-s3
|
|
2
2
|
|
|
3
|
-
|
|
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`.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
""": ""
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|