Uploads de arquivos retomáveis no Angular com RxJS
Retome um upload interrompido no Angular a partir dos bytes que o receptor já persistiu, mesmo quando o navegador não recebeu a última resposta. Este tutorial cria um formulário Angular/RxJS para um único arquivo, um receptor local em Node.js e o proxy que os conecta. Após a publicação bem-sucedida, você pode comparar o arquivo salvo com o original.
Por que usar uploads em blocos e retomáveis?
Uma requisição interrompida pode ter chegado ao servidor mesmo que sua resposta nunca tenha chegado ao navegador. Por isso, a retomada lê o offset confirmado pelo servidor em vez de confiar em um contador local de blocos. Este exemplo envia blocos de 1 MiB sequencialmente e permite pausar, retomar e cancelar na mesma página. Recarregar a página faz com que o File selecionado e o UUID sejam perdidos; reiniciar o receptor faz com que o estado da sessão seja perdido. Após qualquer uma dessas ações, não é possível retomar o upload anterior neste exemplo.
Se você só precisa de uma única requisição multipart, use nosso tutorial básico de upload com Angular. Para uma retomada padronizada que também funcione após recarregar a página, use o tutorial de tus com Angular, disponível separadamente.
Configurar seu projeto Angular
Use Bash, Node.js 24.15.0, Corepack com Yarn 4.12.0 e um navegador moderno. As versões fixadas abaixo são Angular 22.2.0, TypeScript 6.0.3 e RxJS 7.8.2. Este exemplo, exclusivo para navegador, também foi testado com Node.js 26.8.1 e Chromium 145 no Linux. Consulte os requisitos de compatibilidade do Angular ao alterar essas versões.
No diretório pai escolhido, cole este bloco. Ele não prossegue se o destino já existir ou se estiver dentro de um projeto Yarn Plug’n’Play, cujo carregador pode afetar o build do Angular. Se alguma etapa falhar, o subshell mantém seu diretório atual e as opções do shell sem alterações:
(
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 chunked-upload-app &&
cd chunked-upload-app &&
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml &&
touch yarn.lock
)
Salve todos os arquivos a seguir dentro de chunked-upload-app,
criando src/app/ no seu editor.
package.json fixa as dependências diretas; mantenha o arquivo de lock gerado pela instalação:
{
"name": "chunked-upload-app",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "ng build",
"start": "ng serve --host 127.0.0.1 --port 4200"
},
"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",
"rxjs": "7.8.2",
"tslib": "2.8.1",
"typescript": "6.0.3"
}
}
Salve angular.json:
{
"version": 1,
"cli": { "analytics": false, "cache": { "enabled": false } },
"projects": {
"demo": {
"projectType": "application",
"root": ".",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.json",
"index": "src/index.html",
"outputPath": "dist",
"styles": [],
"assets": []
}
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"buildTarget": "demo:build",
"proxyConfig": "proxy.conf.json"
}
}
}
}
}
}
Salve tsconfig.json. As importações explícitas com
.ts são reescritas durante a compilação:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"strict": true,
"lib": ["ES2022", "DOM"],
"types": [],
"rewriteRelativeImportExtensions": true
},
"angularCompilerOptions": { "strictTemplates": true },
"files": ["src/main.ts"]
}
Salve src/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>Chunked Angular upload</title>
</head>
<body><app-root></app-root></body>
</html>
Salve proxy.conf.json. O servidor da CLI baseado em Vite precisa de
/api/** para corresponder às rotas de upload aninhadas;
consulte a configuração de proxy do Angular.
O navegador usa URLs relativas, portanto as requisições permanecem na origem da aplicação:
{
"/api/**": { "target": "http://127.0.0.1:4317", "secure": false }
}
Salve src/app/app.config.ts. O backend Fetch padrão do Angular não emite progresso de upload;
esta aplicação seleciona XHR explicitamente.
O cabeçalho da demonstração permite que o receptor rejeite envios comuns de formulários entre
sites. Ele é público, não uma credencial de autenticação:
import { provideHttpClient, withInterceptors, withXhr } from '@angular/common/http'
import type { ApplicationConfig } from '@angular/core'
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withXhr(),
withInterceptors([
(request, next) => next(request.clone({ setHeaders: { 'X-Demo-Upload': '1' } })),
]),
),
],
}
Adicionar o receptor local
Salve receiver.mts. Este é um receptor de desenvolvimento em loopback
sem login nem vínculo de propriedade com um usuário. Ele aceita requisições com o cabeçalho
da demonstração e, quando fornecida, a origem do navegador configurada. Qualquer processo local
pode enviar esse cabeçalho; não exponha este servidor nem o conecte a um serviço público por proxy.
| Requisição | Comportamento do receptor local |
|---|---|
PUT /api/uploads/{id} com {size} | Criar uma sessão ou retornar {offset, complete} para o mesmo UUID e tamanho. |
PUT /api/uploads/{id}/bytes | Validar o intervalo e o corpo, gravar e sincronizar o bloco com o disco e então confirmar com 204. |
POST /api/uploads/{id}/complete | Exigir todos os bytes, publicar uploads/{id}.bin sem substituir um arquivo existente e então retornar 204. |
DELETE /api/uploads/{id} | Remover um arquivo parcial e manter um marcador de cancelamento; retornar 409 se a publicação ocorrer antes do cancelamento. |
As verificações de estado e as alterações no disco são executadas de forma síncrona em um único processo após a leitura do corpo de cada requisição. Um bloco atrasado não pode recriar uma sessão cancelada. Cada arquivo aceito tem entre 1 byte e 100 MiB, cada bloco tem no máximo 1 MiB, e a demonstração mantém no máximo 32 IDs, incluindo os cancelados e concluídos, até ser reiniciada. Ela salva bytes arbitrários sem inspecionar o conteúdo.
import {
closeSync,
fsyncSync,
ftruncateSync,
linkSync,
mkdirSync,
openSync,
rmSync,
statSync,
writeSync,
} from 'node:fs'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { resolve } from 'node:path'
type Session = { size: number; offset: number; complete: boolean; canceled: boolean }
class RequestError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
const maxFile = 100 * 1024 * 1024
const maxChunk = 1024 * 1024
const sessions = new Map<string, Session>()
const directory = resolve('uploads')
const port = Number(process.env.PORT ?? '4317')
const appOrigin = process.env.APP_ORIGIN ?? 'http://127.0.0.1:4200'
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be between 1 and 65535.')
}
if (new URL(appOrigin).origin !== appOrigin || new URL(appOrigin).hostname !== '127.0.0.1') {
throw new Error('APP_ORIGIN must be a loopback URL such as http://127.0.0.1:4200.')
}
mkdirSync(directory, { recursive: true, mode: 0o700 })
function reply(response: ServerResponse, status: number, value?: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
response.end(value === undefined ? undefined : JSON.stringify(value))
}
async function body(request: IncomingMessage, limit: number): Promise<Buffer> {
const parts: Buffer[] = []
let length = 0
// Keep the socket available for a 413 response when stopping the body reader early.
for await (const part of request.iterator({ destroyOnReturn: false })) {
if (!(part instanceof Buffer)) throw new RequestError(400, 'Expected binary request data.')
length += part.length
if (length > limit) {
request.resume()
throw new RequestError(413, 'Request body is too large.')
}
parts.push(part)
}
return Buffer.concat(parts)
}
function reserve(id: string, size: number): Session {
const existing = sessions.get(id)
if (existing) {
if (existing.canceled) throw new RequestError(410, 'Upload was canceled.')
if (existing.size !== size) throw new RequestError(409, 'Upload size conflicts.')
return existing
}
if (sessions.size >= 32) throw new RequestError(429, 'Restart the demo receiver after 32 IDs.')
const file = openSync(resolve(directory, `${id}.part`), 'wx', 0o600)
closeSync(file)
const session = { size, offset: 0, complete: false, canceled: false }
sessions.set(id, session)
return session
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (
request.headers['x-demo-upload'] !== '1' ||
(request.headers.origin !== undefined && request.headers.origin !== appOrigin)
) {
throw new RequestError(403, 'Use the local demo application.')
}
const match = /^\/api\/uploads\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(\/bytes|\/complete)?$/u.exec(request.url ?? '')
if (!match) throw new RequestError(404, 'Unknown upload route.')
const id = match[1]
if (!id) throw new RequestError(400, 'Missing upload ID.')
const operation = match[2] ?? ''
const part = resolve(directory, `${id}.part`)
const output = resolve(directory, `${id}.bin`)
if (request.method === 'PUT' && operation === '') {
let input: unknown
try {
input = JSON.parse((await body(request, 512)).toString('utf8'))
} catch (error) {
if (error instanceof RequestError) throw error
throw new RequestError(400, 'Expected JSON containing size.')
}
if (
typeof input !== 'object' || input === null || !('size' in input) ||
typeof input.size !== 'number' || !Number.isSafeInteger(input.size) ||
input.size < 1 || input.size > maxFile
) {
throw new RequestError(400, 'Choose a file between 1 byte and 100 MiB.')
}
const session = reserve(id, input.size)
reply(response, 200, { offset: session.offset, complete: session.complete })
return
}
// Read first, then recheck state: cancellation can arrive while a body is pending.
const data = await body(request, operation === '/bytes' ? maxChunk : 512)
let session = sessions.get(id)
if (request.method === 'DELETE' && operation === '') {
if (session?.complete) throw new RequestError(409, 'Upload was already published.')
if (!session) {
if (sessions.size >= 32) throw new RequestError(429, 'Restart the demo receiver after 32 IDs.')
session = { size: 0, offset: 0, complete: false, canceled: true }
sessions.set(id, session)
}
session.canceled = true
rmSync(part, { force: true })
reply(response, 204)
return
}
if (!session || session.canceled) throw new RequestError(410, 'Upload is unavailable.')
if (request.method === 'PUT' && operation === '/bytes') {
const range = /^bytes (\d+)-(\d+)\/(\d+)$/u.exec(request.headers['content-range'] ?? '')
if (!range) throw new RequestError(400, 'Expected Content-Range.')
const start = Number(range[1])
const end = Number(range[2]) + 1
const total = Number(range[3])
if (
!Number.isSafeInteger(start) || !Number.isSafeInteger(end) ||
!Number.isSafeInteger(total) || session.complete || start !== session.offset ||
total !== session.size || end <= start || end > total || data.length !== end - start
) {
throw new RequestError(409, 'Range or body does not match the committed offset.')
}
// These synchronous mutations cannot interleave in this single Node process.
const file = openSync(part, 'r+')
try {
let written = 0
while (written < data.length) {
const count = writeSync(file, data, written, data.length - written, start + written)
if (count === 0) throw new Error('Disk write made no progress.')
written += count
}
ftruncateSync(file, end)
fsyncSync(file)
session.offset = end
} finally {
closeSync(file)
}
reply(response, 204)
return
}
if (request.method === 'POST' && operation === '/complete') {
if (!session.complete) {
if (session.offset !== session.size || statSync(part).size !== session.size) {
throw new RequestError(409, 'Upload still has missing bytes.')
}
// A hard link publishes on this filesystem without replacing an existing output.
linkSync(part, output)
session.complete = true
rmSync(part, { force: true })
console.log(`Published uploads/${id}.bin`)
}
reply(response, 204)
return
}
throw new RequestError(405, 'Unsupported upload operation.')
}
const server = createServer((request, response) => {
handle(request, response).catch((error: unknown) => {
if (response.destroyed) return
if (error instanceof RequestError) {
reply(response, error.status, { message: error.message })
return
}
console.error('Receiver could not process the upload.')
reply(response, 500, { message: 'Receiver could not process the upload.' })
})
})
server.requestTimeout = 30_000
server.on('error', () => {
console.error('Receiver failed to listen. Check whether its port is already in use.')
process.exitCode = 1
})
server.listen(port, '127.0.0.1', () => console.log(`Receiver ready on http://127.0.0.1:${port}`))
O receptor chama fsyncSync do Node
antes de avançar seu offset em memória. Isso permite uma nova tentativa após a perda de uma
resposta enquanto o processo está ativo; não é uma recuperação após encerramento inesperado.
Os arquivos .bin concluídos permanecem no disco após a reinicialização,
mas os metadados de suas sessões não. Os arquivos .part abandonados
também permanecem até que você os remova com o receptor parado. Mantenha esta demonstração em uma
pasta própria.
Implementar a divisão de arquivos em blocos
O serviço abaixo extrai um bloco por vez. end é exclusivo para
File.slice, mas inclusivo no cabeçalho de intervalo. Um arquivo de 2.097.155 bytes,
portanto, termina com bytes 2097152-2097154/2097155, não com um total inferido a partir do último
bloco, que é menor.
Criar um pipeline RxJS para uploads em blocos
Coloque o serviço completo em src/app/file-upload.service.ts. concatMap
mantém uma requisição em andamento por vez. Cada retomada determina o estado do servidor antes
de enviar mais bytes; os erros continuam observáveis e deixam o arquivo selecionado disponível
para outra tentativa de retomada.
import { HttpClient, HttpEventType } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import {
BehaviorSubject,
EMPTY,
Subscription,
catchError,
concatMap,
concatWith,
defer,
finalize,
range,
tap,
} from 'rxjs'
interface UploadState {
progress: number
running: boolean
canceling: boolean
complete: boolean
message: string
}
@Injectable({ providedIn: 'root' })
export class FileUploadService {
readonly state = new BehaviorSubject<UploadState>({
progress: 0,
running: false,
canceling: false,
complete: false,
message: 'Select a file.',
})
readonly #http = inject(HttpClient)
#request = new Subscription()
#file: File | null = null
#id = ''
#offset = 0
readonly #chunkSize = 1024 * 1024
select(file: File): void {
if (this.state.value.canceling) return
this.pause()
this.#file = null
if (file.size === 0 || file.size > 100 * 1024 * 1024) {
this.state.next({
progress: 0,
running: false,
canceling: false,
complete: false,
message: 'Choose a file between 1 byte and 100 MiB.',
})
return
}
this.#file = file
this.#id = crypto.randomUUID()
this.#offset = 0
this.state.next({
progress: 0,
running: false,
canceling: false,
complete: false,
message: 'Ready.',
})
this.resume()
}
resume(): void {
const file = this.#file
if (!file || this.state.value.running || this.state.value.canceling || this.state.value.complete) {
return
}
const url = `/api/uploads/${this.#id}`
this.state.next({ ...this.state.value, running: true, message: 'Uploading…' })
this.#request = this.#http
.put<unknown>(url, { size: file.size }, { timeout: 30_000 })
.pipe(
concatMap((status) => {
if (
typeof status !== 'object' ||
status === null ||
!('offset' in status) ||
typeof status.offset !== 'number' ||
!Number.isSafeInteger(status.offset) ||
status.offset < 0 ||
status.offset > file.size ||
!('complete' in status) ||
typeof status.complete !== 'boolean' ||
(status.complete && status.offset !== file.size)
) {
throw new Error('Invalid upload state')
}
this.#offset = status.offset
this.progress(file)
if (status.complete) {
this.finished()
return EMPTY
}
const offset = status.offset
return range(0, Math.ceil((file.size - offset) / this.#chunkSize)).pipe(
concatMap((index) =>
defer(() => {
const start = offset + index * this.#chunkSize
const end = Math.min(start + this.#chunkSize, file.size)
return this.#http
.put(`${url}/bytes`, file.slice(start, end), {
headers: { 'Content-Range': `bytes ${start}-${end - 1}/${file.size}` },
observe: 'events',
reportProgress: true,
timeout: 30_000,
})
.pipe(
tap((event) => {
if (event.type === HttpEventType.UploadProgress) {
this.progress(file, Math.min(event.loaded, end - start))
}
if (event.type === HttpEventType.Response) {
if (event.status !== 204) throw new Error('Chunk was not committed')
this.#offset = end
this.progress(file)
}
}),
)
}),
),
// Finalization runs once, including when the server already has every byte.
concatWith(
defer(() =>
this.#http
.post(`${url}/complete`, {}, { observe: 'response', timeout: 30_000 })
.pipe(
tap((response) => {
if (response.status !== 204) throw new Error('Publication was not confirmed')
this.finished()
}),
),
),
),
)
}),
finalize(() => this.state.next({ ...this.state.value, running: false })),
)
.subscribe({
error: () =>
this.state.next({ ...this.state.value, message: 'Upload interrupted. Resume to retry.' }),
})
}
pause(): void {
if (this.state.value.canceling) return
this.#request.unsubscribe()
if (this.#file && !this.state.value.complete) {
this.progress(this.#file)
this.state.next({ ...this.state.value, running: false, message: 'Paused.' })
}
}
cancel(): void {
if (this.state.value.canceling) return
this.pause()
if (!this.#file || this.state.value.complete) return
const url = `/api/uploads/${this.#id}`
this.#file = null
let confirmed = false
this.state.next({
progress: 0,
running: true,
canceling: true,
complete: false,
message: 'Canceling…',
})
this.#request = this.#http
.delete(url, { observe: 'response', timeout: 30_000 })
.pipe(
tap((response) => {
if (response.status !== 204) throw new Error('Cancellation was not confirmed')
confirmed = true
}),
// Errors and interrupted requests both leave cleanup unconfirmed.
catchError(() => EMPTY),
// Publish one settled state so observers can safely select another file.
finalize(() =>
this.state.next({
...this.state.value,
running: false,
canceling: false,
message: confirmed ? 'Canceled.' : 'Cancellation unconfirmed. Check your uploads.',
}),
),
)
.subscribe()
}
destroy(): void {
this.pause()
// Destruction also interrupts cleanup; its finalizer reports the unconfirmed result.
this.#request.unsubscribe()
}
private progress(file: File, loaded = 0): void {
this.state.next({
...this.state.value,
progress: Math.min(99, Math.floor((100 * (this.#offset + loaded)) / file.size)),
})
}
private finished(): void {
this.state.next({
progress: 100,
running: false,
canceling: false,
complete: true,
message: 'Upload complete.',
})
}
}
Adicionar pausa, retomada e cancelamento
Cancelar a inscrição aborta a requisição HTTP ativa. Isso não desfaz os bytes que o servidor já aceitou. A pausa preserva o File e o UUID em memória; a retomada reconcilia o offset. Recarregar a página faz com que esse estado seja perdido, portanto este exemplo não retoma uploads após recarregar a página. Pause antes de escolher outro arquivo. Isso inicia um novo UUID e deixa o arquivo parcial antigo no disco; cancele primeiro se quiser que o receptor o remova. O cancelamento tenta fazer a limpeza no servidor; uma falha de rede ou uma condição de corrida com a publicação exige verificar o diretório de saída e o log do receptor. Enquanto a limpeza está pendente, a pausa, a retomada, o cancelamento e a seleção de arquivos ficam desabilitados e são ignorados pelo serviço. A destruição do componente interrompe a limpeza e informa que o cancelamento não foi confirmado.
Acompanhar o progresso
Substitua src/app/app.ts por este componente raiz independente. A sintaxe integrada
de fluxo de controle não precisa de NgIf;
AsyncPipe é importado explicitamente. O progresso fica abaixo de 100 até que
a publicação seja confirmada. Uma retomada pode fazer o progresso regredir quando for necessário
reenviar bytes não confirmados.
import { AsyncPipe } from '@angular/common'
import { Component, inject, OnDestroy } from '@angular/core'
import { FileUploadService } from './file-upload.service.ts'
@Component({
selector: 'app-root',
standalone: true,
imports: [AsyncPipe],
template: `
@if (upload.state | async; as state) {
<label>File <input type="file" (change)="select($event)" [disabled]="state.running" /></label>
<progress aria-label="Upload progress" max="100" [value]="state.progress"></progress>
<p role="status">{{ state.message }}</p>
<button (click)="upload.pause()" [disabled]="!state.running || state.canceling">Pause</button>
<button (click)="upload.resume()" [disabled]="state.running || state.canceling || state.complete">Resume</button>
<button (click)="upload.cancel()" [disabled]="state.canceling || state.complete">Cancel</button>
}
`,
})
export class App implements OnDestroy {
readonly upload = inject(FileUploadService)
select(event: Event): void {
if (!(event.target instanceof HTMLInputElement)) return
const file = event.target.files?.[0]
event.target.value = ''
if (file) this.upload.select(file)
}
ngOnDestroy(): void {
this.upload.destroy()
}
}
Salve src/main.ts para inicializar a aplicação:
import { bootstrapApplication } from '@angular/platform-browser'
import { App } from './app/app.ts'
import { appConfig } from './app/app.config.ts'
bootstrapApplication(App, appConfig).catch(() => console.error('Application startup failed.'))
Executar e verificar o upload
Após salvar todos os arquivos, instale e faça o build a partir do diretório pai. Se a instalação ou o build falhar, a sequência será interrompida, e o subshell fará você voltar ao diretório original:
(
cd chunked-upload-app &&
corepack yarn install &&
corepack yarn build
)
Inicie o receptor em um terminal, a partir do mesmo diretório pai:
(
cd chunked-upload-app &&
node receiver.mts
)
Em um segundo terminal, inicie o servidor de desenvolvimento do Angular:
(
cd chunked-upload-app &&
corepack yarn start
)
Abra http://127.0.0.1:4200, selecione um arquivo não vazio de no máximo 100 MiB e acompanhe
o status. Para uma transferência grande, clique em
Pause e depois em
Resume sem recarregar a página. Uma transferência
pode terminar antes de você clicar; use a limitação de velocidade de rede do navegador se quiser
ter tempo para pausá-la. A barra de progresso fica abaixo de 100 até que o receptor confirme a
publicação. A página então mostra
Upload complete., e o receptor exibe
Published uploads/<UUID>.bin.
Compare esse exato arquivo .bin com o arquivo de entrada selecionado
usando uma ferramenta de soma de verificação ou uma comparação byte a byte. O UUID vem do log do
receptor, não do nome original do arquivo. Selecionar o mesmo arquivo de entrada novamente inicia
um upload separado; o componente limpa o campo de entrada nativo após a seleção para que seu
evento de alteração possa ser disparado novamente. Pare ambos os servidores em primeiro plano
com Ctrl+C.
Upload interrupted. Resume to retry. significa que uma requisição falhou ou que o receptor retornou um estado inválido. Verifique se o receptor está em execução e se o proxy aponta para a porta dele; depois, retome. Se o cancelamento informar Cancellation unconfirmed. Check your uploads., inspecione o log e o diretório de saída do receptor: a publicação pode ter sido concluída antes da chegada do cancelamento.
Se as portas padrão estiverem ocupadas, pare o processo conflitante que você iniciou ou escolha
duas portas livres. Altere a porta do frontend em package.json e a porta do
backend em proxy.conf.json; depois, inicie o receptor com valores correspondentes
para PORT e APP_ORIGIN, como
PORT=4318 APP_ORIGIN=http://127.0.0.1:4201 node receiver.mts a partir do diretório do projeto.
Reinicie o servidor Angular após alterar sua configuração de proxy.
Para uma aplicação pública, a API receptora também precisa de controle de propriedade com autenticação e de proteção contra CSRF validada em cada alteração. O protocolo de offset deste exemplo não fornece nenhum dos dois. Se você quiser um protocolo padrão e uma interface de upload consolidada, explore tus e Uppy.
