UNPKG

@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
"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;