Subidas por bloques a GCS desde el navegador con JavaScript
Google Cloud Storage (GCS) permite subir un único objeto mediante varias solicitudes con su protocolo de subida reanudable. En este tutorial crearás una herramienta local de subida para un único propietario: Node.js autentica al propietario y crea una sesión; después, el navegador envía los bloques del archivo directamente a GCS. Admite archivos de hasta 10 GiB, muestra el progreso tras la confirmación de cada bloque y permite pausar y reanudar mientras la página permanezca abierta.
¿Por qué subir archivos por bloques?
Una subida reanudable lleva un registro de los bytes que GCS ha recibido. Tras un fallo de conexión, el navegador consulta esa posición y continúa desde allí. Los bloques pertenecen a un único objeto; no hay objetos temporales que combinar o eliminar, ni un límite de 32 componentes para combinarlos.
Configura Google Cloud Storage
Usa Node.js 24.2 o posterior, Yarn, la CLI de Google Cloud y un bucket privado. Asigna a la identidad
del servidor el rol roles/storage.objectCreator limitado al bucket: este ejemplo solo crea objetos nuevos.
Para el desarrollo local, configura Application Default Credentials con una identidad que tenga ese
permiso:
gcloud auth application-default login
mkdir gcs-upload
cd gcs-upload
corepack yarn init
corepack yarn config set nodeLinker node-modules
corepack yarn add --exact google-auth-library@11.0.0
La API JSON gestiona CORS independientemente de las reglas CORS del bucket. El servidor incluye el origen exacto del navegador al crear la sesión para que las solicitudes posteriores del navegador reciban encabezados CORS. La configuración de CORS no autentica a quien sube archivos.
Servidor: crea sesiones de subida autenticadas
Guarda lo siguiente como server.mjs. El token configurado identifica al único propietario de
esta herramienta local. Cada sesión requiere ese token y el servidor elige un nombre de objeto
impredecible dentro de uploads/owner/. Los clientes no pueden proporcionar nombres de buckets,
rutas de destino ni nombres de objetos para eliminar.
La precondición ifGenerationMatch=0 también impide sobrescribir un objeto existente.
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { GoogleAuth } from 'google-auth-library'
const ORIGIN = 'http://127.0.0.1:8080'
const MAX_SIZE = 10 * 1024 ** 3
const assets = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/upload.mjs', ['upload.mjs', 'text/javascript; charset=utf-8']],
['/app.mjs', ['app.mjs', 'text/javascript; charset=utf-8']],
])
export function createUploadServer({ auth, bucket, token }) {
if (!bucket || !token || token.length < 32) throw new Error('Set BUCKET and a 32-character token')
const digest = (value) => createHash('sha256').update(value).digest()
const expected = digest(`Bearer ${token}`)
return createServer((req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Referrer-Policy', 'no-referrer')
const reply = (status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function handle() {
const asset = assets.get(req.url)
if (req.method === 'GET' && asset) {
const body = await readFile(new URL(asset[0], import.meta.url))
res.writeHead(200, { 'Content-Type': asset[1] })
res.end(body)
return
}
if (req.method !== 'POST' || req.url !== '/sessions') return reply(404, { error: 'Not found' })
if (!timingSafeEqual(expected, digest(req.headers.authorization ?? ''))) {
return reply(401, { error: 'Invalid upload token' })
}
if (req.headers.origin !== ORIGIN) return reply(403, { error: 'Invalid origin' })
const chunks = []
let bytes = 0
for await (const chunk of req) {
bytes += chunk.length
if (bytes > 1024) return reply(413, { error: 'Request too large' })
chunks.push(chunk)
}
let input
try {
input = JSON.parse(Buffer.concat(chunks, bytes).toString('utf8'))
} catch {
return reply(400, { error: 'Invalid JSON' })
}
const size = input?.size
if (!Number.isSafeInteger(size) || size < 1 || size > MAX_SIZE) {
return reply(400, { error: 'Choose a nonempty file of at most 10 GiB' })
}
const name = `uploads/owner/${randomUUID()}`
const url = new URL(`https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o`)
url.search = new URLSearchParams({ uploadType: 'resumable', name, ifGenerationMatch: '0' })
const client = await auth.getClient()
const response = await client.request({
url: url.href,
method: 'POST',
headers: { Origin: ORIGIN, 'X-Upload-Content-Length': String(size) },
data: { contentType: 'application/octet-stream' },
timeout: 30_000,
retry: false,
})
const sessionUrl = response.headers.get('location')
if (!sessionUrl || new URL(sessionUrl).origin !== 'https://storage.googleapis.com') {
throw new Error('Invalid session response')
}
reply(201, { sessionUrl, name })
}
handle().catch(() => reply(502, { error: 'Could not create upload session' }))
})
}
if (import.meta.main) {
const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/devstorage.read_write'] })
createUploadServer({ auth, bucket: process.env.BUCKET, token: process.env.UPLOAD_TOKEN })
.listen(8080, '127.0.0.1', () => console.log(`Open ${ORIGIN}`))
}
La Google Auth Library mantiene las credenciales de Google en el servidor. La URL de sesión devuelta es en sí misma una credencial de portador: cualquiera que la tenga puede subir archivos a ese único destino. No es una URL firmada de 15 minutos; las sesiones reanudables de GCS caducan al cabo de una semana. Mantén las URL de sesión fuera de los registros y del almacenamiento compartido.
Cliente: una herramienta mínima de subida por bloques
Guarda lo siguiente como upload.mjs. El tamaño de bloque de 8 MiB es múltiplo de los 256 KiB
que exige GCS para la alineación; el último bloque puede ser más pequeño.
Una respuesta 308 indica que la subida está incompleta, por lo que el código la
procesa antes de la comprobación habitual de errores HTTP. Lee el rango de bytes confirmado en
lugar de suponer que GCS aceptó la solicitud completa. Consulta el
protocolo de subida reanudable.
const CHUNK_SIZE = 8 * 1024 * 1024
export async function uploadFileWithChunks(file, sessionUrl, { signal, onProgress = () => {} } = {}) {
if (file.size < 1 || file.size > 10 * 1024 ** 3) throw new Error('Invalid file size')
let offset = 0
let probe = true
let failures = 0
while (true) {
signal?.throwIfAborted()
const end = Math.min(offset + CHUNK_SIZE, file.size)
let response
try {
response = await fetch(sessionUrl, {
method: 'PUT',
credentials: 'omit',
signal,
headers: {
'Content-Range': probe ? `bytes */${file.size}` : `bytes ${offset}-${end - 1}/${file.size}`,
},
body: probe ? new Blob([]) : file.slice(offset, end),
})
} catch (error) {
signal?.throwIfAborted()
if (++failures > 5) throw error
await backoff(failures, signal)
probe = true
continue
}
await response.body?.cancel()
if (response.status === 200 || response.status === 201) {
onProgress(100)
return
}
if (response.status === 429 || response.status >= 500) {
if (++failures > 5) throw new Error('Upload retries exhausted')
await backoff(failures, signal)
probe = true
continue
}
if (response.status !== 308) throw new Error(`Upload failed (HTTP ${response.status})`)
const range = response.headers.get('range')
const match = range === null ? null : /^bytes=0-(\d+)$/.exec(range)
if (range !== null && !match) throw new Error('Invalid acknowledged range')
const next = match ? Number(match[1]) + 1 : 0
if (!Number.isSafeInteger(next) || next < offset || next >= file.size || (!probe && next > end)) {
throw new Error('Invalid acknowledged position')
}
if (!probe && next === offset) {
if (++failures > 5) throw new Error('Upload made no progress')
await backoff(failures, signal)
probe = true
continue
}
if (next > offset) failures = 0
offset = next
onProgress((offset / file.size) * 100)
probe = false
}
}
function backoff(attempt, signal) {
signal?.throwIfAborted()
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', abort)
resolve()
}, 500 * 2 ** (attempt - 1))
signal?.addEventListener('abort', abort, { once: true })
})
}
Reintenta con retardo exponencial
Los errores de red, los límites de frecuencia y los errores del servidor provocan hasta cinco
reintentos entre actualizaciones del progreso. Cada reintento consulta primero la posición
almacenada con una solicitud PUT vacía. Una pausa cancela tanto la solicitud activa
como cualquier espera entre reintentos. Los fallos de permisos, las sesiones caducadas y los rangos
mal formados detienen la subida en lugar de generar un bucle infinito. Si se perdió la respuesta
final, la siguiente consulta de estado puede confirmar que la subida se completó sin volver a subir
el archivo.
Guarda la página como index.html:
<!doctype html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Resumable GCS upload</title>
<label>Upload token <input id="token" type="password" autocomplete="off" /></label>
<label>File <input id="file" type="file" /></label>
<button id="upload">Upload / resume</button>
<button id="pause" disabled>Pause</button>
<progress id="progress" max="100" value="0" aria-label="Upload progress"></progress>
<p id="status" role="status">Choose a file.</p>
<script type="module" src="/app.mjs"></script>
</html>
Guarda los controles como app.mjs:
import { uploadFileWithChunks } from './upload.mjs'
const fileInput = document.getElementById('file')
const tokenInput = document.getElementById('token')
const upload = document.getElementById('upload')
const pause = document.getElementById('pause')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
let session
let controller
fileInput.addEventListener('change', () => {
session = undefined
progress.value = 0
})
pause.addEventListener('click', () => controller?.abort())
upload.addEventListener('click', async () => {
const file = fileInput.files[0]
if (!file) return
controller = new AbortController()
upload.disabled = fileInput.disabled = tokenInput.disabled = true
pause.disabled = false
status.textContent = 'Uploading…'
try {
if (!session) {
const response = await fetch('/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokenInput.value}` },
body: JSON.stringify({ size: file.size }),
signal: controller.signal,
})
if (!response.ok) throw new Error(`Session failed (HTTP ${response.status})`)
session = await response.json()
}
await uploadFileWithChunks(file, session.sessionUrl, {
signal: controller.signal,
onProgress: (value) => { progress.value = value },
})
status.textContent = `Uploaded to ${session.name}`
session = undefined
} catch {
status.textContent = controller.signal.aborted
? 'Paused. Click Upload / resume to continue.'
: 'Upload failed. Retry, or reselect the file to start a new session.'
} finally {
upload.disabled = fileInput.disabled = tokenInput.disabled = false
pause.disabled = true
}
})
Inicia el servidor en el mismo directorio después de configurar el nombre de tu bucket existente e
introducir un token secreto de al menos 32 caracteres. Introduce el mismo token en la página en
http://127.0.0.1:8080:
export BUCKET='your-existing-private-bucket'
read -r -s -p 'Upload token: ' UPLOAD_TOKEN
export UPLOAD_TOKEN
node server.mjs
Conserva el progreso entre recargas
Este ejemplo conserva deliberadamente el archivo y la sesión solo en memoria. Al pausar, ambos se mantienen; al recargar la página o seleccionar otro archivo, se inicia una sesión nueva. Las sesiones incompletas abandonadas caducan sin crear objetos temporales para los bloques.
Para admitir recargas en una aplicación multiusuario, almacena las sesiones en el servidor asociadas a los ID de usuarios autenticados y devuelve cada sesión únicamente a su propietario. Exige que el usuario vuelva a seleccionar el mismo archivo y verifica su identidad por el contenido, no solo por su nombre y tamaño, antes de reanudar. Consulta siempre a GCS la posición de los bytes; un porcentaje de progreso guardado localmente no es una fuente de verdad.
Buenas prácticas de seguridad y rendimiento
El token autentica a un único propietario de confianza; no es un sistema de inicio de sesión multiusuario. Mantén esta demostración vinculada a la interfaz de bucle local. Antes de desplegarla, integra la autenticación y las cuotas de tu aplicación, sírvela mediante HTTPS y mantén el espacio de nombres de los objetos vinculado al usuario autenticado. Trata los bytes subidos como datos no confiables; el tipo de contenido declarado de un archivo no valida su contenido.
El ejemplo limita cada sesión a 10 GiB y solicita esa longitud al iniciar la subida. Mantén el bucket privado y verifica los metadatos de los objetos completados antes de poner un archivo a disposición de procesos posteriores. El navegador lee solo un bloque a la vez; las actualizaciones del progreso informan de los bytes confirmados, no de los que aún están en tránsito. GCS almacena directamente el objeto final, por lo que ningún endpoint de limpieza necesita permisos para eliminar objetos arbitrarios.
Transloadit también puede ayudarte
Para procesar los archivos después de subirlos, el Robot
🤖 /google/import de Transloadit puede importarlos mediante
credenciales de Template.
Uppy ofrece interfaces de subida para aplicaciones que necesitan más que esta
demostración mínima.
