Subidas a Google Storage por bloques con JavaScript del navegador
Envía archivos grandes directamente desde tu navegador a un bucket privado de Google Cloud Storage (GCS), sin que el archivo pase por Node.js. El servidor autentica a un único propietario y abre una sesión de subida reanudable; el navegador envía bloques alineados y consulta a GCS desde dónde continuar después de una interrupción. El ejemplo incluye pausa y reanudación en la misma página abierta y una verificación independiente de los bytes al finalizar.
¿Por qué subir por bloques?
Una conexión fallida puede dejar más bytes almacenados de los que el navegador ha confirmado. El protocolo reanudable de GCS permite que el navegador consulte esa posición aceptada antes de enviar más datos. Estas solicitudes construyen un único objeto, por lo que no hay objetos de bloques separados que combinar o eliminar.
Configura Google Cloud Storage
Usa Bash en Linux o macOS, una versión actual de Node.js 24 LTS, Corepack y un bucket privado existente. La configuración de paquetes que aparece a continuación fija Yarn 4.18.1 y Google Auth Library 11.3.0. Node.js 24.22.0 es la versión LTS probada; sigue instalando las actualizaciones de seguridad mantenidas de esa versión principal.
El servidor necesita storage.objects.create en el bucket. El rol
roles/storage.objectCreator, limitado al bucket, proporciona ese permiso para este ejemplo que
solo permite crear objetos. La verificación posterior de bytes también necesita
storage.objects.get, disponible mediante roles/storage.objectViewer. Consulta los
permisos necesarios para subir archivos de Google.
Usa Application Default Credentials (ADC) existentes
con esos permisos. Si ADC no está configurado, instala Google Cloud CLI y ejecuta
gcloud auth application-default login antes de iniciar el servidor. Iniciar sesión correctamente no
basta para obtener acceso a tu bucket.
Pega esto en el directorio donde quieras crear el proyecto gcs-upload.
No permite usar un directorio existente, mantiene tu shell en el directorio actual y proporciona
al proyecto su propia configuración de Yarn y su archivo de bloqueo:
(
set -eu
command -v node >/dev/null || { printf 'Install Node.js first.\n' >&2; exit 1; }
command -v corepack >/dev/null || { printf 'Install Corepack first.\n' >&2; exit 1; }
mkdir gcs-upload
cd gcs-upload
printf '%s\n' '{"name":"gcs-upload","private":true,"type":"module","packageManager":"yarn@4.18.1","dependencies":{"google-auth-library":"11.3.0"}}' > package.json
printf '%s\n' 'nodeLinker: node-modules' 'ignorePath: true' 'enableGlobalCache: false' > .yarnrc.yml
: > yarn.lock
corepack yarn@4.18.1 install
)
Si la instalación falla, conserva los archivos creados y vuelve a ejecutar
(cd gcs-upload && corepack yarn@4.18.1 install).
Cuando termine correctamente, abre una terminal en gcs-upload y guarda allí
los siguientes cuatro archivos.
La API JSON gestiona CORS independientemente de las reglas CORS del bucket. El servidor proporciona el origen exacto de la página al crear la sesión. Después, el navegador añade ese origen a sus solicitudes de bloques. CORS permite al navegador leer las respuestas; el token de subida controla quién puede crear una sesión.
Servidor: crea sesiones de subida autenticadas
Guarda esto 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 que 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'] })
const server = createUploadServer({ auth, bucket: process.env.BUCKET, token: process.env.UPLOAD_TOKEN })
server.on('error', () => {
console.error('Could not start upload server. Check whether port 8080 is already in use.')
process.exitCode = 1
})
server.listen(8080, '127.0.0.1', () => console.log(`Open ${ORIGIN}`))
}
Google Auth Library mantiene las credenciales de Google en el servidor. La propia URL de sesión que se devuelve es una credencial Bearer: 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 después de una semana. Mantén las URL de sesión fuera de los registros y del almacenamiento compartido.
Cliente: un cargador mínimo por bloques
Guarda esto como upload.mjs. El tamaño de bloque de 8 MiB es un múltiplo de la
alineación de 256 KiB que exige GCS; el último bloque puede ser más pequeño. Una respuesta
308 significa que la subida está incompleta, por lo que el código la
procesa antes de la comprobación habitual de errores HTTP. Lee el intervalo de bytes confirmado
en lugar de suponer que GCS aceptó toda la solicitud. 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 espera exponencial entre intentos
Los errores de red, los límites de solicitudes y los errores del servidor activan hasta cinco
reintentos entre actualizaciones de 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 intervalos mal formados detienen la subida en lugar de repetirla indefinidamente.
Si se perdió una respuesta final, la siguiente consulta de estado puede confirmar que la subida
terminó 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
}
})
Sustituye el marcador del bucket y luego inicia el servidor desde
gcs-upload. Cuando se solicite el secreto sin mostrar lo que escribes, pega uno
aleatorio de al menos 32 caracteres de tu gestor de contraseñas. Abre
http://127.0.0.1:8080, introduce ese secreto en Upload token,
elige un archivo no vacío de un máximo de 10 GiB y haz clic en Upload / resume.
Usa Pause y el mismo botón de subida para continuar.
Mantén el archivo sin cambios durante la subida. Detén el servidor con Ctrl+C cuando termines:
(
export BUCKET='your-existing-private-bucket'
read -r -s -p 'Upload token: ' UPLOAD_TOKEN &&
printf '\n' &&
export UPLOAD_TOKEN &&
node server.mjs
)
Verifica los bytes almacenados
La página muestra Uploaded to seguido del nombre del objeto
cuando GCS confirma que la subida terminó. Antes de usar el objeto en procesos posteriores,
compara sus bytes con los del archivo local seleccionado. Guarda esto como
verify.mjs en el mismo proyecto. Comprueba los metadatos, fija la descarga a esa
generación exacta y procesa ambos archivos con SHA-256 mediante flujos, sin mantener el archivo
completo en memoria.
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { GoogleAuth } from 'google-auth-library'
const [file, name, secondsText = '7200', ...extra] = process.argv.slice(2)
const seconds = Number(secondsText)
if (!file || !name || !process.env.BUCKET || extra.length ||
!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400) {
console.error('Usage: BUCKET=your-bucket node verify.mjs FILE OBJECT_NAME [SECONDS: 1–86400]')
process.exit(1)
}
let phase = 'local-file'
// A process deadline also covers ADC refresh, which has its own HTTP transport.
const deadline = setTimeout(() => {
console.error(`Verification deadline exceeded during ${phase}. Retry with a larger SECONDS value.`)
process.exit(124)
}, seconds * 1000)
try {
const info = await stat(file)
if (!info.isFile() || info.size < 1 || info.size > 10 * 1024 ** 3) {
throw new Error('Choose a nonempty regular file of at most 10 GiB')
}
async function hash(stream) {
const digest = createHash('sha256')
let bytes = 0
for await (const chunk of stream) {
bytes += chunk.length
digest.update(chunk)
}
return { bytes, sha256: digest.digest('hex') }
}
const local = await hash(createReadStream(file))
phase = 'credentials'
const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/devstorage.read_only'] })
const client = await auth.getClient()
await client.getAccessToken()
phase = 'metadata'
const url = new URL(`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(process.env.BUCKET)}/o/${encodeURIComponent(name)}`)
const metadata = await client.request({ url: url.href, retry: false, timeout: 0 })
const object = metadata.data
if (object.name !== name || !/^[1-9]\d*$/.test(object.generation) ||
Number(object.size) !== local.bytes) throw new Error('Object metadata does not match the local file')
url.search = new URLSearchParams({ alt: 'media', generation: object.generation })
phase = 'download'
const response = await client.request({ url: url.href, responseType: 'stream', retry: false, timeout: 0 })
const stored = await hash(response.data)
phase = 'integrity'
if (stored.bytes !== local.bytes || stored.sha256 !== local.sha256) {
throw new Error('Stored bytes do not match the local file')
}
console.log(`Verified ${stored.bytes} bytes (generation ${object.generation}).`)
} catch (error) {
const status = Number.isInteger(error?.response?.status) ? error.response.status : null
const data = error?.response?.data
const oauthError = ['invalid_grant', 'invalid_client', 'unauthorized_client'].includes(data?.error)
? data.error : null
const oauthSubtype = data?.error_subtype === 'invalid_rapt' ? 'invalid_rapt' : null
console.error(JSON.stringify({ error: 'verification-failed', phase, status, oauthError, oauthSubtype }))
console.error(`Verification failed during ${phase}. Check ADC and bucket access for credentials/metadata errors; check the selected file and object for an integrity error.`)
process.exitCode = 1
} finally {
clearTimeout(deadline)
}
Ejecútalo en una segunda terminal desde gcs-upload, sustituyendo la ruta del
archivo y copiando el nombre completo del objeto que aparece en la página:
BUCKET='your-existing-private-bucket' node verify.mjs './large-file.bin' 'uploads/owner/your-object-id' 7200
Para archivos de hasta 10 GiB, el límite predeterminado es de 7.200 segundos (dos horas) para
toda la verificación: cálculo del hash local, obtención de credenciales y renovación de OAuth,
metadatos y descarga completa. Establece SECONDS entre 1 y 86.400 cuando tu
conexión necesite un límite diferente; solo la descarga de 10 GiB necesita unos 12 Mbit/s para
completarse en dos horas. Aquí el SDK no tiene un tiempo de espera adicional para todo el cuerpo.
Al llegar al límite, el proceso termina con el código de estado 124 y no imprime ningún resultado
verificado. Vuelve a ejecutarlo con un límite mayor después de revisar por separado los fallos de
acceso; ampliar el plazo no puede corregir credenciales incorrectas ni una discrepancia de bytes.
El verificador lee el objeto almacenado y no lo vuelve a subir ni lo modifica.
Conservación del progreso entre recargas
El archivo y la sesión permanecen en la memoria de esta página. Una pausa conserva ambos; recargar la página o seleccionar otro archivo inicia una sesión nueva. Una sesión incompleta caduca sin publicar un objeto completo. Este ejemplo no recupera sesiones después de una recarga.
Una aplicación multiusuario necesitaría autenticar y controlar en el servidor la propiedad de las sesiones, además de contar con una forma de verificar que un archivo seleccionado de nuevo tiene el mismo contenido. Un nombre, un tamaño o un porcentaje de progreso guardado no son suficientes.
Buenas prácticas de seguridad y rendimiento
El token identifica a un único propietario de confianza. Mantén el servidor vinculado a la interfaz de bucle local; este ejemplo no proporciona un sistema de inicio de sesión multiusuario ni cuotas. Las URL de sesión son credenciales de subida, así que mantenlas fuera de los registros, del almacenamiento del navegador y de las capturas de pantalla compartidas. Las credenciales de Google permanecen en Node.js.
El navegador envía un bloque de 8 MiB a la vez e informa del progreso solo después de recibir una confirmación. Una pausa cancela la solicitud actual y la espera entre reintentos; es posible que algunos bytes ya estén almacenados cuando pauses. El siguiente intento consulta a GCS el desplazamiento que ha aceptado. Se rechazan los archivos vacíos y los de más de 10 GiB. Un error definitivo de sesión requiere volver a seleccionar el archivo para empezar de nuevo. La precondición que solo permite crear objetos impide sustituir un objeto existente, incluso si coincide un nombre de destino.
Si buscas una interfaz de selección de archivos más completa, consulta Uppy. Su integración sigue necesitando un mecanismo de transporte para subidas adecuado para tu aplicación.
