// js/storage/s3-multipart.js import { requireAwsSdk } from "./s3-client.js"; import logger from "../utils/logger.js"; const DEFAULT_CACHE_CONTROL = "public, max-age=3600"; function normalizeRegion(region) { if (!region) { return ""; } const trimmed = String(region).trim(); if (!trimmed || trimmed.toLowerCase() === "auto") { return ""; } return trimmed; } function getCreateBucketConfig(region) { const normalized = normalizeRegion(region); if (!normalized || normalized === "us-east-1") { return {}; } return { CreateBucketConfiguration: { LocationConstraint: normalized, }, }; } function computeCacheControl(key) { if (!key) { return DEFAULT_CACHE_CONTROL; } const lower = String(key).toLowerCase(); if (/(?:\.)(m3u8|mpd)$/.test(lower)) { return "public, max-age=60, must-revalidate"; } if ( /\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|vtt|srt|mpg|mpeg)$/.test( lower ) ) { return "public, max-age=31536000, immutable"; } return DEFAULT_CACHE_CONTROL; } export async function ensureBucketExists({ s3, bucket, region } = {}) { if (!s3) { throw new Error("S3 client is required to ensure buckets"); } if (!bucket) { throw new Error("Bucket name is required to ensure buckets"); } const { CreateBucketCommand, HeadBucketCommand } = requireAwsSdk(); try { await s3.send(new HeadBucketCommand({ Bucket: bucket })); return; } catch (error) { const status = error?.$metadata?.httpStatusCode || 0; const code = error?.name || error?.Code || ""; if (status !== 404 && code !== "NotFound" && code !== "NoSuchBucket") { throw error; } } try { await s3.send( new CreateBucketCommand({ Bucket: bucket, ...getCreateBucketConfig(region), }) ); } catch (error) { const status = error?.$metadata?.httpStatusCode || 0; const code = error?.name || error?.Code || ""; if ( status === 409 || code === "BucketAlreadyOwnedByYou" || code === "BucketAlreadyExists" ) { return; } throw error; } } export async function ensureBucketCors({ s3, bucket, origins, region } = {}) { if (!s3) { throw new Error("S3 client is required to configure CORS"); } if (!bucket) { throw new Error("Bucket name is required to configure CORS"); } const { PutBucketCorsCommand } = requireAwsSdk(); const allowedOrigins = (origins || []).filter(Boolean); if (allowedOrigins.length === 0) { return; } await ensureBucketExists({ s3, bucket, region }); const command = new PutBucketCorsCommand({ Bucket: bucket, CORSConfiguration: { CORSRules: [ { AllowedHeaders: ["*"], AllowedMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"], AllowedOrigins: allowedOrigins, ExposeHeaders: [ "ETag", "Content-Length", "Content-Range", "Accept-Ranges", ], MaxAgeSeconds: 3600, }, ], }, }); await s3.send(command); } export async function deleteObject({ s3, bucket, key } = {}) { if (!s3 || !bucket || !key) { throw new Error("Missing required parameters for deleteObject"); } const { DeleteObjectCommand } = requireAwsSdk(); try { await s3.send( new DeleteObjectCommand({ Bucket: bucket, Key: key, }) ); } catch (error) { // We treat 404 (NoSuchKey) as success since the object is gone. const status = error?.$metadata?.httpStatusCode || 0; const code = error?.name || error?.Code || ""; if (status !== 404 && code !== "NoSuchKey") { throw error; } } } export async function multipartUpload({ s3, bucket, key, file, contentType, onProgress, createBucketIfMissing = false, region, } = {}) { if (!s3) { throw new Error("S3 client is required"); } if (!bucket) { throw new Error("Bucket is required"); } if (!key) { throw new Error("Object key is required"); } if (!file) { throw new Error("File is required"); } const { CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, } = requireAwsSdk(); if (createBucketIfMissing) { try { await ensureBucketExists({ s3, bucket, region }); } catch (error) { logger.dev.warn( "[S3 multipart] Bucket creation check failed, continuing anyway.", error ); } } logger.dev.debug("[S3 multipart] Starting multipart upload for:", key); const createCommand = new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: contentType || file.type || "application/octet-stream", CacheControl: computeCacheControl(key), }); const { UploadId } = await s3.send(createCommand); if (!UploadId) { throw new Error("Failed to start multipart upload"); } const MIN_PART = 5 * 1024 * 1024; const maxParts = 10000; const calculated = Math.ceil(file.size / maxParts); const partSize = Math.max(MIN_PART, calculated); const total = file.size; const parts = []; let uploadedBytes = 0; let partNumber = 1; if (typeof onProgress === "function") { onProgress(0); } try { while (uploadedBytes < total) { const start = uploadedBytes; const end = Math.min(start + partSize, total); const chunk = file.slice(start, end); const { ETag } = await s3.send( new UploadPartCommand({ Bucket: bucket, Key: key, UploadId, PartNumber: partNumber, Body: chunk, }) ); parts.push({ ETag, PartNumber: partNumber }); uploadedBytes = end; partNumber += 1; if (typeof onProgress === "function") { onProgress(uploadedBytes / total); } } await s3.send( new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId, MultipartUpload: { Parts: parts }, }) ); } catch (error) { await s3 .send( new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId, }) ) .catch(() => {}); throw error; } }