akiyama.mizuki.guru/apps/backend/src/lib/s3.ts
2026-06-26 07:58:24 +09:00

86 lines
2.7 KiB
TypeScript

import { S3Client } from "bun";
import config from "@/../config.toml";
// Bun.S3Client 단일화
const client = new S3Client({
accessKeyId: config.s3.access_key,
secretAccessKey: config.s3.secret_key,
bucket: config.s3.bucket,
endpoint: config.s3.endpoint,
});
async function warmupS3() {
try {
await client.exists(`__warmup_${Date.now()}__`);
} catch (e) {
}
}
function makeS3FileName(authorId: string, tweetId: string, mediaUrl: string, index: number) {
const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`;
const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`;
const safeName = withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_");
return `twitter/${authorId}/${tweetId}/${safeName}`;
}
async function uploadToS3(fileName: string, mediaUrl: string, maxRetry = 3) {
let lastError: unknown;
// 1. 요청 시작 전 예열 수행
await warmupS3();
for (let attempt = 1; attempt <= maxRetry; attempt++) {
try {
const parsedUrl = new URL(mediaUrl);
const isPixivImage = parsedUrl.hostname === "i.pximg.net";
const response = await fetch(mediaUrl, {
headers: isPixivImage
? {
"Referer": "https://www.pixiv.net/",
"User-Agent": "Mozilla/5.0 (compatible; akiyama.mizuki.guru/1.0)",
}
: undefined,
});
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
}
const buffer = await response.arrayBuffer();
// 2. Bun S3 Client만 사용하여 쓰기
// client.write는 내부적으로 효율적인 스트리밍/버퍼 처리를 수행합니다.
await client.write(fileName, buffer, {
type: response.headers.get("content-type") || "application/octet-stream",
});
return; // 성공 시 리턴
} catch (error) {
lastError = error;
console.error(`[S3 upload attempt ${attempt}/${maxRetry}] key=${fileName}`, error);
// UnknownError 발생 시 잠시 대기 후 재시도 (지수 백오프)
if (attempt < maxRetry) {
await Bun.sleep(attempt * 1000);
// 재시도 전 다시 한번 예열 시도 가능
await warmupS3();
}
}
}
// 최종 실패 전 마지막 확인 (이미 올라갔을 수도 있음)
if (await client.exists(fileName)) {
console.warn(`[S3 upload recovered] key=${fileName} was found after error.`);
return;
}
throw lastError;
}
async function writeToS3(fileName: string, data: ArrayBuffer | Blob, contentType: string) {
await warmupS3();
await client.write(fileName, data, {
type: contentType,
});
}
export { makeS3FileName, uploadToS3, writeToS3, client as s3Client };