Ir para o conteúdo

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.

Upload resumível (tus) na gallery

Seção recipe-resumable-upload da gallery — rode localmente para interagir.

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 } (e o owner, com getPrincipal) 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.

Um registro por usuário — getPrincipal e clear()

O registro sobrevive ao reload — e, por isso mesmo, ao logout. A chave padrão não tem usuário nenhum: se A sai deixando um upload pela metade e B escolhe um arquivo com a mesma impressão digital, o cliente de B faria HEAD e PATCH na URL de upload de A com o token de B e, se o servidor recusasse, mandaria o POST de criação com a Idempotency-Key de A (#479).

Duas peças fecham isso:

import { createLocalUploadStorage, createResumableUpload } from "tempest-react-sdk";

declare const file: File;
declare const auth: { getToken: () => string | null };
declare const session: { userId: string | null };

const upload = createResumableUpload({
  endpoint: "/api/uploads",
  file,
  getToken: () => auth.getToken(),
  getPrincipal: () => session.userId,
});

await upload.start();

export function onSessionEnd(): void {
  createLocalUploadStorage().clear();
}
  • getPrincipal — o mesmo getter do reporter de erros e do provideBackgroundSyncCredentials. O registro ganha uma impressão digital de mão única do usuário (owner, nunca o id em claro), e um registro de outro dono — outro usuário, ninguém, ou sem dono, gravado antes da 0.78.0 — é apagado em vez de retomado: a URL dele não recebe request, e a Idempotency-Key dele nunca é reaproveitada.
  • clear() — apaga todo registro sob o prefixo tempest-upload: do createLocalUploadStorage(). Chame no onSessionEnd (veja Auth → Fim de sessão). Um storage próprio implementa o equivalente do lado dele.

E se a troca de conta acontece com o upload rodando? O getPrincipal é relido antes de cada chunk e de cada gravação do registro: quando ele passa a apontar outra pessoa, o run para com ResumableUploadSupersededError (reason: "principal"), antes de o token da conta nova ir para a URL de upload da anterior. Um clear() no meio do run faz o mesmo (reason: "cleared") em vez de o registro ser regravado. Não é pausa — o run não pode continuar como a conta nova: o estado vai para "error".

import { ResumableUploadSupersededError, createResumableUpload } from "tempest-react-sdk";

declare const file: File;
declare const session: { userId: string | null };

export async function send(): Promise<string | null> {
  const upload = createResumableUpload({
    endpoint: "/api/uploads",
    file,
    getPrincipal: () => session.userId,
  });
  try {
    return (await upload.start())?.url ?? null;
  } catch (error) {
    if (error instanceof ResumableUploadSupersededError) return null;
    throw error;
  }
}

O clear() só apaga valor com formato de registro de upload: um prefixo sem delimitador ("app") não leva junto as chaves do próprio app ("appSettings").

Sem getPrincipal, o comportamento é o de antes

Quem não passa getPrincipal continua retomando qualquer registro com a mesma chave, de qualquer usuário, e um run em andamento não percebe a troca de conta. Serve para app de uma conta por navegador; com mais de uma, passe o getter e limpe no fim de sessão — e, sem o getter, chame abort() nos uploads em andamento dentro do onSessionEnd.

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:

  • Location pode ser relativo. O cliente resolve contra a página, então /api/uploads/abc serve.
  • Upload-Metadata é chave base64(valor) separado por vírgula, com o valor em UTF-8 antes do base64 — é assim que nota-ação.webm sobrevive a um header HTTP.
  • HEAD respondendo 404/410 é lido como "o servidor esqueceu esse upload": o cliente recria do zero em vez de travar.
  • Upload-Offset ausente num PATCH bem-sucedido é tolerado — o cliente assume o fim do chunk —, mas mandar é melhor.
  • Reflita o Idempotency-Key numa 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}`);
  },
});
  • onProgress recebe bytes do arquivo inteiro, não do chunk. resumedFrom diz 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. O resume() faz HEAD primeiro, então bytes parciais que o servidor aceitou não se perdem.
  • abort({ discard: true }) manda DELETE e apaga o registro; sem discard, o ponto de retomada fica.

O token não segue o Location para fora da sua origem

O tus responde a criação com um Location que o servidor escolhe, e o spec permite que seja uma URL absoluta em outro host — entregar o upload a um storage é operação normal, não ataque. Até a 0.65.0 o bearer token seguia aquele header para onde ele apontasse, junto com os bytes do arquivo.

Desde a 0.66.0 a credencial fica presa à origem do endpoint. Se o Location aponta para outra origem, o POST de criação ainda leva o token — ele foi para o seu backend —, e os HEAD/PATCH/DELETE seguintes vão sem ele. O upload termina normalmente, que é o que uma URL de storage pré-assinada espera.

Quando o storage realmente precisa do token da sua API, declare:

createResumableUpload({
  endpoint: "/api/uploads",
  file,
  getToken: () => auth.getToken(),
  trustedOrigins: ["https://storage.exemplo.com"],
});

Vale também para o registro persistido: uma url guardada no localStorage apontando para fora não recupera o token na retomada.

A opção csrf segue a mesma regra: o token CSRF vai no POST de criação, em cada PATCH e no DELETE do descarte — nunca no HEAD de offset — e só para a origem do endpoint e as trustedOrigins. Ver CSRF.

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() resolve null quando você parou de propósito.
  • O ponto de retomada mora em localStorage por padrão; troque por storage, desligue com null.
  • getPrincipal prende o registro ao usuário; createLocalUploadStorage().clear() no onSessionEnd apaga os que sobraram.
  • Resposta perdida é resolvida por offset endereçado + HEAD antes de cada retentativa, e a criação por Idempotency-Key.
  • Backoff é o retry do SDK — não existe um segundo.

Veja também

  • HTTP — uploadWithProgress (uma request), retry, generateIdempotencyKey
  • Áudio — useAudioRecorder, que produz os arquivos longos que motivaram isso
  • Offline — createOfflineStore, se você preferir IndexedDB pro estado de retomada