Передача файлов большого объема (видеоматериалы 4K, многогигабайтные архивы, научные датасеты, медицинские снимки DICOM > 100 МБ) через стандартные серверные обработчики Next.js (Server Actions или стандартные Route Handlers с парсингом FormData) создает критические инфраструктурные риски в промышленной эксплуатации:
413 Content Too Large на уровне пограничного прокси еще до вызова вашего кода.Решением уровня Enterprise является архитектура прямой загрузки (Direct-to-Storage Upload) с использованием подписанных URL (Presigned URLs) и протокола S3 Multipart Upload (совместимого с AWS S3, Cloudflare R2, MinIO, Google Cloud Storage). Сервер Next.js выступает исключительно в роли легковесного контроллера авторизации: он проверяет права пользователя, выделяет уникальный UploadId, подписывает временные криптографические ссылки на загрузку отдельных частей и фиксирует завершение сборки.
Вместо проксирования гигабайтов через Node.js сервер разделяет процесс на фазу авторизации метаданных и фазу независимой прямой передачи бинарных данных:
+-----------------------------------------------------------------------------------------+
| АРХИТЕКТУРА S3/R2 MULTIPART PRESIGNED UPLOAD |
+-----------------------------------------------------------------------------------------+
| КЛИЕНТ (БРАУЗЕР) NEXT.JS 16 (SERVER ACTION) S3/R2 ОБЪЕКТНОЕ ХРАНИЛИЩЕ
| |
| 1. Выбор файла 2 ГБ |
| file.slice(parts) |
| | |
| +-- Запрос инициализации ---------> [ Проверка прав сессии ] |
| (name, size, mime) [ Валидация квоты tenant ] |
| [ CreateMultipartUpload ] ------> Инициализация |
| <-- Возврат UploadId -------------- + (Генерация ключа объекта) UploadId |
| + Presigned URLs для частей |
| |
| 2. Параллельная отправка частей |
| Chunk 1 (5 МБ, Part 1) =================== Direct PUT Part 1 =======> [Part 1: ETag1]|
| Chunk 2 (5 МБ, Part 2) =================== Direct PUT Part 2 =======> [Part 2: ETag2]|
| Chunk N (5 МБ, Part N) =================== Direct PUT Part N =======> [Part N: ETagN]|
| |
| 3. Завершение сборки |
| +-- Список всех [PartNumber, ETag]-> [ CompleteMultipart ] -------> Атомарная сборка|
| [ Запись в Postgres DB ] единого объекта |
| <-- HTTP 200 OK (Файл готов) -------+ |
+-----------------------------------------------------------------------------------------+Клиент передает имя файла, размер и MIME-тип в Server Action. Сервер валидирует авторизацию, вызывает CreateMultipartUploadCommand в AWS SDK v3 и генерирует набор подписанных URL для каждого чанка.
Браузер с помощью File.slice() делит файл на блоки фиксированного размера (от 5 МБ до 20 МБ) и формирует очередь параллельной отправки.
Чанки передаются напрямую в S3 параллельными HTTP PUT запросами (ограничение пула 3–5 одновременных потоков). S3 возвращает заголовок ETag для каждого принятого блока.
Клиент передает массив { PartNumber, ETag } на сервер Next.js. Сервер выполняет CompleteMultipartUploadCommand, собирает объект в бакете и регистрирует запись в БД.
Каков минимально допустимый размер каждого чанка (кроме самого последнего завершающего) при использовании спецификации AWS S3 Multipart Upload?
Используем модульный клиент @aws-sdk/client-s3 и вспомогательный пакет @aws-sdk/s3-request-presigner внутри защищенного Next.js Server Action:
'use server';
import {
S3Client,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
CompletedPart,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
// Инициализация S3 клиента (подходит для AWS S3, Cloudflare R2, MinIO)
const s3 = new S3Client({
region: process.env.AWS_REGION || 'auto',
endpoint: process.env.S3_CUSTOM_ENDPOINT, // Например, https://<accountid>.r2.cloudflarestorage.com
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const BUCKET_NAME = process.env.S3_BUCKET_NAME!;
const CHUNK_SIZE_MB = 10;
export const CHUNK_SIZE_BYTES = CHUNK_SIZE_MB * 1024 * 1024; // 10 MB на чанк
interface InitUploadParams {
fileName: string;
fileSizeBytes: number;
mimeType: string;
}
export async function initiateMultipartUploadAction({ fileName, fileSizeBytes, mimeType }: InitUploadParams) {
// 1. Проверка авторизации текущей пользовательской сессии
// const session = await auth(); if (!session) throw new Error("Unauthorized");
// 2. Валидация размера и расширения
const MAX_ALLOWED_SIZE = 10 * 1024 * 1024 * 1024; // 10 GB
if (fileSizeBytes > MAX_ALLOWED_SIZE) {
throw new Error('Превышен максимальный лимит размера файла (10 ГБ).');
}
const fileKey = `uploads/${new Date().getFullYear()}/${randomUUID()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
const totalParts = Math.ceil(fileSizeBytes / CHUNK_SIZE_BYTES);
// 3. Создание сессии в S3
const createRes = await s3.send(
new CreateMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: fileKey,
ContentType: mimeType,
})
);
const uploadId = createRes.UploadId;
if (!uploadId) throw new Error('Не удалось получить UploadId от S3');
// 4. Генерация Presigned URL для каждого чанка
const partUrls: { partNumber: number; presignedUrl: string }[] = [];
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
const uploadPartCmd = new UploadPartCommand({
Bucket: BUCKET_NAME,
Key: fileKey,
UploadId: uploadId,
PartNumber: partNumber,
});
// Ссылка активна в течение 1 часа (3600 секунд)
const presignedUrl = await getSignedUrl(s3, uploadPartCmd, { expiresIn: 3600 });
partUrls.push({ partNumber, presignedUrl });
}
return { uploadId, fileKey, partUrls };
}
interface CompleteUploadParams {
fileKey: string;
uploadId: string;
parts: CompletedPart[];
}
export async function completeMultipartUploadAction({ fileKey, uploadId, parts }: CompleteUploadParams) {
// Сортировка частей по возрастанию PartNumber обязательна по спецификации S3!
const sortedParts = [...parts].sort((a, b) => (a.PartNumber ?? 0) - (b.PartNumber ?? 0));
const result = await s3.send(
new CompleteMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: fileKey,
UploadId: uploadId,
MultipartUpload: {
Parts: sortedParts,
},
})
);
// Сохранение метаданных в базу данных
// await db.insert(files).values({ key: fileKey, location: result.Location, status: 'READY' });
return { success: true, location: result.Location, key: fileKey };
}
export async function abortMultipartUploadAction({ fileKey, uploadId }: { fileKey: string; uploadId: string }) {
await s3.send(
new AbortMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: fileKey,
UploadId: uploadId,
})
);
return { aborted: true };
}На клиенте реализуем пул параллельной отправки (concurrency limit = 3) с расчетом процента выполнения и экспоненциальными повторными попытками:
'use client';
import { useState, useRef } from 'react';
import {
initiateMultipartUploadAction,
completeMultipartUploadAction,
abortMultipartUploadAction,
CHUNK_SIZE_BYTES,
} from '@/app/actions/s3-upload';
import { UploadCloud, CheckCircle, AlertCircle, X } from 'lucide-react';
export function MultipartUploader() {
const [progress, setProgress] = useState<number>(0);
const [isUploading, setIsUploading] = useState<boolean>(false);
const [statusMessage, setStatusMessage] = useState<string>('');
const [isDone, setIsDone] = useState<boolean>(false);
const activeSessionRef = useRef<{ fileKey: string; uploadId: string } | null>(null);
const uploadFile = async (file: File) => {
try {
setIsUploading(true);
setIsDone(false);
setProgress(0);
setStatusMessage('Инициализация сессии загрузки в S3...');
// 1. Инициализация через Server Action
const { uploadId, fileKey, partUrls } = await initiateMultipartUploadAction({
fileName: file.name,
fileSizeBytes: file.size,
mimeType: file.type || 'application/octet-stream',
});
activeSessionRef.current = { fileKey, uploadId };
setStatusMessage('Загрузка блоков данных напрямую в S3...');
const completedParts: { PartNumber: number; ETag: string }[] = [];
let uploadedBytes = 0;
// 2. Функция загрузки одного блока с повторными попытками (Retry)
const uploadChunk = async (part: { partNumber: number; presignedUrl: string }) => {
const start = (part.partNumber - 1) * CHUNK_SIZE_BYTES;
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
const chunkBlob = file.slice(start, end);
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
attempts++;
const response = await fetch(part.presignedUrl, {
method: 'PUT',
body: chunkBlob,
headers: {
'Content-Type': file.type || 'application/octet-stream',
},
});
if (!response.ok) {
throw new Error(`Ошибка загрузки части ${part.partNumber}: HTTP ${response.status}`);
}
// S3 возвращает заголовок ETag в кавычках (например: "d41d8cd98f00b204e9800998ecf8427e")
const etag = response.headers.get('ETag');
if (!etag) {
throw new Error(`ETag отсутствует в ответе S3 для части ${part.partNumber}`);
}
completedParts.push({
PartNumber: part.partNumber,
ETag: etag.replace(/"/g, ''), // Санитизируем кавычки
});
uploadedBytes += (end - start);
setProgress(Math.round((uploadedBytes / file.size) * 100));
return;
} catch (err) {
if (attempts >= maxAttempts) throw err;
await new Promise((res) => setTimeout(res, 1000 * Math.pow(2, attempts)));
}
}
};
// 3. Пул параллельности (Concurrency = 3)
const CONCURRENCY_LIMIT = 3;
const queue = [...partUrls];
const activeWorkers: Promise<void>[] = [];
while (queue.length > 0 || activeWorkers.length > 0) {
while (queue.length > 0 && activeWorkers.length < CONCURRENCY_LIMIT) {
const nextPart = queue.shift()!;
const workerPromise = uploadChunk(nextPart).then(() => {
activeWorkers.splice(activeWorkers.indexOf(workerPromise), 1);
});
activeWorkers.push(workerPromise);
}
if (activeWorkers.length > 0) {
await Promise.race(activeWorkers);
}
}
// 4. Финализация сборки объекта на стороне хранилища
setStatusMessage('Финализация и объединение частей на S3...');
await completeMultipartUploadAction({
fileKey,
uploadId,
parts: completedParts,
});
activeSessionRef.current = null;
setIsUploading(false);
setIsDone(true);
setStatusMessage('Файл успешно сохранен!');
} catch (error: unknown) {
console.error('Ошибка выгрузки файла:', error);
setStatusMessage(`Сбой загрузки: ${(error as Error).message}`);
setIsUploading(false);
if (activeSessionRef.current) {
await abortMultipartUploadAction(activeSessionRef.current);
activeSessionRef.current = null;
}
}
};
return (
<div className="max-w-lg p-6 rounded-2xl border border-border bg-bg-surface space-y-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-cyan/10 border border-accent-cyan/20 flex items-center justify-center text-accent-cyan">
<UploadCloud className="w-5 h-5" />
</div>
<div>
<h3 className="text-base font-bold text-text-primary">Direct S3 Multipart Upload</h3>
<p className="text-xs text-text-muted">Загрузка файлов до 10 ГБ в обход лимитов серверов</p>
</div>
</div>
<input
type="file"
disabled={isUploading}
onChange={(e) => {
const selected = e.target.files?.[0];
if (selected) uploadFile(selected);
}}
className="block w-full text-sm text-text-muted file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-accent-cyan file:text-bg-base hover:file:opacity-90 cursor-pointer"
/>
{isUploading && (
<div className="space-y-2">
<div className="flex justify-between text-xs font-mono">
<span className="text-text-muted">{statusMessage}</span>
<span className="text-accent-cyan font-bold">{progress}%</span>
</div>
<div className="w-full h-2 rounded-full bg-bg-elevated overflow-hidden border border-border">
<div
className="h-full bg-accent-cyan transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{isDone && (
<div className="flex items-center gap-2 text-xs text-success bg-success/10 border border-success/20 p-3 rounded-xl">
<CheckCircle className="w-4 h-4 flex-shrink-0" />
<span>{statusMessage}</span>
</div>
)}
</div>
);
}Валидация пользовательских прав, формата файла и доступной квоты организации без касания тела файла.
Генерация уникальных Presigned URLs с ограниченным временем жизни (TTL = 1 час) на каждую часть файла.
Клиент передает бинарные чанки напрямую в сеть провайдера хранилища по протоколу HTTP/2 PUT минуя сервера Next.js.
Хранилище атомарно склеивает части. Срабатывает S3 Event Notification для запуска асинхронных воркеров.
Какой заголовок обязательно должен быть указан в блоке ExposeHeaders политики CORS бакета S3/R2, чтобы клиентский код браузера мог прочитать ETag чанка?
Это самая частая причина поломок direct-загрузок. Запрос PUT успешно возвращает HTTP 200, но в JavaScript вызов response.headers.get('ETag') возвращает null. В результате сервер получает пустой массив ETag, и сборка падает. Обязательно добавьте <ExposeHeader>ETag</ExposeHeader> в CORS-конфигурацию вашего бакета.
AWS S3 строго требует, чтобы массив Parts в CompleteMultipartUploadCommand был отсортирован по строгому возрастанию PartNumber (1, 2, 3...). При параллельной загрузке чанки завершаются в произвольном порядке (например: 2, 1, 3). Если отправить их на сервер без вызова parts.sort((a, b) => a.PartNumber - b.PartNumber), S3 вернет ошибку InvalidPartOrder.
Если пользователь закрыл вкладку во время загрузки 5 ГБ файла, части, успевшие загрузиться в S3, продолжают храниться в бакете и тарифицироваться провайдером, оставаясь невидимыми в списке обычных объектов! Обязательно настраивайте правило жизненного цикла бакета (S3 Lifecycle Rule): AbortIncompleteMultipartUpload с удалением брошенных частей через 1–2 дня.
Создание подписанных ссылок со сроком действия в несколько суток создает уязвимость повторного использования URL злоумышленниками. Для загрузок ограничивайте TTL в 15–60 минут.