Upload resumível (tus)
uploadWithProgress faz uma request. Isso funciona até o arquivo ficar grande: um áudio de 40 minutos gravado com useAudioRecorder, um vídeo, um dump. Aí um 4G que oscila por três segundos joga fora 300 MB já enviados, e recarregar a página joga fora tudo de novo.
createResumableUpload divide o arquivo em chunks, guarda onde parou e continua — depois de uma queda de rede ou de um reload.
Protocolo: tus 1.0.0, não um esquema nosso
O cliente fala tus (core + as extensões creation e termination). Isso é deliberado: um cliente resumível de formato próprio obriga o backend a ser sempre nosso. Com tus você pode apontar para tusd, tus-node-server ou py-tus sem escrever servidor nenhum — e a seção O que o backend precisa implementar cabe numa tabela.
O exemplo mínimo
import { createResumableUpload, type ResumableUpload } from "tempest-react-sdk";
import { useRef, useState } from "react";
export function EnviarGravacao({ file }: { file: File }) {
const upload = useRef<ResumableUpload | null>(null);
const [percent, setPercent] = useState(0);
const [state, setState] = useState("idle");
async function start() {
upload.current ??= createResumableUpload({
endpoint: "/api/uploads",
file,
metadata: { filename: file.name },
onProgress: ({ fraction }) => setPercent(Math.round(fraction * 100)),
onStateChange: setState,
});
const done = await upload.current.start();
if (done) await fetch(`/api/gravacoes?url=${encodeURIComponent(done.url)}`, { method: "POST" });
}
return (
<>
<progress value={percent} max={100} />
<button type="button" onClick={() => void start()}>Enviar</button>
<button type="button" onClick={() => upload.current?.pause()}>Pausar</button>
<button type="button" onClick={() => void upload.current?.resume()}>Continuar</button>
<button type="button" onClick={() => void upload.current?.abort({ discard: true })}>
Cancelar
</button>
<p>{state}</p>
</>
);
}
O que acontece: um POST cria o upload, uma sequência de PATCH empurra 5 MiB por vez, onProgress recebe cada tick de bytes, e no fim start() resolve com { url, size } — a URL tus, que você grava junto do registro.
start() resolve null quando você pausou ou cancelou
Só uma falha rejeita. pause() e abort() não são falhas, então a promessa resolve null e você não precisa de try/catch em volta de um botão de pausar.
Estados
upload.state (e o onStateChange) passa por:
| Estado | Significado |
|---|---|
idle |
Nada começou |
creating |
O POST de criação está em voo |
uploading |
Chunks indo |
paused |
pause() — o ponto de retomada fica guardado |
done |
Terminou; o registro persistido foi apagado |
error |
As tentativas acabaram; start() rejeitou |
aborted |
abort() — com discard: true, o registro foi apagado |
Retomar depois de um reload
É o motivo de existir. O cliente guarda { url, offset, size, idempotencyKey } sob uma chave de retomada e, no próximo start(), reencontra o upload:
const upload = createResumableUpload({ endpoint: "/api/uploads", file });
// Recarregou a página, o usuário escolheu o mesmo arquivo, chamou start() de novo:
// → HEAD no upload antigo, offset volta em 312 MB, os PATCH continuam de lá.
A chave padrão é uma impressão digital de endpoint + nome + tamanho + tipo + lastModified — o mesmo critério dos clientes tus de referência. Barato (não hasheia 400 MB) e muda sempre que o arquivo muda, que é a propriedade importante: retomar dentro do arquivo errado corromperia os dois.
Um Blob sem nome tem impressão digital fraca
Blob não tem name nem lastModified, então dois blobs do mesmo tamanho e tipo colidem. Passe key explícito quando o que você envia não é um File.
Onde o estado mora
Por padrão, localStorage, via createLocalUploadStorage(). Não IndexedDB, e isso é escolha: o registro tem quatro campos e uma URL, o requisito é só sobreviver a um reload, e puxar Dexie pra isso colocaria IndexedDB no bundle de todo app que envia um arquivo.
Se o seu app já tem um banco Dexie aberto, plugue o seu:
import {
createOfflineStore,
createResumableUpload,
type ResumableUploadRecord,
} from "tempest-react-sdk";
const store = createOfflineStore<{ id: string; record: ResumableUploadRecord }, string>({
databaseName: "Uploads",
version: 1,
tableName: "resume",
indexes: "&id",
});
createResumableUpload({
endpoint: "/api/uploads",
file,
storage: {
get: async (key) => (await store.get(key))?.record ?? null,
set: async (key, record) => {
await store.put({ id: key, record });
},
delete: async (key) => {
await store.delete(key);
},
},
});
storage: null desliga a persistência: aí a retomada sobrevive a uma queda de rede, mas não a um reload.
A falha que realmente acontece
Não é o chunk que se perde. É o chunk que o servidor gravou e cuja resposta não voltou. O cliente não distingue isso de um chunk perdido, e reenviar às cegas duplicaria bytes.
Duas coisas impedem:
1. Escritas são endereçadas, não anexadas. Todo PATCH declara o offset em que escreve (Upload-Offset). Se a resposta se perdeu e o cliente repetir, ele está pedindo pra escrever bytes que o servidor já tem — e leva 409. Em qualquer retentativa, o cliente relê a verdade com HEAD antes de escrever e continua de onde o servidor realmente está.
2. A criação leva Idempotency-Key. O tus não tem criação idempotente; sem isso, um 201 perdido deixaria um upload órfão no servidor e o cliente criaria um segundo. A chave (de generateIdempotencyKey) é gravada antes da primeira tentativa e reusada na retentativa. Backend que honra o header devolve o mesmo Location; backend que ignora funciona igual, só fica com o órfão.
O backoff é o retry do SDK
Não existe um segundo backoff aqui: cada chunk roda dentro do retry — exponencial, com Retry-After respeitado. Ajuste por retry: { retries, initialDelay, shouldRetry }.
O que o loop de chunk retenta, e o que não
Duas decisões são específicas do protocolo de retomada e não seguem a política geral do SDK:
| Resposta | Retenta? | Por quê |
|---|---|---|
falha de rede, 408, 425, 429, 5xx |
sim | política padrão: pode mudar sozinho |
409, 412 |
sim | é o sinal de offset divergente — o replay é a correção, e o HEAD de resync a executa |
404, 410 |
não | o upload sumiu do servidor. Recriar só acontece no attach, nunca dentro do loop |
400, 403, 413, 415, 422 |
não | recusa deliberada; a 5ª tentativa recebe a mesma resposta |
O 409 é o caso interessante: a política geral do SDK recusa 4xx, e com razão
— mas aqui ele significa "seu offset está errado", que é exatamente o que a
retentativa conserta.
O 404 é o inverso. Recurso que desapareceu parece transitório, mas o
HEAD de resync bate no mesmo 404, então retentar era sondar um recurso
morto cinco vezes e só então mostrar "o upload expirou" — com o backoff somado
em cima. Nesse caso, comece um upload novo.
Um shouldRetry seu substitui essa política inteira, nas duas direções.
O que o backend precisa implementar
Toda request leva Tus-Resumable: 1.0.0.
| Passo | Request | Resposta esperada |
|---|---|---|
| Criar | POST {endpoint} + Upload-Length, Upload-Metadata, Idempotency-Key |
201 + Location (URL do upload, absoluta ou relativa) |
| Sondar | HEAD {uploadUrl} |
200/204 + Upload-Offset |
| Escrever | PATCH {uploadUrl} + Upload-Offset, Content-Type: application/offset+octet-stream, corpo = chunk |
204 + o novo Upload-Offset; 409 quando o offset não bate |
| Descartar | DELETE {uploadUrl} |
204 |
Detalhes que costumam morder:
Locationpode ser relativo. O cliente resolve contra a página, então/api/uploads/abcserve.Upload-Metadataéchave base64(valor)separado por vírgula, com o valor em UTF-8 antes do base64 — é assim quenota-ação.webmsobrevive a um header HTTP.HEADrespondendo404/410é lido como "o servidor esqueceu esse upload": o cliente recria do zero em vez de travar.Upload-Offsetausente numPATCHbem-sucedido é tolerado — o cliente assume o fim do chunk —, mas mandar é melhor.- Reflita o
Idempotency-Keynuma tabela de chaves se quiser evitar órfãos.
Servidor pronto
tusd (Go), tus-node-server (Node) e tuspy/py-tus (Python) já implementam a tabela. Num FastAPI próprio são quatro rotas.
Progresso e pausa, com precisão
const upload = createResumableUpload({
endpoint: "/api/uploads",
file,
chunkSize: 1024 * 1024, // 1 MiB: tick mais fino, mais round-trips
// o padrão é DEFAULT_CHUNK_SIZE (5 MiB), exportado para uma tela de ajustes exibir
getToken: () => auth.getToken(),
withCredentials: true,
retry: { retries: 8, initialDelay: 500 },
onProgress: ({ loaded, total, fraction, resumedFrom }) => {
console.log(`${loaded}/${total} (${Math.round(fraction * 100)}%), retomou em ${resumedFrom}`);
},
});
onProgressrecebe bytes do arquivo inteiro, não do chunk.resumedFromdiz quantos já estavam no servidor quando esta execução começou — útil pra não anunciar "0%" num upload que retomou em 80%.pause()derruba o chunk em voo; o offset persistido continua o do último chunk confirmado. Oresume()fazHEADprimeiro, então bytes parciais que o servidor aceitou não se perdem.abort({ discard: true })mandaDELETEe apaga o registro; semdiscard, o ponto de retomada fica.
XMLHttpRequest, não fetch
Como em uploadWithProgress: fetch ainda não reporta progresso de upload em navegador nenhum. Aqui há um segundo motivo — o tus devolve o novo offset num header de resposta, e uploadWithProgress só entrega corpo parseado, então não dava pra reaproveitar.
Resumo
createResumableUpload({ endpoint, file })— chunks de 5 MiB, retomada por rede e por reload, protocolo tus 1.0.0.start()/pause()/resume()/abort({ discard });start()resolvenullquando você parou de propósito.- O ponto de retomada mora em
localStoragepor padrão; troque porstorage, desligue comnull. - Resposta perdida é resolvida por offset endereçado +
HEADantes de cada retentativa, e a criação porIdempotency-Key. - Backoff é o
retrydo SDK — não existe um segundo.