Files

210 lines
6.6 KiB
TypeScript

import {
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
PutObjectCommand,
S3Client,
UploadPartCommand,
} from "@aws-sdk/client-s3"
import type { ManagedTask, TaskHandler, TaskLifecycleStatus } from "./task-manager"
import { formatBytes } from "./functions"
import { createTaskId } from "./task-id"
import { getUploadContentType } from "./upload-content-type"
import { logger } from "./logger"
export type UploadStatus = "pending" | "running" | "completed" | "failed" | "canceled"
export interface UploadTask extends ManagedTask<UploadStatus> {
kind: "upload"
file: File
bucketName: string
prefix?: string
key: string
actionLabel: string
displayName: string
subInfo: string
uploadId?: string
completedParts?: { ETag: string; PartNumber: number }[]
}
export interface UploadTaskConfig {
chunkSize?: number // MB
maxRetries?: number
retryDelay?: number
}
export interface UploadTaskHelpers {
handler: TaskHandler<UploadTask>
createTasks: (items: { file: File; key: string }[], bucketName: string) => UploadTask[]
}
const lifecycle: TaskLifecycleStatus<UploadStatus> = {
pending: "pending",
running: "running",
completed: "completed",
failed: "failed",
canceled: "canceled",
}
export function createUploadTaskHelpers(s3Client: S3Client, config: UploadTaskConfig = {}): UploadTaskHelpers {
const chunkSize = (config.chunkSize ?? 5) * 1024 * 1024
const maxRetries = config.maxRetries ?? 3
const retryDelay = config.retryDelay ?? 1000
const perform: TaskHandler<UploadTask>["perform"] = async (task) => {
task.progress = 0
if (task.file.size > chunkSize) {
await multipartUpload(task, s3Client, chunkSize)
} else {
await putObject(task, s3Client)
}
}
const shouldRetry: TaskHandler<UploadTask>["shouldRetry"] = (task, error) => {
if (task.status === lifecycle.canceled) return false
if ((task.retryCount || 0) >= maxRetries) return false
const err = error as { $metadata?: { httpStatusCode?: number }; message?: string }
const statusCode = err?.$metadata?.httpStatusCode
if (statusCode && statusCode >= 400 && statusCode < 500 && statusCode !== 408 && statusCode !== 429) return false
const errorMessage = (err?.message || "").toLowerCase()
const nonRetryableErrors = [
"access denied",
"forbidden",
"invalid credentials",
"bucket not found",
"file not found",
]
return !nonRetryableErrors.some((msg) => errorMessage.includes(msg))
}
const handler: TaskHandler<UploadTask> = {
lifecycle,
perform,
shouldRetry,
isCanceledError: (error) =>
(error as Error)?.name === "AbortError" || (error as Error)?.message?.includes("canceled"),
maxRetries,
retryDelay,
}
const createTasks = (items: { file: File; key: string }[], bucketName: string): UploadTask[] => {
const existKeys = new Set<string>()
return items
.filter((item) => !existKeys.has(`${bucketName}/${item.key}`))
.map((item) => {
existKeys.add(`${bucketName}/${item.key}`)
const displayName = item.key.split("/").pop() ?? item.key
return {
id: createTaskId(item.key),
kind: "upload" as const,
file: item.file,
key: item.key,
bucketName,
status: lifecycle.pending,
progress: 0,
actionLabel: "Upload",
displayName,
subInfo: formatBytes(item.file.size),
retryCount: 0,
}
})
}
return { handler, createTasks }
}
async function putObject(task: UploadTask, s3Client: S3Client) {
const { file, bucketName, key } = task
const abortController = new AbortController()
task.abortController = abortController
const contentType = await getUploadContentType(file, key)
await s3Client.send(
new PutObjectCommand({
Bucket: bucketName,
Key: key,
Body: file,
ContentType: contentType,
}),
{ abortSignal: abortController.signal },
)
task.progress = 100
}
async function multipartUpload(task: UploadTask, s3Client: S3Client, chunkSize: number) {
const { file, bucketName, key } = task
const abortController = new AbortController()
task.abortController = abortController
let uploadId: string | undefined = task.uploadId
const completedParts: { ETag: string; PartNumber: number }[] = task.completedParts || []
const contentType = await getUploadContentType(file, key)
try {
if (!uploadId) {
const createResponse = await s3Client.send(
new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: key,
ContentType: contentType,
}),
{ abortSignal: abortController.signal },
)
uploadId = createResponse.UploadId
task.uploadId = uploadId
task.completedParts = []
}
if (!uploadId) throw new Error("Failed to create multipart upload")
const totalChunks = Math.ceil(file.size / chunkSize)
for (let partNumber = 1; partNumber <= totalChunks; partNumber++) {
if (abortController.signal.aborted) throw new DOMException("Upload canceled", "AbortError")
if (completedParts.some((p) => p.PartNumber === partNumber)) continue
const start = (partNumber - 1) * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
const { ETag } = await s3Client.send(
new UploadPartCommand({
Bucket: bucketName,
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
Body: chunk,
}),
{ abortSignal: abortController.signal },
)
if (!ETag) throw new Error(`Failed to upload part ${partNumber}`)
completedParts.push({ ETag, PartNumber: partNumber })
task.completedParts = completedParts
task.progress = Math.round((completedParts.length / totalChunks) * 100)
}
await s3Client.send(
new CompleteMultipartUploadCommand({
Bucket: bucketName,
Key: key,
UploadId: uploadId,
MultipartUpload: { Parts: completedParts },
}),
{ abortSignal: abortController.signal },
)
task.uploadId = undefined
task.completedParts = undefined
} catch (error) {
if (uploadId) {
try {
const { AbortMultipartUploadCommand } = await import("@aws-sdk/client-s3")
await s3Client.send(new AbortMultipartUploadCommand({ Bucket: bucketName, Key: key, UploadId: uploadId }))
} catch (cleanupError) {
logger.warn("Failed to abort multipart upload", cleanupError)
} finally {
task.uploadId = undefined
task.completedParts = undefined
}
}
throw error
}
}