Subidas de archivos resumibles y en chunks con Laravel, Next.js y Redis: guía paso a paso
Escenario real: el agente te dice “subida completa” y el archivo se pierde
Imagina que tu asistente de IA está procesando un video de 2 GB que el usuario acaba de subir. El agente muestra el mensaje “¡Todo listo!” y, al intentar reproducir el video, descubres que el archivo está corrupto o falta parte de él. No hay orquestador ni cola que garantice la entrega; la única pieza de infraestructura que controla el flujo es el propio agente que reporta el estado.
En este punto la solución no es “agregar más logs” sino asegurarse de que la subida sea resumible y en chunks. Si el agente puede preguntar “¿está completo?” y la respuesta proviene de un estado persistido, el error desaparece.
Regla transferible
Todo proceso de subida debe exponer un punto de control idempotente que el agente pueda consultar en cualquier momento para saber cuántos bytes ya fueron recibidos y, si falta algo, reanudar desde ese punto.
Esta regla se aplica tanto a Laravel como a Next.js. El resto del artículo muestra cómo implementarla con Redis como store temporal, Tailwind CSS para la UI y TypeScript para tipar la lógica del cliente.
Arquitectura mínima
- Frontend (Next.js + TypeScript + Tailwind): divide el archivo en chunks, envía cada chunk a una API y muestra progreso.
- Backend (Laravel): recibe chunks, los guarda en una carpeta temporal y actualiza un registro en Redis con el offset actual.
- Redis: almacena
{uploadId: {receivedBytes, totalBytes, expiresAt}}para que el agente pueda consultar el estado.
1. Generar un identificador de subida en Laravel
El primer paso es crear una ruta que devuelva un uploadId único y la información básica del archivo. El agente llama a esta API antes de iniciar la transferencia.
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Redis;
use Illuminate\Http\Request;
Route::post('/api/upload/init', function (Request $request) {
$request->validate([
'filename' => 'required|string',
'totalBytes' => 'required|integer|min:1',
]);
$uploadId = (string) Str::uuid();
$data = [
'filename' => $request->filename,
'totalBytes' => $request->totalBytes,
'received' => 0,
'expiresAt' => now()->addHours(2)->timestamp,
];
Redis::setex("upload:$uploadId", 7200, json_encode($data));
return response()->json(['uploadId' => $uploadId]);
});
El agente guarda uploadId en su contexto y lo usa para cada chunk posterior.
2. Endpoint para recibir chunks
Laravel necesita aceptar peticiones POST con multipart/form-data. Cada petición lleva:
uploadIdchunkIndex(opcional, solo para orden)chunk(el blob)offset(bytes ya recibidos antes de este chunk)
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Redis;
Route::post('/api/upload/chunk', function (Request $request) {
$request->validate([
'uploadId' => 'required|string',
'offset' => 'required|integer|min:0',
'chunk' => 'required|file',
]);
$key = "upload:{$request->uploadId}";
$meta = json_decode(Redis::get($key), true);
if (!$meta) {
return response()->json(['error' => 'Upload no encontrado'], 404);
}
// Guardar el chunk en un archivo temporal
$tempPath = "uploads/tmp/{$request->uploadId}";
Storage::makeDirectory($tempPath);
$chunkPath = "$tempPath/{$request->offset}";
Storage::put($chunkPath, $request->file('chunk')->get());
// Actualizar offset en Redis
$meta['received'] = $request->offset + $request->file('chunk')->getSize();
Redis::setex($key, 7200, json_encode($meta));
// Si ya recibimos todo, ensamblar
if ($meta['received'] >= $meta['totalBytes']) {
$finalPath = "uploads/complete/{$meta['filename']}";
$handle = fopen(Storage::path($finalPath), 'w');
for ($i = 0; $i < $meta['received']; $i += $request->file('chunk')->getSize()) {
$part = Storage::get("$tempPath/{$i}");
fwrite($handle, $part);
}
fclose($handle);
Storage::deleteDirectory($tempPath);
Redis::del($key);
return response()->json(['status' => 'completed']);
}
return response()->json(['received' => $meta['received']]);
});
El agente solo necesita saber cuántos bytes fueron aceptados; el backend se encarga de ensamblar cuando la suma coincide con totalBytes.
3. Consultar el estado desde el agente (Redis)
En muchos casos el agente no está en la misma red que Laravel, pero puede hacer una petición HTTP a un endpoint de “status”.
Route::get('/api/upload/status/{uploadId}', function ($uploadId) {
$key = "upload:$uploadId";
$meta = Redis::get($key);
if (!$meta) {
return response()->json(['error' => 'No existe'], 404);
}
return response()->json(json_decode($meta, true));
});
El agente llama a este endpoint antes de cada intento de envío. Si la respuesta indica que ya se recibieron 5 MB, el cliente envía el siguiente chunk a partir de ese offset.
4. Frontend en Next.js: UI resumible con Tailwind
El componente Uploader.tsx muestra un input de tipo file, un botón de “Reanudar” y una barra de progreso.
import { useState, useRef } from 'react';
import axios from 'axios';
export default function Uploader() {
const [progress, setProgress] = useState(0);
const [uploadId, setUploadId] = useState<string | null>(null);
const fileRef = useRef<File | null>(null);
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB
const startUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
fileRef.current = file;
// 1️⃣ Obtener uploadId
const init = await axios.post('/api/upload/init', {
filename: file.name,
totalBytes: file.size,
});
setUploadId(init.data.uploadId);
// 2️⃣ Enviar chunks
await sendChunks(init.data.uploadId, 0);
};
const sendChunks = async (id: string, start: number) => {
const file = fileRef.current!;
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
const form = new FormData();
form.append('uploadId', id);
form.append('offset', start.toString());
form.append('chunk', blob);
const resp = await axios.post('/api/upload/chunk', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const received = resp.data.received;
setProgress(Math.round((received / file.size) * 100));
if (received < file.size) {
// Recursivo hasta completar
await sendChunks(id, received);
} else {
alert('Subida completa');
}
};
const resumeUpload = async () => {
if (!uploadId) return;
const status = await axios.get(`/api/upload/status/${uploadId}`);
const received = status.data.received;
await sendChunks(uploadId, received);
};
return (
<div className="max-w-lg mx-auto p-4">
<input
type="file"
className="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-indigo-50 file:text-indigo-700
hover:file:bg-indigo-100"
onChange={startUpload}
/>
{uploadId && (
<button
onClick={resumeUpload}
className="mt-2 px-4 py-2 bg-yellow-500 text-white rounded"
>
Reanudar
</button>
)}
<div className="mt-4 w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-indigo-600 h-2.5 rounded-full"
style={{ width: `${progress}%` }}
></div>
</div>
<p className="mt-2 text-sm text-gray-600">{progress}%</p>
</div>
);
}
El botón “Reanudar” solo aparece si ya existe un uploadId. Cuando el agente vuelve a lanzar la UI (por ejemplo, después de un reinicio del navegador), puede leer uploadId de localStorage y llamar a resumeUpload automáticamente.
5. Manejo de expiración y limpieza
Redis mantiene la información solo por 2 horas (configurable). Si el agente no reanuda antes de ese tiempo, el backend elimina la carpeta temporal y el agente debe iniciar una nueva subida. Añade un listener en Laravel que escuche eventos de expiración:
Redis::expire('upload:' . $uploadId, 7200); // refresca cada chunk
En el cliente, antes de intentar resumeUpload, verifica que el estado no sea 404. Si lo es, muestra al usuario “La sesión expiró, comienza de nuevo”.
6. Seguridad básica
- Limita el tamaño máximo de chunk (ej. 5 MiB) para evitar ataques de denegación de servicio.
- Valida el
filenamecontra una lista blanca de extensiones. - Utiliza signed URLs o tokens JWT para que solo agentes autenticados puedan iniciar o consultar subidas.
7. Integración con el agente de IA
El agente no necesita saber nada de HTTP ni de Redis; solo llama a dos métodos abstractos:
async function iniciarSubida(file: File): Promise<string> {
// llama a /api/upload/init y devuelve uploadId
}
async function consultarEstado(uploadId: string): Promise<{received: number, total: number}> {
// llama a /api/upload/status/{uploadId}
}
El flujo del agente queda así:
- Detecta que el usuario quiere subir un archivo.
- Llama a
iniciarSubiday guardauploadIden su memoria de corto plazo. - Envía chunks usando la lógica del componente
Uploader. - Después de cada chunk, pregunta
consultarEstado. Si el número de bytes recibidos coincide con el total, el agente avanza al siguiente paso del workflow.
Así, el mensaje “Subida completa” proviene de una verificación explícita del estado, no de una suposición basada en la finalización del bucle de envío.
8. Pruebas rápidas
Para validar que la regla de idempotencia funciona, ejecuta estos pasos manuales:
- Sube un archivo grande (p.ej. 500 MB) y corta la conexión a mitad de proceso.
- Recarga la página; el botón “Reanudar” debe aparecer y el progreso debe retomar desde el último offset reportado por
/api/upload/status. - Verifica en Redis que la clave
upload:{id}contiene el número correcto de bytes recibidos. - Una vez completada la subida, asegúrate de que la clave haya sido eliminada y que el archivo final exista en
storage/app/uploads/complete.
Conclusión práctica
Aplicar la regla de punto de control idempotente permite que cualquier agente que solo sabe “subida completa” obtenga una respuesta fiable sin necesidad de orquestadores complejos. Con Laravel como receptor, Redis como store de estado y Next.js + TypeScript como cliente, puedes implementar subidas resumibles en menos de 200 líneas de código y con una UI ligera de Tailwind.
Si tu próximo caso de uso implica procesamiento en tiempo real (por ejemplo, transcodificación de video mientras llega), extiende el mismo patrón: cada chunk actualizado dispara un evento en Redis Streams y tu worker puede consumirlo sin esperar a que la subida termine.