Upload de arquivos em Angular com progresso e servidor local
Envie um arquivo selecionado como FormData pelo HttpClient do Angular e mostre sucesso somente
quando o servidor o aceitar. Este passo a passo cria um uploader de arquivo único com progresso,
erros visíveis e um receptor Node.js executável. O receptor verifica a requisição e retorna um
checksum; ele não salva o arquivo.
Configuração necessária
Use o Node.js 26.8.2, o Corepack com Yarn 4.12.0 e um navegador moderno. O exemplo fixa o Angular 22.1.7, o Angular CLI/build 22.1.8 e o TypeScript 6.0.3. O Angular 22 é uma versão com suporte; sua tabela de compatibilidade explica os requisitos de Node e TypeScript. Este é um app standalone para navegador, sem renderização no lado do servidor. O exemplo foi testado no macOS com Chromium 145. Os comandos usam um shell POSIX, como Bash no macOS, Linux ou WSL.
Execute isto a partir de um diretório pai onde você quer criar uma nova pasta angular-upload-demo. O mkdir
recusa um destino existente, e cada comando dependente só é executado se o anterior tiver sucesso.
Mantenha o lockfile gerado para que instalações posteriores usem a mesma resolução de dependências.
(
mkdir angular-upload-demo &&
cd angular-upload-demo &&
NG_CLI_ANALYTICS=false corepack yarn@4.12.0 dlx @angular/cli@22.1.8 new upload-demo \
--directory . --standalone --routing=false --ssr=false --style=css \
--skip-tests --skip-git --skip-install --package-manager=yarn --defaults &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn@4.12.0 add --exact \
@angular/common@22.1.7 @angular/compiler@22.1.7 @angular/core@22.1.7 \
@angular/forms@22.1.7 @angular/platform-browser@22.1.7 @angular/router@22.1.7 \
rxjs@7.8.2 fastify@5.12.5 @fastify/multipart@10.1.1 &&
corepack yarn@4.12.0 add --dev --exact \
@angular/build@22.1.8 @angular/cli@22.1.8 @angular/compiler-cli@22.1.7 typescript@6.0.3
)
Depois que isso funcionar, abra angular-upload-demo no seu editor. Todos os nomes de arquivo abaixo são
relativos a essa pasta. Substitua apenas o src/main.ts gerado; crie os outros três arquivos com os
nomes indicados.
Use a codificação multipart do navegador
O input de arquivo nativo fornece um File; o FormData.append('file', file) coloca os bytes dele no campo
multipart que nosso servidor espera. Não defina Content-Type. O navegador adiciona o boundary
multipart, como explica o guia de FormData da MDN.
Nenhum FormsModule ou ngModel é necessário para o evento change deste input.
O progresso de upload exige o backend XHR do Angular. Configure provideHttpClient(withXhr()) e depois solicite
eventos com observe: 'events' e reportProgress: true. O backend Fetch padrão não informa o progresso de
upload. O Angular documenta tanto a
configuração do XHR quanto a
sequência de eventos da requisição.
Um evento de progresso descreve a transmissão do corpo da requisição, incluindo o enquadramento
multipart. Mesmo 100% transferido não significa que o servidor aceitou o arquivo.
Adicione o componente de upload
Substitua src/main.ts por este ponto de entrada completo. Signals atualizam a view quando os
eventos HTTP chegam. Enquanto uma requisição está pendente, os dois controles ficam desabilitados e
os handlers se protegem contra chamadas repetidas. Uma requisição com falha mantém o arquivo
selecionado para uma nova tentativa manual; em caso de sucesso, o input nativo é limpo para que
selecionar o mesmo arquivo novamente funcione.
import { HttpClient, HttpErrorResponse, HttpEventType, provideHttpClient, withXhr } from '@angular/common/http'
import { Component, DestroyRef, inject, signal } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { bootstrapApplication } from '@angular/platform-browser'
import { finalize } from 'rxjs'
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './upload.html',
})
class UploadDemo {
readonly selected = signal<File | null>(null)
readonly busy = signal(false)
readonly progress = signal<number | null>(null)
readonly message = signal('Choose a file to upload.')
readonly #http = inject(HttpClient)
readonly #destroyRef = inject(DestroyRef)
select(event: Event): void {
if (this.busy()) return
const input = event.target
if (!(input instanceof HTMLInputElement)) return
const file = input.files?.[0] ?? null
this.progress.set(null)
this.selected.set(null)
if (file === null) {
this.message.set('Choose a file to upload.')
return
}
if (file.size > 1024 * 1024) {
input.value = ''
this.message.set('Choose a file of 1 MiB or smaller.')
return
}
this.selected.set(file)
this.message.set(`Ready to upload “${file.name}”.`)
}
upload(input: HTMLInputElement): void {
const file = this.selected()
if (file === null || this.busy()) return
this.busy.set(true)
this.progress.set(null)
this.message.set(`Sending “${file.name}”…`)
const body = new FormData()
body.append('file', file)
this.#http.post('/api/upload', body, {
observe: 'events',
reportProgress: true,
timeout: 30_000,
}).pipe(
takeUntilDestroyed(this.#destroyRef),
finalize(() => this.busy.set(false)),
).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress) {
const total = event.total
this.progress.set(total != null && total > 0
? Math.floor(100 * event.loaded / total)
: null)
if (total != null && total > 0 && event.loaded >= total) {
this.message.set('Transfer complete. Waiting for server acceptance…')
}
} else if (event.type === HttpEventType.Response) {
this.selected.set(null)
input.value = ''
this.message.set(`Server accepted “${file.name}”. This demo did not save it.`)
}
},
error: (error: unknown) => {
this.progress.set(null)
this.message.set(error instanceof HttpErrorResponse && error.status !== 0
? `Upload failed (HTTP ${error.status}). Try again or choose another file.`
: 'Network error or timeout. The server may have received the file; check before retrying.')
},
})
}
}
bootstrapApplication(UploadDemo, {
providers: [provideHttpClient(withXhr())],
}).catch(console.error)
Rotule os controles e o feedback
Crie src/upload.html. O input e o botão nativos oferecem suporte à navegação por teclado. A região
de status anuncia o feedback sem mover o foco. Um total desconhecido deixa a barra de progresso
indeterminada; um upload local pequeno pode terminar rápido demais para mostrar porcentagens
intermediárias.
<main>
<h1>Upload a file</h1>
<p id="file-hint">Up to 1 MiB. The server rejects empty files and does not save uploads.</p>
<label for="upload-file">Choose a file</label>
<input #fileInput id="upload-file" type="file" aria-describedby="file-hint"
[disabled]="busy()" (change)="select($event)" />
<button type="button" [disabled]="selected() === null || busy()"
(click)="upload(fileInput)">Upload</button>
@if (busy()) {
<p>
<progress aria-label="Request upload progress" max="100" [attr.value]="progress()"></progress>
@if (progress() !== null) {
<span>{{ progress() }}% transferred</span>
}
</p>
}
<p role="status" aria-atomic="true">{{ message() }}</p>
</main>
Execute um receptor que verifica o upload
Crie server.ts na raiz do projeto. Ele aceita POST /api/upload com exatamente um campo de arquivo
multipart chamado file, rejeita arquivos vazios com HTTP 422 e limita os arquivos a
1 MiB. O plugin multipart aplica os limites de tamanho e de quantidade de partes;
toBuffer() consome o arquivo antes que a resposta seja produzida.
O receptor retorna o nome do arquivo recebido, o nome do campo, a contagem de bytes e o checksum SHA-256. Ele mantém o arquivo em buffer na memória e o descarta após a requisição. Ele não tem caminho de armazenamento e, portanto, nunca sobrescreve um arquivo existente. Esta demonstração local aceita qualquer tipo de arquivo e se vincula apenas ao endereço de loopback.
import { createHash } from 'node:crypto'
import multipart from '@fastify/multipart'
import Fastify from 'fastify'
const server = Fastify()
await server.register(multipart, {
limits: { files: 1, fields: 0, parts: 1, fileSize: 1024 * 1024 },
})
server.setErrorHandler((error, _request, reply) => {
// A part-count limit can interrupt toBuffer() before the parser's own error surfaces.
if (error instanceof Error && 'code' in error && error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
return reply.code(400).send({ error: 'Incomplete multipart upload.' })
}
const status = error instanceof Error && 'statusCode' in error
&& typeof error.statusCode === 'number'
&& error.statusCode >= 400 && error.statusCode <= 599
? error.statusCode : 500
reply.code(status).send({ error: 'Upload failed.' })
})
server.post('/api/upload', async (request, reply) => {
let receipt: { field: string; filename: string; bytes: number; sha256: string } | null = null
for await (const part of request.parts()) {
if (part.type !== 'file') {
return reply.code(400).send({ error: 'Send one file field named file.' })
}
const bytes = await part.toBuffer()
if (part.fieldname !== 'file') {
return reply.code(400).send({ error: 'Send one file field named file.' })
}
if (bytes.length === 0) {
return reply.code(422).send({ error: 'Empty files are not accepted.' })
}
receipt = {
field: part.fieldname,
filename: part.filename,
bytes: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex'),
}
}
if (receipt === null) {
return reply.code(400).send({ error: 'Send one file field named file.' })
}
return receipt
})
const address = await server.listen({ host: '127.0.0.1', port: Number(process.env.PORT ?? '3000') })
console.log(`Receiver listening at ${address}`)
Crie proxy.json na raiz do projeto para que o servidor de desenvolvimento do Angular encaminhe
as requisições /api ao Node. O navegador usa sua própria origem, então este exemplo não
precisa de configuração de CORS.
{
"/api/**": {
"target": "http://127.0.0.1:3000"
}
}
A partir do diretório pai usado na configuração, inicie o receptor em um terminal:
cd angular-upload-demo && node server.ts
Em um segundo terminal, também a partir desse diretório pai, compile e sirva o app:
cd angular-upload-demo &&
corepack yarn@4.12.0 ng build &&
NG_CLI_ANALYTICS=false corepack yarn@4.12.0 ng serve --host 127.0.0.1 --port 4200 --proxy-config proxy.json
Abra http://127.0.0.1:4200. Se alguma das portas estiver ocupada, use outra porta livre: defina
PORT ao iniciar o Node e atualize o destino do proxy de acordo; altere o --port do
Angular conforme necessário. Pare os dois servidores com Ctrl+C quando terminar. Recompilar
substitui a saída dist/ deste app gerado; os arquivos enviados nunca são gravados lá.
Verifique sucesso e falha separadamente
Selecione um arquivo não vazio com menos de 1 MiB e acione o botão Upload. A mensagem final
deve dizer “Server accepted” e explicar que a demonstração não salvou o arquivo. No painel Rede
(Network) do navegador, inspecione a resposta /api/upload: field deve ser file, e bytes e
sha256 descrevem o conteúdo do arquivo recebido, excluindo o enquadramento multipart.
Selecione o mesmo arquivo novamente para enviar outra requisição.
Tente um arquivo vazio para ver a mensagem de HTTP 422. Um arquivo com mais de 1 MiB deve ser rejeitado na interface antes do envio; o servidor também aplica o próprio limite para clientes que contornam a interface. Enquanto um upload está pendente, o seletor de arquivos e o botão Upload continuam desabilitados, inclusive depois que a transmissão chega a 100%. Uma resposta atrasada ou rejeitada do servidor nunca deve virar uma mensagem de upload aceito.
Para testar uma falha de conexão, deixe a página carregar, mude o painel Rede do navegador para offline e depois faça o upload. Restaure a conectividade antes de tentar de novo. Parar apenas o receptor Node pode, em vez disso, produzir um erro HTTP vindo do proxy de desenvolvimento. Um erro de rede ou timeout não permite saber se o servidor processou a requisição antes da falha da conexão; este exemplo não tenta novamente de forma automática.
A verificação de tamanho é uma conveniência para o usuário, não uma barreira de segurança. Se a sua
aplicação precisa de uploads de JPEG, PNG ou PDF, o atributo accept do input pode orientar a
seleção, mas tanto ele quanto File.type são dicas controladas pelo cliente. Valide o conteúdo
real no servidor que recebe o arquivo. A MDN explica por que
as restrições do seletor de arquivos não validam uploads.
Um receptor implantado também precisa da autenticação, da autorização, da proteção contra
falsificação de requisições e da política de armazenamento da sua aplicação; o receptor local não
oferece nada disso.
Adicione a retomada de uploads quando precisar
Esta requisição multipart recomeça do zero após uma transferência com falha. Se você precisa continuar uploads interrompidos, veja uploads de arquivos retomáveis em Angular com tus-js-client. Para um seletor de arquivos e uma interface de upload prontos, o guia oficial do Uppy para Angular documenta seus componentes e versões compatíveis. Combine essa interface com um plugin de upload e um receptor que fale o mesmo protocolo.
