@d3oxy/s3-pilot
Version:
A TypeScript wrapper for AWS S3 and S3-compatible services (R2, MinIO, DigitalOcean Spaces) with a simplified single-client, single-bucket architecture.
512 lines (511 loc) • 24.2 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __asyncValues = (this && this.__asyncValues) || function (o) {
if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
var m = o[Symbol.asyncIterator], i;
return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.S3Pilot = void 0;
const client_s3_1 = require("@aws-sdk/client-s3");
const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
/**
* S3Pilot class abstracts interactions with AWS S3 SDK to provide a cleaner API.
* Each instance represents a single S3 client connected to a single bucket.
*
* Supports S3-compatible services like Cloudflare R2, DigitalOcean Spaces, MinIO, etc.
* via the `endpoint` configuration option.
*/
class S3Pilot {
/**
* Creates an instance of the S3Pilot class.
* @param config - The configuration object for the S3 client and bucket.
*/
constructor(config) {
const { region, accessKeyId, secretAccessKey, bucket, additionalConfig, keyPrefix, validateBucketOnInit = false, endpoint, publicBaseUrl } = config;
// Store endpoint from config or additionalConfig
this.endpoint = endpoint !== null && endpoint !== void 0 ? endpoint : additionalConfig === null || additionalConfig === void 0 ? void 0 : additionalConfig.endpoint;
this.publicBaseUrl = publicBaseUrl;
this.s3 = new client_s3_1.S3Client(Object.assign(Object.assign({ region, credentials: {
accessKeyId,
secretAccessKey,
} }, additionalConfig), (this.endpoint && { endpoint: this.endpoint })));
this.bucket = bucket;
this.region = region;
this.keyPrefix = keyPrefix;
// Validate bucket on initialization if requested
if (validateBucketOnInit) {
// Note: This is intentionally not awaited to avoid blocking constructor
// The promise will be rejected if validation fails
this.validateBucketOnInit().catch(() => {
// Error is thrown in validateBucketOnInit, this catch is just to prevent unhandled rejection
});
}
}
/**
* Validates the bucket on initialization.
* Called from constructor if validateBucketOnInit is true.
* Note: This method is used conditionally in the constructor, which may trigger unused member warnings.
*
* @throws An error if the bucket is not valid or the user does not have access to it.
*/
validateBucketOnInit() {
return __awaiter(this, void 0, void 0, function* () {
try {
yield this.s3.send(new client_s3_1.HeadBucketCommand({ Bucket: this.bucket }));
}
catch (_a) {
throw new Error(`Bucket ${this.bucket} is not valid or you do not have access to it.`);
}
});
}
/**
* Retrieves the URL for a given object key.
*
* URL generation priority:
* 1. If `publicBaseUrl` is configured: `{publicBaseUrl}/{key}`
* 2. If `endpoint` is configured (S3-compatible): `{endpoint}/{bucket}/{key}`
* 3. Default AWS S3 format: `https://{bucket}.s3.{region}.amazonaws.com/{key}`
*
* @param key - The object key.
* @returns The URL of the object.
*/
getUrl(key) {
// Note: key is expected to already include keyPrefix if applicable
// (upload methods construct keys with prefix before calling getUrl)
if (this.publicBaseUrl) {
// User-provided public URL base takes highest priority
const baseUrl = this.publicBaseUrl.replace(/\/$/, "");
return `${baseUrl}/${key}`;
}
if (this.endpoint) {
// S3-compatible service: use path-style URL
const endpointUrl = this.endpoint.replace(/\/$/, "");
return `${endpointUrl}/${this.bucket}/${key}`;
}
// Default: AWS S3 URL format
return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
}
/**
* Uploads a file stream to the bucket.
*
* @param params - The parameters for the file upload.
* @param params.filename - The name of the file.
* @param params.folder - The folder to upload the file to (optional).
* @param params.stream - The readable stream to upload.
* @param params.contentType - The content type of the file.
* @param params.contentLength - The content length of the stream (optional, but recommended for large files).
* @param params.additionalParams - Additional parameters for the PutObjectCommand (optional).
* @returns A promise that resolves to an object containing the URL, key, etag, and versionId of the uploaded file.
* @throws {Error} If the file fails to upload.
*/
uploadFileStream(params) {
return __awaiter(this, void 0, void 0, function* () {
const { filename, folder, stream, contentType, contentLength, additionalParams } = params;
const key = `${this.keyPrefix ? `${this.keyPrefix}/` : ""}${folder ? `${folder}/` : ""}${filename}`;
const command = new client_s3_1.PutObjectCommand(Object.assign(Object.assign(Object.assign({}, additionalParams), { Bucket: this.bucket, Key: key, Body: stream, ContentType: contentType }), (contentLength && { ContentLength: contentLength })));
const res = yield this.s3.send(command);
if (res.$metadata.httpStatusCode !== 200) {
throw new Error(`Failed to upload file stream to ${this.bucket}/${key}`);
}
// construct the URL of the uploaded file
const url = this.getUrl(key);
return {
url: url,
key: key,
etag: res.ETag,
versionId: res.VersionId,
};
});
}
/**
* Uploads a file to the bucket.
*
* @param params - The parameters for the file upload.
* @param params.filename - The name of the file.
* @param params.folder - The folder to upload the file to (optional).
* @param params.file - The file to upload.
* @param params.contentType - The content type of the file.
* @param params.additionalParams - Additional parameters for the PutObjectCommand (optional).
* @returns A promise that resolves to an object containing the URL and key of the uploaded file.
* @throws {Error} If the file fails to upload.
*/
uploadFile(params) {
return __awaiter(this, void 0, void 0, function* () {
const { filename, folder, file, contentType, additionalParams } = params;
const key = `${this.keyPrefix ? `${this.keyPrefix}/` : ""}${folder ? `${folder}/` : ""}${filename}`;
const command = new client_s3_1.PutObjectCommand(Object.assign(Object.assign({}, additionalParams), { Bucket: this.bucket, Key: key, Body: file, ContentType: contentType }));
const res = yield this.s3.send(command);
if (res.$metadata.httpStatusCode !== 200) {
throw new Error(`Failed to upload file to ${this.bucket}/${key}`);
}
// construct the URL of the uploaded file
const url = this.getUrl(key);
return {
url: url,
key: key,
};
});
}
/**
* Deletes a file from the bucket.
*
* @param params - The parameters for deleting the file.
* @param params.key - The key of the file to delete.
* @returns A Promise that resolves when the file is successfully deleted.
*/
deleteFile(params) {
return __awaiter(this, void 0, void 0, function* () {
const { key } = params;
const command = new client_s3_1.DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
});
yield this.s3.send(command);
});
}
/**
* Deletes multiple files from the bucket in a single request.
* Automatically batches requests if more than 1000 keys are provided (S3 API limit).
*
* @param params - The parameters for deleting files.
* @param params.keys - Array of keys to delete.
* @returns A promise that resolves to an object containing deleted keys and any errors.
*/
deleteFiles(params) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const { keys } = params;
const deleted = [];
const errors = [];
// S3 allows max 1000 objects per DeleteObjects request
const BATCH_SIZE = 1000;
for (let i = 0; i < keys.length; i += BATCH_SIZE) {
const batch = keys.slice(i, i + BATCH_SIZE);
const objects = batch.map((key) => ({ Key: key }));
const command = new client_s3_1.DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: objects,
Quiet: false,
},
});
try {
const response = yield this.s3.send(command);
// Collect successfully deleted keys
if (response.Deleted) {
for (const deletedItem of response.Deleted) {
if (deletedItem.Key) {
deleted.push(deletedItem.Key);
}
}
}
// Collect errors
if (response.Errors) {
for (const error of response.Errors) {
errors.push({
key: (_a = error.Key) !== null && _a !== void 0 ? _a : "",
code: error.Code,
message: error.Message,
});
}
}
}
catch (error) {
// If the entire batch fails, add all keys to errors
for (const key of batch) {
errors.push({
key,
message: error instanceof Error ? error.message : "Unknown error",
});
}
}
}
return {
deleted,
errors,
};
});
}
/**
* Renames a file in the bucket.
*
* @param params - The parameters for renaming the file.
* @param params.oldKey - The key of the file to be renamed.
* @param params.newFolder - The new folder for the renamed file (optional).
* @param params.newFilename - The new filename for the renamed file (optional).
* @returns A promise that resolves to an object containing the URL and key of the renamed file.
* @deprecated This function is not stable and should not be used.
*/
renameFile(params) {
return __awaiter(this, void 0, void 0, function* () {
const { oldKey, newFolder, newFilename } = params;
const extension = oldKey.split(".").pop();
const newKey = `${this.keyPrefix ? `${this.keyPrefix}/` : ""}${newFolder ? `${newFolder}/` : ""}${newFilename}.${extension}`;
const copyCommand = new client_s3_1.CopyObjectCommand({
Bucket: this.bucket,
CopySource: `${this.bucket}/${oldKey}`,
Key: newKey,
});
yield this.s3.send(copyCommand);
yield this.deleteFile({ key: oldKey });
const url = this.getUrl(newKey);
return {
url: url,
key: newKey,
};
});
}
/**
* Moves a file within the bucket from one key to another.
* Uses S3's CopyObject + DeleteObject operations.
*
* @param params - The parameters for moving the file.
* @param params.sourceKey - The current key of the file to move.
* @param params.destinationKey - The new key for the file.
* @returns A promise that resolves to an object containing the URL and key of the moved file.
* @throws {Error} If the file cannot be moved.
*/
moveFile(params) {
return __awaiter(this, void 0, void 0, function* () {
const { sourceKey, destinationKey } = params;
const copyCommand = new client_s3_1.CopyObjectCommand({
Bucket: this.bucket,
CopySource: `${this.bucket}/${sourceKey}`,
Key: destinationKey,
});
yield this.s3.send(copyCommand);
yield this.deleteFile({ key: sourceKey });
const url = this.getUrl(destinationKey);
return {
url,
key: destinationKey,
};
});
}
/**
* Moves a file from this bucket to another bucket.
* First attempts a direct S3 copy (works if same AWS credentials have access to both buckets).
* Falls back to download + upload if direct copy fails (for cross-account moves).
*
* @param params - The parameters for moving the file.
* @param params.sourceKey - The key of the file to move from this bucket.
* @param params.destinationKey - The key for the file in the destination bucket.
* @param params.destination - The destination S3Pilot instance.
* @returns A promise that resolves to an object containing the URL and key of the moved file.
* @throws {Error} If the file cannot be moved.
*/
moveToBucket(params) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const { sourceKey, destinationKey, destination } = params;
try {
// Try direct S3 copy (works if same credentials have access to both buckets)
const copyCommand = new client_s3_1.CopyObjectCommand({
Bucket: destination.bucket,
CopySource: `${this.bucket}/${sourceKey}`,
Key: destinationKey,
});
yield destination.s3.send(copyCommand);
}
catch (_b) {
// Fall back to download + upload (for cross-account or permission issues)
const file = yield this.getFile({ key: sourceKey });
// Use PutObjectCommand directly to preserve exact destinationKey (no keyPrefix applied)
const putCommand = new client_s3_1.PutObjectCommand({
Bucket: destination.bucket,
Key: destinationKey,
Body: file.buffer,
ContentType: (_a = file.contentType) !== null && _a !== void 0 ? _a : "application/octet-stream",
});
yield destination.s3.send(putCommand);
}
// Delete source file after successful move
yield this.deleteFile({ key: sourceKey });
const url = destination.getUrl(destinationKey);
return {
url,
key: destinationKey,
};
});
}
/**
* Downloads a file from S3 and returns its content as a Buffer.
* This method is useful for server-side file processing or when you need to
* stream file content to clients through your API server.
*
* @param params - The parameters for downloading the file.
* @param params.key - The key of the file to download.
* @returns A promise that resolves to an object containing the file buffer and metadata.
* @throws {Error} If the file cannot be downloaded or doesn't exist.
*/
getFile(params) {
return __awaiter(this, void 0, void 0, function* () {
var _a, e_1, _b, _c;
const { key } = params;
const command = new client_s3_1.GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
try {
const response = yield this.s3.send(command);
if (!response.Body) {
throw new Error(`File ${key} not found in bucket ${this.bucket}`);
}
// Convert the readable stream to a buffer
const chunks = [];
const stream = response.Body;
try {
for (var _d = true, stream_1 = __asyncValues(stream), stream_1_1; stream_1_1 = yield stream_1.next(), _a = stream_1_1.done, !_a; _d = true) {
_c = stream_1_1.value;
_d = false;
const chunk = _c;
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(bufferChunk);
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (!_d && !_a && (_b = stream_1.return)) yield _b.call(stream_1);
}
finally { if (e_1) throw e_1.error; }
}
const buffer = Buffer.concat(chunks);
return {
buffer,
contentType: response.ContentType,
contentLength: response.ContentLength,
etag: response.ETag,
lastModified: response.LastModified,
};
}
catch (error) {
if (error instanceof Error && error.name === "NoSuchKey") {
throw new Error(`File ${key} not found in bucket ${this.bucket}`);
}
throw error;
}
});
}
/**
* Downloads a file from S3 and returns its content as a readable stream.
* This method is useful for streaming large files efficiently without loading
* the entire file into memory.
*
* @param params - The parameters for downloading the file.
* @param params.key - The key of the file to download.
* @returns A promise that resolves to an object containing the file stream and metadata.
* @throws {Error} If the file cannot be downloaded or doesn't exist.
*/
getFileStream(params) {
return __awaiter(this, void 0, void 0, function* () {
const { key } = params;
const command = new client_s3_1.GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
try {
const response = yield this.s3.send(command);
if (!response.Body) {
throw new Error(`File ${key} not found in bucket ${this.bucket}`);
}
return {
stream: response.Body,
contentType: response.ContentType,
contentLength: response.ContentLength,
etag: response.ETag,
lastModified: response.LastModified,
};
}
catch (error) {
if (error instanceof Error && error.name === "NoSuchKey") {
throw new Error(`File ${key} not found in bucket ${this.bucket}`);
}
throw error;
}
});
}
/**
* Generates a signed URL for accessing an object in the bucket.
* Enhanced version with support for download-specific headers to force browser download
* and avoid CORS issues.
*
* @param params - Additional parameters for generating the signed URL.
* @param params.key - The key of the object.
* @param params.expiresIn - The expiration time of the signed URL in seconds. Defaults to 1 hour (3600 seconds).
* @param params.responseContentDisposition - Optional content disposition header to force download behavior.
* @param params.responseContentType - Optional content type header.
* @param params.responseCacheControl - Optional cache control header.
* @param params.responseContentLanguage - Optional content language header.
* @param params.responseContentEncoding - Optional content encoding header.
* @param params.responseExpires - Optional expires header.
* @returns A promise that resolves to the signed URL.
*/
generateSignedUrl(params) {
return __awaiter(this, void 0, void 0, function* () {
const { key, expiresIn = 3600, responseContentDisposition, responseContentType, responseCacheControl, responseContentLanguage, responseContentEncoding, responseExpires, } = params;
const command = new client_s3_1.GetObjectCommand({
Bucket: this.bucket,
Key: key,
ResponseContentDisposition: responseContentDisposition,
ResponseContentType: responseContentType,
ResponseCacheControl: responseCacheControl,
ResponseContentLanguage: responseContentLanguage,
ResponseContentEncoding: responseContentEncoding,
ResponseExpires: responseExpires,
});
return (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn });
});
}
/**
* Deletes a folder and all its contents from the bucket.
*
* @param params - The parameters for deleting the folder.
* @param params.folder - The folder to delete.
* @returns A promise that resolves when the folder and its contents are successfully deleted.
*/
deleteFolder(params) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const { folder } = params;
const prefix = `${this.keyPrefix ? `${this.keyPrefix}/` : ""}${folder}/`;
let isTruncated = true;
let continuationToken;
while (isTruncated) {
const command = new client_s3_1.ListObjectsV2Command({
Bucket: this.bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
});
const res = yield this.s3.send(command);
if (res.Contents && res.Contents.length > 0) {
const deleteCommands = res.Contents.map((item) => ({
Key: item.Key,
}));
const deleteCommand = new client_s3_1.DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: deleteCommands,
Quiet: false,
},
});
yield this.s3.send(deleteCommand);
}
isTruncated = (_a = res.IsTruncated) !== null && _a !== void 0 ? _a : false;
continuationToken = res.NextContinuationToken;
}
});
}
}
exports.S3Pilot = S3Pilot;