Uploads em blocos do navegador ao Google Storage com JavaScript
Envie arquivos grandes diretamente do navegador para um bucket privado do Google Cloud Storage (GCS), sem passar o arquivo pelo Node.js. O servidor autentica um único proprietário e abre uma sessão de upload retomável; o navegador envia blocos alinhados e consulta o GCS para saber de onde continuar após uma interrupção. O exemplo inclui pausa e retomada na mesma página aberta e uma verificação independente dos bytes após a conclusão.
Por que usar uploads em blocos?
Uma falha de conexão pode deixar mais bytes armazenados do que o número de bytes para os quais o navegador recebeu confirmação. O protocolo retomável do GCS permite que o navegador consulte essa posição aceita antes de enviar mais dados. Essas requisições criam um único objeto, portanto não há objetos separados por bloco para combinar ou excluir.
Configurar o Google Cloud Storage
Use Bash no Linux ou macOS, uma versão LTS atual do Node.js 24, Corepack e um bucket privado existente. A configuração de pacotes abaixo fixa o Yarn 4.18.1 e a Google Auth Library 11.3.0. Node.js 24.22.0 é a versão LTS testada; continue usando atualizações de segurança com suporte dentro dessa versão principal.
O servidor precisa de storage.objects.create no bucket. O papel
roles/storage.objectCreator, com escopo limitado ao bucket, fornece essa permissão para este
exemplo que só permite criar objetos. A verificação posterior dos bytes também precisa de
storage.objects.get, disponível por meio de roles/storage.objectViewer.
Consulte as permissões necessárias para upload do Google.
Use Application Default Credentials (ADC) existentes
com essas permissões. Se as ADC não estiverem configuradas, instale a Google Cloud CLI e execute
gcloud auth application-default login antes de iniciar o servidor. Um login bem-sucedido, por si só,
não estabelece acesso ao seu bucket.
Cole o conteúdo a seguir no diretório em que você quer criar o novo projeto
gcs-upload. O procedimento recusa um diretório já existente, mantém seu shell
no diretório atual e dá ao projeto sua própria configuração do Yarn e seu próprio arquivo de lock:
(
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
)
Se a instalação falhar, mantenha os arquivos criados e tente (cd gcs-upload && corepack yarn@4.18.1 install) novamente.
Após a instalação ser concluída com sucesso, abra um terminal em gcs-upload
e salve ali os quatro arquivos a seguir.
A JSON API trata o CORS independentemente das regras de CORS do bucket. O servidor fornece a origem exata da página durante a criação da sessão. O navegador então adiciona essa origem às requisições dos blocos. O CORS permite que o navegador leia as respostas; o token de upload controla quem pode criar uma sessão.
Back-end: criar sessões de upload autenticadas
Salve o conteúdo a seguir como server.mjs. O token configurado identifica
o único proprietário desta ferramenta local. Toda sessão exige esse token, e o servidor escolhe
um nome de objeto imprevisível dentro de uploads/owner/. Os clientes não podem
fornecer nomes de buckets, caminhos de destino ou nomes de objetos para excluir.
A precondição ifGenerationMatch=0 também impede a sobrescrita de um 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}`))
}
A Google Auth Library mantém as credenciais do Google no servidor. A própria URL de sessão retornada é uma credencial de portador: qualquer pessoa que a tenha pode fazer upload para aquele único destino. Ela não é uma URL assinada de 15 minutos; as sessões retomáveis do GCS expiram após uma semana. Não inclua URLs de sessão em logs nem em armazenamento compartilhado.
Front-end: um mecanismo mínimo de upload em blocos
Salve o conteúdo a seguir como upload.mjs. O tamanho de bloco de 8 MiB é múltiplo
do alinhamento de 256 KiB exigido pelo GCS; o último bloco pode ser menor. Uma resposta
308 significa que o upload está incompleto, por isso o código a trata antes
da verificação comum de erros HTTP. Ele lê o intervalo de bytes confirmado em vez de presumir
que o GCS aceitou a requisição inteira. Consulte o
protocolo de upload retomável.
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 })
})
}
Repetir tentativas com espera exponencial
Erros de rede, limites de requisições e erros do servidor acionam até cinco novas tentativas entre
as atualizações de progresso. Cada nova tentativa consulta primeiro a posição armazenada com uma
requisição PUT vazia. Uma pausa aborta tanto a requisição ativa quanto
qualquer espera entre tentativas. Falhas de permissão, sessões expiradas e intervalos malformados
interrompem o upload em vez de repetir indefinidamente. Se uma resposta final tiver sido perdida,
a próxima consulta de status pode confirmar a conclusão sem fazer upload do arquivo novamente.
Salve a 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>
Salve os 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
}
})
Substitua o marcador do bucket e inicie o servidor a partir de gcs-upload.
No prompt que oculta a entrada, cole um segredo aleatório de pelo menos 32 caracteres do seu
gerenciador de senhas. Abra http://127.0.0.1:8080, insira esse segredo em
Upload token, escolha um arquivo não vazio de
no máximo 10 GiB e clique em Upload / resume.
Use Pause para pausar e o mesmo botão
de upload para continuar. Mantenha o arquivo inalterado durante o upload. Encerre o servidor
com Ctrl+C quando terminar:
(
export BUCKET='your-existing-private-bucket'
read -r -s -p 'Upload token: ' UPLOAD_TOKEN &&
printf '\n' &&
export UPLOAD_TOKEN &&
node server.mjs
)
Verificar os bytes armazenados
A página exibe Uploaded to seguido do nome
do objeto após o GCS confirmar a conclusão. Antes de usar o objeto nas etapas seguintes, compare
seus bytes com os do arquivo local selecionado. Salve o conteúdo a seguir como
verify.mjs no mesmo projeto. Ele verifica os metadados, vincula o download
àquela geração exata e calcula o hash SHA-256 de ambos os arquivos em fluxo, sem manter o arquivo
inteiro na memória.
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)
}
Execute em um segundo terminal a partir de gcs-upload, substituindo o caminho
do arquivo e copiando o nome completo do objeto exibido na página:
BUCKET='your-existing-private-bucket' node verify.mjs './large-file.bin' 'uploads/owner/your-object-id' 7200
Para arquivos de até 10 GiB, o prazo padrão é de 7.200 segundos (duas horas) para
toda a verificação: cálculo local do hash, obtenção de credenciais e renovação OAuth, metadados
e download completo. Defina SECONDS entre 1 e 86.400 quando sua conexão
precisar de outro prazo; só o download de 10 GiB exige cerca de 12 Mbit/s para caber em duas horas.
O SDK não tem aqui um tempo limite adicional para a transferência do corpo inteiro.
Ao atingir o prazo, o processo termina com status 124 e não imprime nenhum resultado verificado.
Execute novamente com um prazo maior após investigar separadamente as falhas de acesso; ampliar
um prazo não corrige credenciais inválidas nem uma divergência de bytes. O verificador lê o objeto
armazenado e não faz upload dele novamente nem o altera.
Persistir o progresso entre recarregamentos
O arquivo e a sessão ficam na memória desta página. Pausar mantém ambos; recarregar a página ou selecionar outro arquivo inicia uma nova sessão. Uma sessão incompleta expira sem publicar um objeto concluído. Este exemplo não recupera sessões após um recarregamento.
Uma aplicação multiusuário precisaria de um controle autenticado de propriedade das sessões no servidor e de uma forma de verificar se um arquivo selecionado novamente tem o mesmo conteúdo. Um nome, um tamanho ou uma porcentagem de progresso salva não são suficientes.
Boas práticas de segurança e desempenho
O token identifica um único proprietário confiável. Mantenha o servidor vinculado à interface de loopback; este exemplo não fornece um sistema de login multiusuário nem cotas. URLs de sessão são credenciais de upload, portanto não as inclua em logs, no armazenamento do navegador nem em capturas de tela compartilhadas. As credenciais do Google permanecem no Node.js.
O navegador envia um bloco de 8 MiB por vez e informa o progresso somente após uma confirmação. Pausar aborta a requisição atual e a espera entre tentativas; alguns bytes podem já estar armazenados quando você pausa. A próxima tentativa consulta o GCS para saber a posição aceita. Arquivos vazios e arquivos acima de 10 GiB são recusados. Um erro definitivo de sessão exige selecionar o arquivo novamente para recomeçar. A precondição que só permite criar objetos impede a substituição de um objeto existente mesmo que haja uma colisão de nomes de destino.
Para uma interface de seleção de arquivos com mais recursos, consulte o Uppy. A integração dele ainda precisa de um mecanismo de transporte de upload adequado à sua aplicação.
