Subidas reanudables de archivos en Angular con RxJS
Reanuda una subida interrumpida en Angular desde los bytes confirmados por el receptor, incluso si el navegador no recibió su última respuesta. Esta guía crea un formulario Angular/RxJS para un solo archivo, un receptor local Node.js y el proxy que los conecta. Una vez que la publicación se complete correctamente, puedes comparar el archivo guardado con el original.
¿Por qué usar subidas por fragmentos y reanudables?
Una solicitud interrumpida puede haber llegado al servidor aunque su respuesta nunca haya llegado al navegador. Por eso, la reanudación consulta el desplazamiento confirmado por el servidor en lugar de confiar en un contador local de fragmentos. Este ejemplo envía fragmentos de 1 MiB secuencialmente y permite pausar, reanudar y cancelar dentro de la misma página. Al recargar, se pierden el File seleccionado y el UUID; al reiniciar el receptor, se pierde el estado de la sesión. Ninguna de estas acciones permite reanudar la subida anterior en este ejemplo.
Si solo necesitas una solicitud multipart, usa nuestra guía básica de subidas en Angular. Para una reanudación estandarizada que también funcione tras recargar la página, usa el tutorial de Angular con el protocolo tus, disponible por separado.
Configura tu proyecto Angular
Usa Bash, Node.js 24.15.0, Corepack con Yarn 4.12.0 y un navegador moderno. Las versiones fijadas a continuación son Angular 22.2.0, TypeScript 6.0.3 y RxJS 7.8.2. Este ejemplo, exclusivo para el navegador, también se probó con Node.js 26.8.1 y Chromium 145 en Linux. Consulta los requisitos de compatibilidad de Angular si cambias esas versiones.
Desde el directorio padre que elijas, pega este bloque. Rechaza un destino existente y un proyecto Yarn Plug’n’Play que lo contenga, cuyo cargador puede afectar la compilación de Angular. El subshell mantiene tu directorio actual y las opciones del shell sin cambios si falla algún paso:
(
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
)
Guarda todos los archivos siguientes dentro de chunked-upload-app y crea
src/app/ en tu editor.
package.json fija las dependencias directas; conserva el archivo de bloqueo
generado por la instalación:
{
"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"
}
}
Guarda 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"
}
}
}
}
}
}
Guarda tsconfig.json. Las importaciones explícitas de
.ts se reescriben durante la compilación:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"strict": true,
"lib": ["ES2022", "DOM"],
"types": [],
"rewriteRelativeImportExtensions": true
},
"angularCompilerOptions": { "strictTemplates": true },
"files": ["src/main.ts"]
}
Guarda 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>
Guarda proxy.conf.json. El servidor de la CLI, basado en Vite, necesita
/api/** para reconocer las rutas de subida anidadas;
consulta la configuración del proxy de Angular.
El navegador usa URL relativas, por lo que las solicitudes permanecen en el origen de la aplicación:
{
"/api/**": { "target": "http://127.0.0.1:4317", "secure": false }
}
Guarda src/app/app.config.ts. El backend Fetch predeterminado de Angular no emite el
progreso de subida; esta aplicación selecciona XHR explícitamente.
El encabezado del ejemplo permite al receptor rechazar envíos de formularios comunes desde otros
sitios. Es público, no una credencial de autenticación:
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' } })),
]),
),
],
}
Añade el receptor local
Guarda receiver.mts. Este es un receptor de desarrollo en la interfaz de loopback,
sin inicio de sesión ni propiedad asociada a usuarios. Acepta solicitudes con el encabezado del
ejemplo y, cuando se proporciona, el origen del navegador configurado. Cualquier proceso local puede
enviar ese encabezado; no expongas este servidor ni lo conectes mediante un proxy a un servicio público.
| Solicitud | Comportamiento del receptor local |
|---|---|
PUT /api/uploads/{id} con {size} | Crea una sesión o devuelve {offset, complete} para el mismo UUID y tamaño. |
PUT /api/uploads/{id}/bytes | Valida el rango y el cuerpo, escribe el fragmento y fuerza su persistencia en disco; después confirma con 204. |
POST /api/uploads/{id}/complete | Exige todos los bytes, publica uploads/{id}.bin sin reemplazar un archivo existente y después devuelve 204. |
DELETE /api/uploads/{id} | Elimina un archivo parcial y conserva un marcador de cancelación; devuelve 409 si la publicación ganó la condición de carrera. |
Las comprobaciones de estado y las modificaciones en disco se ejecutan de forma síncrona en un solo proceso después de leer el cuerpo de cada solicitud. Un fragmento tardío no puede volver a crear una sesión cancelada. Cada archivo aceptado tiene entre 1 byte y 100 MiB, cada fragmento tiene como máximo 1 MiB y el ejemplo conserva como máximo 32 IDs, incluidos los cancelados y completados, hasta el reinicio. Guarda bytes arbitrarios sin inspeccionar el contenido.
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}`))
El receptor llama a fsyncSync de Node
antes de avanzar su desplazamiento en memoria. Esto permite reintentar tras perder una respuesta
mientras el proceso siga activo; no permite recuperarse de una caída. Los archivos
.bin completados permanecen en disco después del reinicio, pero los
metadatos de sus sesiones no. Los archivos .part abandonados también
permanecen hasta que los elimines con el receptor detenido. Mantén este ejemplo en su propia carpeta.
Implementa la división de archivos
El servicio siguiente divide el archivo en un fragmento a la vez. end
es exclusivo para File.slice, pero inclusivo en el encabezado de rango.
Por lo tanto, un archivo de 2.097.155 bytes termina con bytes 2097152-2097154/2097155, no con
un total deducido del fragmento final más corto.
Crea un pipeline de RxJS para subir fragmentos
Coloca el servicio completo en src/app/file-upload.service.ts. concatMap
mantiene una solicitud en curso. Cada reanudación determina el estado del servidor antes de enviar
más bytes; los errores siguen siendo observables y dejan el archivo seleccionado disponible para
otro intento de reanudación.
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.',
})
}
}
Añade pausa, reanudación y cancelación
Cancelar la suscripción aborta la solicitud HTTP activa. No revierte los bytes que el servidor ya aceptó. La pausa conserva el File y el UUID en memoria; la reanudación sincroniza el desplazamiento con el servidor. Al recargar se pierde este estado, por lo que este ejemplo no reanuda las subidas tras recargar la página. Pausa antes de elegir un archivo de reemplazo. Eso inicia un nuevo UUID y deja el archivo parcial anterior en disco; cancela primero si quieres que el receptor lo elimine. La cancelación intenta limpiar los datos en el servidor; un fallo de red o una condición de carrera con la publicación requiere revisar el directorio de salida y el registro del receptor. Mientras la limpieza esté pendiente, la pausa, la reanudación, la cancelación y la selección de archivos se deshabilitan y el servicio las ignora. Destruir el componente interrumpe la limpieza e informa que la cancelación no está confirmada.
Supervisa el progreso
Reemplaza src/app/app.ts con este componente raíz independiente. La sintaxis
integrada de flujo de control no necesita NgIf;
AsyncPipe se importa explícitamente. El progreso permanece por debajo de
100 hasta que se confirma la publicación. Una reanudación puede hacer retroceder el progreso
cuando sea necesario reenviar bytes no 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()
}
}
Guarda src/main.ts para iniciar la aplicación:
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.'))
Ejecuta y verifica la subida
Después de guardar todos los archivos, instala y compila desde tu directorio padre. Si falla la instalación o la compilación, la cadena se detiene y el subshell te devuelve a tu directorio original:
(
cd chunked-upload-app &&
corepack yarn install &&
corepack yarn build
)
Inicia el receptor en una terminal, desde el mismo directorio padre:
(
cd chunked-upload-app &&
node receiver.mts
)
En una segunda terminal, inicia el servidor de desarrollo de Angular:
(
cd chunked-upload-app &&
corepack yarn start
)
Abre http://127.0.0.1:4200, selecciona un archivo no vacío de como máximo 100 MiB y
observa el estado. Para una transferencia grande, haz clic en
Pause y después en
Resume sin recargar. Una transferencia
puede terminar antes de que hagas clic; usa la limitación de velocidad de red del navegador si
quieres tener tiempo para pausarla. La barra de progreso permanece por debajo de 100 hasta que
el receptor confirma la publicación. Entonces, la página muestra
Upload complete. y el receptor imprime
Published uploads/<UUID>.bin.
Compara ese archivo .bin exacto con el archivo de entrada seleccionado
mediante una herramienta de suma de verificación o una comparación de bytes. El UUID proviene del
registro del receptor, no del nombre original del archivo. Seleccionar de nuevo el mismo archivo de
entrada inicia una subida independiente; el componente vacía el campo de entrada nativo después de
la selección para que su evento de cambio pueda volver a activarse. Detén ambos servidores en
primer plano con Ctrl+C.
Upload interrupted. Resume to retry. significa que una solicitud falló o que el receptor devolvió un estado no válido. Comprueba que el receptor esté en ejecución y que el proxy apunte a su puerto; después reanuda. Si la cancelación informa Cancellation unconfirmed. Check your uploads., inspecciona el registro y el directorio de salida del receptor: la publicación puede haberse completado antes de que llegara la cancelación.
Si los puertos predeterminados están ocupados, detén el proceso de tu propiedad que cause el
conflicto o elige dos puertos libres. Cambia el puerto del frontend en
package.json y el del backend en proxy.conf.json; después inicia
el receptor con valores de PORT y APP_ORIGIN que
coincidan, como PORT=4318 APP_ORIGIN=http://127.0.0.1:4201 node receiver.mts desde el directorio del proyecto.
Reinicia el servidor de Angular después de cambiar la configuración de su proxy.
Para una aplicación pública, la API receptora también necesita verificar la propiedad mediante autenticación y validar la protección CSRF en cada modificación. El protocolo de desplazamientos de este ejemplo no proporciona ninguna de las dos medidas. Si quieres un protocolo estándar y una interfaz de subida consolidada, explora el protocolo tus y Uppy.
