Subidas desde el navegador a Cloudflare R2 con el AWS SDK
Cloudflare R2 ofrece a los desarrolladores una solución de almacenamiento de objetos rentable y de alto rendimiento, compatible con la API de S3 pero sin tarifas de egreso. Una capacidad clave para las aplicaciones web es gestionar subidas directas desde el navegador, que pueden reducir la carga del servidor, la latencia y los costos al evitar tener que enviar los archivos a través de tu backend.
En este DevTip, exploraremos cómo aprovechar el AWS SDK para JavaScript (v3) y Cloudflare Workers para habilitar subidas de archivos seguras directamente desde el navegador de un usuario a tu bucket de Cloudflare R2.
Introducción a Cloudflare R2
Cloudflare R2 ofrece almacenamiento de objetos compatible con S3 integrado con la red global de Cloudflare. Su compatibilidad con la API de S3 de AWS significa que puedes usar los SDK y las herramientas de AWS existentes mientras aprovechas el modelo de precios de R2, en particular las tarifas de egreso nulas.
Usar el AWS SDK para subidas desde el navegador
Para interactuar de forma segura con Cloudflare R2 desde el navegador, usaremos un backend (como un servidor Node.js o un Cloudflare Worker) para generar enlaces de subida temporales y seguros llamados URL prefirmadas. El navegador luego usa esas URL para subir archivos directamente a R2.
Primero, instala los paquetes necesarios del AWS SDK v3 en tu proyecto de backend:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Configurar URL prefirmadas para subidas seguras desde el navegador
Las URL prefirmadas otorgan permiso temporal para realizar una acción específica de S3 (como
PutObject) sobre una clave de objeto específica, sin exponer al navegador tus
credenciales secretas de R2.
Aquí tienes un manejador de Node.js/Express para montar detrás de la autenticación, la autorización y la limitación de tasa existentes de tu aplicación. No es un servicio público de firma de URL: solo los usuarios autorizados deberían poder asignar una subida, con cuotas aplicadas por tu aplicación. Este módulo del lado del servidor requiere Node.js 22 o posterior y configuración de ESM.
// backend/presigned-url-generator.js
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { randomUUID } from 'node:crypto'
// Ensure environment variables are set:
// CLOUDFLARE_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME
for (const name of ['CLOUDFLARE_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET_NAME']) {
if (!process.env[name]) throw new Error(`Missing configuration: ${name}`)
}
const R2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
requestChecksumCalculation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID, // Use R2_ACCESS_KEY_ID
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, // Use R2_SECRET_ACCESS_KEY
},
})
const BUCKET_NAME = process.env.R2_BUCKET_NAME
// Example function (adapt for your framework, e.g., Express route handler)
async function generateUploadUrl(req, res) {
// It's crucial to sanitize and validate filenames from user input
const unsafeFilename = req.query.filename
const contentType = req.query.contentType || 'application/octet-stream'
if (
typeof unsafeFilename !== 'string' || unsafeFilename.length === 0 || unsafeFilename.length > 255 ||
typeof contentType !== 'string' || contentType.length > 255 ||
!/^[a-zA-Z0-9!#$&^_.+-]+\/[a-zA-Z0-9!#$&^_.+-]+$/.test(contentType)
) {
return res.status(400).json({ error: 'A valid filename and content type are required' })
}
// Basic sanitization: replace potentially problematic characters
const safeFilename = unsafeFilename.replace(/[^a-zA-Z0-9._-]/g, '_')
const key = `uploads/${randomUUID()}-${safeFilename}`
try {
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key, // Use the sanitized and potentially prefixed key
ContentType: contentType, // Set ContentType for correct handling
})
// Generate the presigned URL, valid for 1 hour (3600 seconds)
const signedUrl = await getSignedUrl(R2, command, {
expiresIn: 3600,
signableHeaders: new Set(['content-type']),
})
res.json({ url: signedUrl, key: key }) // Return the URL and the final key
} catch (error) {
console.error('Unable to generate an upload URL')
res.status(500).json({ error: 'Failed to generate upload URL' })
}
}
// Example usage in an Express app:
// app.get('/api/generate-upload-url', requireSignedInUser, generateUploadUrl);
requireSignedInUser arriba denota el middleware existente de tu aplicación, no una
implementación de esta guía. R2 no admite las ACL public-read de S3. Configura el
acceso al bucket por separado y mantén las subidas privadas hasta validarlas. Las URL prefirmadas
usan el nombre de host de la API de S3 de R2, no un dominio personalizado.
Configura el CORS del bucket para el origen de tu frontend:
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"]
}
]
El encabezado Content-Type debe coincidir con el valor firmado. Sigue siendo
metadatos proporcionados por el cliente, no una prueba del contenido real del archivo. El CORS
permite el acceso desde el navegador; no reemplaza la autorización.
En el frontend, obtienes esta URL prefirmada y luego la usas para subir el archivo seleccionado directamente a R2:
// frontend/uploader.js
async function uploadFileToR2(file) {
const contentType = file.type || 'application/octet-stream'
try {
// 1. Request a presigned URL from your backend
const response = await fetch(
// Pass filename and content type to backend
`/api/generate-upload-url?filename=${encodeURIComponent(file.name)}&contentType=${encodeURIComponent(contentType)}`,
)
if (!response.ok) {
throw new Error(`Failed to get upload URL: ${response.status}`)
}
const { url, key } = await response.json() // Get URL and the final object key
console.log(`Received presigned URL for key: ${key}`)
// 2. Upload the file directly to R2 using the presigned URL
const uploadResponse = await fetch(url, {
method: 'PUT',
body: file,
headers: {
// Content-Type must match what was used to generate the presigned URL if specified
'Content-Type': contentType,
},
})
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.status}`)
}
console.log(`File uploaded successfully! Object key: ${key}`)
return { success: true, key: key }
} catch (error) {
console.error('R2 upload failed')
return { success: false, error: 'Unable to complete the upload' }
}
}
// Example usage with a file input element
document.getElementById('fileInput').addEventListener('change', async (event) => {
const file = event.target.files[0]
if (file) {
const uploadProgress = document.getElementById('uploadProgress')
uploadProgress.textContent = 'Uploading...'
const result = await uploadFileToR2(file)
if (result.success) {
uploadProgress.textContent = `Upload complete! Key: ${result.key}`
// Optionally display the file URL if the bucket is public or served via Worker
// e.g., `https://your-public-bucket-domain/${result.key}`
// or `https://your-worker-domain/${result.key}`
} else {
uploadProgress.textContent = `Upload failed: ${result.error}`
}
}
})
Gestionar subidas de archivos grandes con subida multiparte
Para archivos más grandes o conexiones poco fiables, considera las subidas multiparte de S3. Esto divide el archivo en fragmentos más pequeños, lo que permite subidas en paralelo, reintentos de las partes fallidas y pausar o reanudar las subidas.
Implementar subidas multiparte directamente desde el navegador es más complejo y a menudo implica:
- Endpoint de backend para iniciar la subida multiparte (
CreateMultipartUploadCommand) y devolver unUploadId. - Endpoint(s) de backend para generar URL prefirmadas para cada parte (
UploadPartCommand). - Lógica de frontend para dividir el archivo, solicitar URL prefirmadas para las partes, subir las partes y hacer seguimiento del progreso.
- Endpoint de backend para finalizar la subida (
CompleteMultipartUploadCommand) una vez que todas las partes se hayan subido.
Las siguientes primitivas del lado del servidor usan el cliente R2 y BUCKET_NAME de arriba. Los
manejadores de rutas deben vincular cada ID de subida y clave a su propietario, validar los números
de parte y los ETag, y aplicar cuotas antes de llamarlas. Estas funciones por sí solas no son una
API multiparte autenticada.
// backend/multipart-handler.js
import {
S3Client,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand, // Important for cleanup
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
// Assume R2 S3Client is configured as shown previously
// const R2 = new S3Client({...});
// const BUCKET_NAME = process.env.R2_BUCKET_NAME;
async function initiateMultipartUpload(key, contentType) {
const command = new CreateMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: key,
ContentType: contentType,
})
const response = await R2.send(command)
if (!response.UploadId) throw new Error('R2 did not return an upload ID')
return response.UploadId
}
async function getMultipartPresignedUrl(key, uploadId, partNumber) {
const command = new UploadPartCommand({
Bucket: BUCKET_NAME,
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
})
// Generate presigned URL for uploading a specific part
const signedUrl = await getSignedUrl(R2, command, { expiresIn: 3600 }) // 1 hour expiry
return signedUrl
}
async function completeMultipartUpload(key, uploadId, parts) {
// 'parts' should be an array of { ETag: string, PartNumber: number }
// The ETag is returned by R2 in the header of a successful part upload
const command = new CompleteMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.toSorted((a, b) => a.PartNumber - b.PartNumber),
},
})
return await R2.send(command)
}
async function abortMultipartUpload(key, uploadId) {
const command = new AbortMultipartUploadCommand({
Bucket: BUCKET_NAME,
Key: key,
UploadId: uploadId,
})
return await R2.send(command)
}
// You would need API endpoints calling these functions
// e.g., POST /api/uploads/initiate, GET /api/uploads/:uploadId/part/:partNumber, POST /api/uploads/:uploadId/complete
Bibliotecas como Uppy pueden simplificar la implementación de subidas multiparte en el frontend, ya que se encargan de la fragmentación del archivo, las solicitudes de firma de las partes y la gestión de la subida.
Gestionar buckets de R2 con la CLI de Wrangler
Cloudflare ofrece la herramienta de CLI wrangler para gestionar recursos de Cloudflare, incluidos
los buckets de R2.
Instálala de forma global:
npm install -g wrangler
Inicia sesión en tu cuenta de Cloudflare:
wrangler login
Ahora puedes gestionar tus buckets de R2:
# Create a bucket with a name unique within your account
wrangler r2 bucket create your-unique-bucket-name
# List all buckets associated with your account
wrangler r2 bucket list
# Upload a file from your local machine
wrangler r2 object put your-unique-bucket-name/path/to/object.txt --remote --file ./local-file.txt --content-type "text/plain"
# Download an object
wrangler r2 object get your-unique-bucket-name/path/to/object.txt --remote --file ./downloaded-file.txt
# Delete an object
wrangler r2 object delete your-unique-bucket-name/path/to/object.txt --remote
Los comandos de objetos de Wrangler 4 usan el almacenamiento local de forma predeterminada si no se
indica --remote. Usa el panel de R2 o la API ListObjectsV2 de S3 para listar objetos;
wrangler r2 bucket list lista buckets, no objetos.
Configuraciones avanzadas con Cloudflare Workers
Los Cloudflare Workers te permiten ejecutar código JavaScript en el edge, lo que habilita lógica personalizada para acceder a tus buckets de R2, como autenticación, enrutamiento o entrega de contenido privado.
El siguiente Worker es una interfaz administrativa aparte, de servidor a servidor. Protege todas las operaciones, incluidas las descargas, con un único secreto que otorga acceso a todo el bucket vinculado. Nunca incluyas este secreto en el JavaScript del navegador. Las aplicaciones de navegador deberían usar el flujo prefirmado de alcance acotado anterior o integrar con el Worker la autorización de sesión de usuario que ya tienen. Este ejemplo no implementa el acceso al navegador entre orígenes.
// worker/src/index.js
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url)
// Remove leading slash from pathname to get the object key
const key = url.pathname.slice(1)
// Ensure the R2 bucket binding 'MY_BUCKET' is configured in wrangler.toml
if (!env.MY_BUCKET || !env.ADMIN_TOKEN) {
return new Response('Service unavailable', { status: 503 })
}
if (!isAuthenticated(request, env)) {
return new Response('Unauthorized', { status: 401 })
}
if (!key) {
return new Response('An object key is required', { status: 400 })
}
switch (request.method) {
case 'PUT':
case 'POST': // Handle POST as PUT for simplicity here
// Stream the request body directly to R2
try {
const object = await env.MY_BUCKET.put(key, request.body, {
httpMetadata: request.headers, // Pass client headers (like Content-Type) to R2
})
// Return a success response, potentially with the object details
return new Response(null, {
status: 200,
headers: { ETag: object.httpEtag },
})
} catch (e) {
return new Response('Upload failed', { status: 500 })
}
case 'GET':
// Retrieve the object from R2
const object = await env.MY_BUCKET.get(key)
if (object === null) {
return new Response('Object Not Found', { status: 404 })
}
// Set necessary response headers from the object's metadata
const headers = new Headers()
object.writeHttpMetadata(headers) // Copies Content-Type, etc.
headers.set('etag', object.httpEtag) // Set ETag for caching
headers.set('Cache-Control', 'private, no-store')
headers.set('Content-Disposition', 'attachment')
headers.set('X-Content-Type-Options', 'nosniff')
// Stream the object body back to the client
return new Response(object.body, {
headers,
})
case 'DELETE':
try {
await env.MY_BUCKET.delete(key)
return new Response(null, { status: 204 }) // No Content
} catch (e) {
return new Response('Delete failed', { status: 500 })
}
default:
return new Response('Method Not Allowed', { status: 405 })
}
},
}
function isAuthenticated(request, env) {
const authHeader = request.headers.get('Authorization')
return authHeader === `Bearer ${env.ADMIN_TOKEN}`
}
Para desplegar este Worker, configura tu wrangler.toml para vincular tu bucket de R2:
# wrangler.toml
name = "r2-file-server-worker"
main = "src/index.js" # Path to your worker script
compatibility_date = "2026-09-11"
# Bind the R2 bucket to MY_BUCKET in the Worker
[[r2_buckets]]
binding = "MY_BUCKET" # Variable name available in the Worker (env.MY_BUCKET)
bucket_name = "your-unique-bucket-name"
Establece un token administrativo robusto con el prompt de secretos de Wrangler y luego despliega:
wrangler secret put ADMIN_TOKEN
wrangler deploy
Configurar dominios personalizados
Puedes entregar tu contenido de R2 a través de un dominio personalizado (p. ej.,
files.yourdomain.com) en lugar de las URL públicas predeterminadas de Cloudflare Worker o R2.
- Asegúrate de que tu dominio (
yourdomain.com) esté gestionado por Cloudflare. - Despliega un Cloudflare Worker (como el del ejemplo anterior) que entregue contenido desde tu bucket de R2.
- En el panel de Cloudflare, ve a tu Worker y añade un «Custom Domain» o añade una ruta en
«Workers & Pages» -> tu dominio -> «Workers Routes», apuntando una ruta específica (p. ej.,
files.yourdomain.com/*) a tu servicio Worker desplegado.
Esta configuración te permite controlar el acceso, añadir encabezados de caché y, si lo necesitas, reescribir URL, todo servido bajo tu dominio de marca.
Manejo de errores y reintentos
Los problemas de red pueden interrumpir las subidas. El envoltorio de reintentos básico que aparece abajo solicita una clave nueva en cada intento. Por eso, una respuesta perdida después de un PUT exitoso puede dejar un objeto adicional: úsalo solo junto con una política de limpieza en tu aplicación. Un flujo de reintentos de producción debería reutilizar una clave ya asignada y hacer seguimiento de la finalización en el servidor, o usar un cargador multiparte consolidado. Reemplaza el listener de cambios original por el listener de abajo; no registres ambos.
// frontend/uploader.js - (Simplified retry logic for PUT example)
async function uploadFileWithRetry(file, maxRetries = 3) {
let attempt = 0
while (attempt <= maxRetries) {
console.log(`Upload attempt ${attempt + 1} of ${maxRetries + 1}...`)
const result = await uploadFileToR2(file) // Use the function defined earlier
if (result.success) {
return result // Success!
}
console.error(`Attempt ${attempt + 1} failed: ${result.error}`)
attempt++
if (attempt <= maxRetries) {
// Exponential backoff: 1s, 2s, 4s...
const delay = Math.pow(2, attempt - 1) * 1000
console.log(`Retrying in ${delay / 1000} seconds...`)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
console.error(`Upload failed after ${maxRetries + 1} attempts.`)
return { success: false, error: `Upload failed after ${maxRetries + 1} attempts` }
}
// Modify the event listener to use the retry function:
document.getElementById('fileInput').addEventListener('change', async (event) => {
const file = event.target.files[0]
if (file) {
const uploadProgress = document.getElementById('uploadProgress')
uploadProgress.textContent = 'Uploading...'
// Use the retry wrapper
const result = await uploadFileWithRetry(file)
// ... (update UI based on final result) ...
if (result.success) {
uploadProgress.textContent = `Upload complete! Key: ${result.key}`
} else {
uploadProgress.textContent = `Upload failed: ${result.error}`
}
}
})
Casos de uso prácticos
Las subidas directas desde el navegador a Cloudflare R2 son útiles para:
- Fotos de perfil de usuario y avatares.
- Galerías de imágenes y plataformas para compartir archivos multimedia.
- Formularios de envío de documentos.
- Sitios de contenido generado por usuarios.
- Alojamiento de recursos estáticos donde los usuarios suben contenido directamente.
Entre los beneficios se incluyen:
- Menor carga del servidor: tu backend solo genera URL prefirmadas, sin actuar de proxy para archivos grandes.
- Menor latencia: los usuarios suben directamente al edge de Cloudflare, más cerca de ellos.
- Ahorro de costos: evita las tarifas de egreso de R2 y reduce tus costos de ancho de banda del servidor.
- Arquitectura simplificada: menos piezas móviles en comparación con enviar las subidas mediante un proxy.
Conclusión
Usar el AWS SDK v3 con URL prefirmadas ofrece un método seguro y eficiente para habilitar subidas directas desde el navegador a Cloudflare R2. Combinado con Cloudflare Workers para un control avanzado y la CLI de Wrangler para la gestión, puedes integrar en tus aplicaciones web un manejo de archivos robusto y escalable, mientras aprovechas el almacenamiento rentable de R2.
Para escenarios más complejos que impliquen el procesamiento de archivos después de la subida, considera un servicio como Transloadit. Transloadit se integra sin problemas con Cloudflare R2 mediante nuestro Robot 🤖 /cloudflare/store, lo que te permite activar encoding, redimensionado, marcas de agua y más a medida que los archivos llegan a tu bucket de R2. Nuestro SDK de Uppy también ofrece una experiencia robusta de subida de archivos en el frontend, con soporte para subidas multiparte a distintos destinos.
