Uploads de arquivos retomáveis em Angular
Use tus-js-client com um servidor tus para retomar um upload de arquivo em Angular a partir dos
bytes que o servidor já tem. Este exemplo cria um formulário Angular standalone e um receptor local
com armazenamento em disco. Você poderá pausar uma transferência, recarregar a página, selecionar o
mesmo arquivo e concluir o upload.
Por que uploads retomáveis?
O protocolo tus usa um POST para criar uma URL de upload,
requisições PATCH para enviar bytes e uma requisição HEAD para descobrir o Upload-Offset salvo ao
retomar. O offset do servidor decide onde continuar. Uma barra de progresso no navegador, sozinha,
não consegue dizer quais bytes sobreviveram a uma conexão interrompida.
O navegador armazena a URL de upload, não uma cópia do arquivo. Depois de recarregar a página, o usuário precisa selecionar novamente o arquivo original, sem alterações, no mesmo perfil do navegador e na mesma origem de página. Limpar o armazenamento do site, alterar o arquivo ou perder o upload armazenado no servidor pode significar recomeçar do zero.
Configurando seu projeto Angular
Use Node.js 24.15.0 e Yarn 4.12.0 neste passo a passo. As versões de pacotes abaixo foram compiladas e testadas no Chromium 145 no Linux. O Angular tem seus próprios requisitos de compatibilidade com Node.js e TypeScript; essas versões fixadas descrevem a configuração testada.
A partir de um diretório de trabalho onde você quer que o exemplo fique, crie um novo diretório de projeto. Estes comandos Bash recusam um diretório existente e um projeto Yarn Plug’n’Play que o envolva, cujo loader pode afetar o build do Angular. Eles deixam seu shell no local original:
(
node --input-type=module <<'NODE' &&
import { existsSync, realpathSync } from 'node:fs'
import { dirname, join } from 'node:path'
let directory = realpathSync('.')
while (true) {
if (existsSync(join(directory, '.pnp.cjs')) || existsSync(join(directory, '.pnp.js'))) {
console.error('Create this demo outside the enclosing Yarn Plug’n’Play project.')
process.exit(1)
}
const parent = dirname(directory)
if (parent === directory) break
directory = parent
}
NODE
mkdir resumable-upload-demo &&
cd resumable-upload-demo &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml &&
touch yarn.lock
)
Salve cada arquivo abaixo dentro de resumable-upload-demo. O lockfile local e a configuração do Yarn dão a
este exemplo sua própria instalação de dependências. package.json fixa as dependências diretas e o
alvo de build para navegador:
{
"name": "resumable-upload-demo",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "ng build",
"start": "ng serve --host 127.0.0.1 --port 4200",
"server": "node server.ts"
},
"browserslist": ["Chrome 145"],
"dependencies": {
"@angular/build": "22.2.0",
"@angular/cli": "22.2.0",
"@angular/common": "22.2.0",
"@angular/compiler": "22.2.0",
"@angular/compiler-cli": "22.2.0",
"@angular/core": "22.2.0",
"@angular/platform-browser": "22.2.0",
"@tus/file-store": "2.1.1",
"@tus/server": "2.4.5",
"@types/node": "24.19.0",
"rxjs": "7.8.2",
"tslib": "2.8.1",
"tus-js-client": "4.3.1",
"typescript": "6.0.3"
}
}
Salve angular.json para conectar o build e o servidor de desenvolvimento a main.ts:
{
"version": 1,
"cli": { "analytics": false, "cache": { "enabled": false } },
"projects": {
"demo": {
"projectType": "application",
"root": ".",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "main.ts",
"tsConfig": "tsconfig.json",
"index": "index.html",
"outputPath": "dist",
"styles": [],
"assets": []
}
},
"serve": {
"builder": "@angular/build:dev-server",
"options": { "buildTarget": "demo:build" }
}
}
}
}
}
Salve tsconfig.json. Os tipos do Node satisfazem as declarações compartilhadas do pacote cliente;
eles não adicionam APIs do Node ao navegador:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"strict": true,
"types": ["node"],
"lib": ["ES2022", "DOM"],
"rewriteRelativeImportExtensions": true
},
"angularCompilerOptions": { "strictTemplates": true },
"files": ["main.ts"]
}
Salve index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" />
<title>Resumable Angular upload</title>
</head>
<body><app-upload></app-upload></body>
</html>
Adicionar o servidor tus local
Salve server.ts. O servidor tus para Node
cuida do protocolo e dos cabeçalhos CORS; seu
FileStore mantém tanto os bytes
enviados quanto os metadados em uploads/. Reiniciar este processo a partir do mesmo diretório
os preserva. Este receptor local tem um limite de 100 MiB por arquivo e nenhuma autenticação.
Mantenha-o vinculado ao loopback.
import { FileStore } from '@tus/file-store'
import { Server } from '@tus/server'
const server = new Server({
path: '/files',
datastore: new FileStore({ directory: './uploads' }),
maxSize: 100 * 1024 * 1024,
allowedOrigins: ['http://127.0.0.1:4200'],
})
server.listen({ host: '127.0.0.1', port: 1080 })
.once('listening', () => {
console.log('Tus receiver ready at http://127.0.0.1:1080/files')
})
.once('error', () => {
console.error('Cannot start the receiver. Check whether port 1080 is already in use.')
process.exitCode = 1
})
Criando um serviço de upload
Salve upload.service.ts. O serviço assume o estado ocupado antes de procurar URLs salvas, ignora
callbacks de um upload parado e aguarda abort() antes de permitir um novo início. Seus signals
notificam o Angular quando os callbacks assíncronos do cliente atualizam a UI, inclusive com
detecção de mudanças zoneless.
import { Injectable, signal, type OnDestroy } from '@angular/core'
import { DetailedError, Upload } from 'tus-js-client'
@Injectable()
export class UploadService implements OnDestroy {
readonly busy = signal(false)
readonly pausing = signal(false)
readonly percentage = signal(0)
readonly message = signal('Choose a file, then select Upload.')
readonly uploadUrl = signal<string | null>(null)
#upload: Upload | null = null
#ignoreCallbacks = false
async start(file: File): Promise<void> {
if (this.busy()) return
if (file.size > 100 * 1024 * 1024) {
this.message.set('Choose a file no larger than 100 MiB.')
return
}
this.busy.set(true)
this.#ignoreCallbacks = false
this.percentage.set(0)
this.uploadUrl.set(null)
this.message.set('Uploading…')
const isCurrent = (): boolean => this.#upload === upload && !this.#ignoreCallbacks
const upload = new Upload(file, {
endpoint: 'http://127.0.0.1:1080/files',
chunkSize: 1024 * 1024,
retryDelays: [0, 1000, 3000, 5000],
removeFingerprintOnSuccess: true,
metadata: { filename: file.name },
onUploadUrlAvailable: () => {
if (isCurrent()) this.uploadUrl.set(upload.url)
},
onProgress: (sent, total) => {
if (isCurrent()) this.percentage.set(total > 0 ? (sent / total) * 100 : 0)
},
onSuccess: () => {
if (!isCurrent()) return
this.#upload = null
this.busy.set(false)
this.percentage.set(100)
this.message.set('Upload complete.')
},
onError: (error) => {
if (!isCurrent()) return
this.#upload = null
this.busy.set(false)
const status = error instanceof DetailedError ? error.originalResponse?.getStatus() : null
this.message.set(status === 401 || status === 403
? 'Upload denied. Check authorization before retrying.'
: 'Upload failed. Check the receiver and connection, then select Upload to retry.')
},
})
this.#upload = upload
try {
const previous = await upload.findPreviousUploads()
if (!isCurrent()) return
if (previous[0]) upload.resumeFromPreviousUpload(previous[0])
upload.start()
} catch {
if (!isCurrent()) return
this.#upload = null
this.busy.set(false)
this.message.set('Cannot prepare the upload. Check browser storage, then retry.')
}
}
async pause(): Promise<void> {
const upload = this.#upload
if (!upload || this.pausing()) return
this.#ignoreCallbacks = true
this.pausing.set(true)
try {
await upload.abort()
this.#upload = null
this.busy.set(false)
this.message.set('Paused. Select Upload to resume.')
} catch {
this.message.set('Could not pause. Select Pause to try again.')
} finally {
this.pausing.set(false)
}
}
ngOnDestroy(): void {
const upload = this.#upload
this.#upload = null
void upload?.abort().catch(() => {
console.error('Unable to pause the upload during cleanup.')
})
}
}
A API do cliente define abort() como uma
pausa; já abort(true) solicita a exclusão. Este exemplo descarta a instância de cliente parada e
procura sua URL no próximo início, então tanto a retomada pelo botão quanto a recuperação após
recarregar a página exigem armazenamento de URLs no navegador. Ele seleciona a primeira
correspondência armazenada, o que é adequado para esta demonstração de usuário único.
Os chunks de 1 MiB facilitam a inspeção de requisições individuais. Eles não são necessários para a retomada; o padrão do cliente é um tamanho de chunk ilimitado. Sua política de novas tentativas limita as tentativas consecutivas e reinicia essa cota quando há progresso. Quando as tentativas param, Upload permite que o usuário tente novamente.
Construindo o componente de upload
Salve main.ts. Ele é ao mesmo tempo o componente standalone e o ponto de entrada da
aplicação, então não sobra nenhum template raiz ou módulo para conectar. A instância do serviço
pertence ao componente e é limpa quando o Angular o destrói.
import { DecimalPipe } from '@angular/common'
import { Component, inject, signal } from '@angular/core'
import { bootstrapApplication } from '@angular/platform-browser'
import { UploadService } from './upload.service.ts'
@Component({
selector: 'app-upload',
standalone: true,
imports: [DecimalPipe],
providers: [UploadService],
template: `
<main>
<h1>Resumable upload</h1>
<label for="file">Choose file</label>
<input id="file" type="file" [disabled]="uploader.busy()" (change)="select($event)" />
<button type="button" (click)="start()" [disabled]="!file() || uploader.busy()">
Upload
</button>
<button type="button" (click)="uploader.pause()"
[disabled]="!uploader.busy() || uploader.pausing()">Pause</button>
<p>Sent: {{ uploader.percentage() | number: '1.0-0' }}%</p>
<progress max="100" [value]="uploader.percentage()" aria-label="Upload progress"></progress>
<p role="status">{{ uploader.message() }}</p>
@if (uploader.uploadUrl(); as url) {
<p>Upload URL: <code>{{ url }}</code></p>
}
</main>
`,
})
export class UploadComponent {
readonly uploader = inject(UploadService)
readonly file = signal<File | null>(null)
select(event: Event): void {
if (this.uploader.busy()) return
if (event.target instanceof HTMLInputElement) {
this.file.set(event.target.files?.[0] ?? null)
}
}
start(): void {
const file = this.file()
if (file) void this.uploader.start(file)
}
}
bootstrapApplication(UploadComponent).catch(() => {
console.error('Unable to start the upload application.')
})
Executar e verificar uma transferência retomada
Com os sete arquivos salvos, instale e faça o build a partir do diretório pai:
(cd resumable-upload-demo && yarn install && yarn build)
Depois de um build bem-sucedido, inicie o receptor em um terminal e o servidor de desenvolvimento do Angular em outro, ambos a partir desse mesmo diretório pai:
(cd resumable-upload-demo && yarn server)
(cd resumable-upload-demo && yarn start)
Abra http://127.0.0.1:4200. Selecione um arquivo menor que 100 MiB por meio de
Choose file e depois selecione
Upload. Use um arquivo de vários MiB e a limitação de rede do
navegador para ter tempo de selecionar Pause. Aguarde
Paused. Select Upload to resume. antes de continuar.
Inspecione as requisições de rede do navegador. Depois de pelo menos um PATCH bem-sucedido,
recarregue a página, selecione o mesmo arquivo sem alterações e selecione
Upload novamente. Você deverá ver um HEAD para a URL de
upload anterior, seguido de um PATCH cujo Upload-Offset na requisição
corresponde ao offset retornado pelo HEAD. Esse offset deve ser maior que zero. Um novo POST
significa que o cliente criou um novo upload em vez de retomar o antigo.
Aguarde Upload complete.. O último segmento da URL de upload exibida
identifica o arquivo em resumable-upload-demo/uploads/; um arquivo .json vizinho guarda os
metadados. Compare os bytes ou o digest SHA-256 desse arquivo com o original. O nome original do
arquivo é um metadado, não um caminho em disco. Fazer um novo upload após o sucesso cria um recurso
separado, porque o cliente remove o registro da URL concluída. Os uploads existentes permanecem em
disco.
A porcentagem mostra os bytes enviados pelo navegador e pode retroceder após uma recuperação. A conclusão vem do callback de sucesso do tus, não de chegar a 100% na barra de progresso. Nenhum dos dois significa que uma aplicação em produção validou, fez a varredura de segurança ou publicou o arquivo.
Configuração de CORS
Use 127.0.0.1 de forma consistente: localhost é uma origem diferente. O receptor permite a
origem do Angular na porta 4200 e fornece os cabeçalhos de requisição do tus e os cabeçalhos de
resposta expostos, incluindo Location e Upload-Offset. Se uma porta estiver ocupada, escolha outra e
atualize a URL correspondente em server.ts, upload.service.ts ou no script start. Erros de CORS
costumam aparecer para o cliente como falhas de rede; inspecione a resposta do preflight e também a
requisição de upload.
CORS não é autenticação. Este receptor serve para testes locais e não autoriza usuários. O
tus-js-client usa seu próprio transporte XMLHttpRequest, então o HttpInterceptor do Angular não
anexa credenciais a essas requisições. Para uma integração protegida, use a opção documentada
headers ou onBeforeRequest do cliente, permita no servidor quaisquer cabeçalhos adicionados e
autorize cada operação de upload. Mantenha credenciais fora deste exemplo e envie-as apenas para
URLs de upload confiáveis.
Boas práticas e considerações
A fingerprint no navegador usada pelo cliente se baseia nos metadados do arquivo e no endpoint, não em um hash do conteúdo. Selecione novamente o original sem alterações. Uma aplicação com várias contas precisa de armazenamento de URLs por conta e de uma política deliberada para múltiplas correspondências; selecionar a primeira correspondência não é uma verificação de propriedade.
Se a URL salva retornar 404 ou 410, a versão 4.3.1 recorre à criação de um novo
upload no endpoint configurado. Bytes ausentes no servidor não podem ser recuperados do
armazenamento do navegador. Este servidor local não agenda expiração nem remove arquivos
abandonados; encerre os dois processos com Ctrl+C ao terminar e remova o diretório uploads/ da
demonstração somente quando não precisar mais dos dados dele.
Em falhas de conexão, restabeleça o receptor ou a rede e tente novamente. Em uma requisição negada, resolva a autorização primeiro. O formulário mantém a seleção de arquivos e novos inícios desabilitados até que um upload ativo termine, falhe ou conclua a pausa. Se a pausa falhar, tente Pause novamente antes de iniciar qualquer outra coisa. O servidor aplica o limite de tamanho independentemente do navegador; a validação de conteúdo em produção e a limpeza do armazenamento também devem ficar no servidor.
