Subidas robustas de archivos con sincronización en segundo plano
Guarda el archivo antes de intentar enviarlo. Este tutorial pone un archivo pequeño en cola en IndexedDB, lo conserva al recargar la página sin conexión y lo entrega a un receptor local autenticado. Una clave estable y un comprobante vinculado a los bytes del archivo permiten reintentar de forma segura cuando el servidor guardó la subida, pero su respuesta nunca llegó.
Background Sync le da a un service worker otra oportunidad de reintentar. Requiere un contexto seguro y tiene disponibilidad limitada en los navegadores. No garantiza la entrega después de cerrar el navegador. Conserva el botón de reintento en primer plano y recuerda que el navegador puede desalojar los datos almacenados.
Cómo funcionan las subidas por fragmentos
Este ejemplo reintenta el envío de un archivo completo: desde un byte hasta 10 MiB, con un máximo de 10 archivos en cola entre todas las cuentas y una ventana de reintento de 24 horas. La división en fragmentos reduce la retransmisión tras una interrupción, pero no conserva un archivo que solo está en la memoria de la página. Para archivos grandes, el protocolo tus negocia los desplazamientos; aun así, necesitas conservar los bytes originales y autenticar el servidor.
El tutorial del cliente de subidas reanudables abarca esa tarea por separado. Su contrato con el servidor difiere del receptor de bytes sin procesar que se muestra a continuación.
Fetch ya ejecuta la E/S de red de forma asíncrona. Aquí, un Web Lock serializa los reintentos de la página y del service worker en todo el origen. Los Web Workers dedicados no añadirían persistencia ni harían atómica la publicación en el servidor.
Configura el proyecto local
Usa Node.js 24.15.0 con Corepack disponible, Yarn 4.12.0 y un navegador con IndexedDB, Web Locks y service workers. El flujo del navegador se probó con Chromium 145. Usa el origen de loopback exacto que imprime el servidor; HTTP en loopback es un contexto seguro. El despliegue requiere HTTPS y la autenticación de tu aplicación.
Pega esto en Bash desde el directorio padre donde quieras crear la demo. Rechaza un directorio de proyecto existente y deja tu shell en su directorio original, tanto si tiene éxito como si falla. El archivo de bloqueo local y la configuración explícita del empaquetador lo aíslan de un proyecto que lo contenga.
(
mkdir background-upload-demo &&
cd background-upload-demo &&
printf '%s\n' '{"name":"background-upload-demo","private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact idb@8.0.3 esbuild@0.27.3
)
Guarda los siguientes archivos dentro de background-upload-demo. Crea su directorio
public al guardar public/index.html. No se requiere una aplicación
existente, una cuenta en la nube ni un endpoint de subida.
Ejecuta un receptor que confirme bytes y comprobantes juntos
Guarda server.mts. Escucha solo en loopback e imprime contraseñas nuevas para
las dos cuentas de demostración al iniciarse. El inicio de sesión crea una sesión con una cookie
HttpOnly. Las solicitudes de modificación también deben coincidir con el origen, el propietario
de la sesión y el token CSRF. El navegador almacena solo un ID de cuenta junto con su cola;
obtiene el token de la sesión actual en cada reintento.
El receptor almacena el BLOB, el tamaño, el resumen SHA-256, la clave y el propietario en una sola fila de SQLite. Una transacción serializa la comprobación de cuota y la inserción. Devuelve 201 tras confirmar la transacción, o 200 con el mismo comprobante ante una repetición idéntica. Si cambian los bytes o las marcas de tiempo, devuelve 409. Las claves vencidas reciben 410, incluso cuando una repetición intenta proporcionar una marca de tiempo más reciente. Esta demo conserva las filas de forma permanente, por lo que la limpieza de comprobantes no puede hacer que una clave aceptada anteriormente vuelva a considerarse nueva por accidente.
La documentación de confirmaciones atómicas de SQLite explica los límites del almacenamiento. Aquí se usa la API de SQLite integrada en Node, que es una versión candidata a lanzamiento en la versión de Node probada. La persistencia presupone un disco local en funcionamiento; no es un servicio de almacenamiento replicado.
import { createHash, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { DatabaseSync } from 'node:sqlite'
import { parseArgs } from 'node:util'
const { values } = parseArgs({ options: { port: { type: 'string', default: '8090' } } })
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
const maxSize = 10 * 1024 * 1024
const lifetime = 24 * 60 * 60 * 1000
const passwords = { alice: randomUUID(), bob: randomUUID() }
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
let origin = ''
class HttpError extends Error {
status: number
constructor(status: number, message: string) { super(message); this.status = status }
}
async function body(request: IncomingMessage, limit: number): Promise<Buffer> {
const chunks: Buffer[] = []
let size = 0
for await (const chunk of request) {
if (!Buffer.isBuffer(chunk)) throw new HttpError(400, 'Invalid body')
size += chunk.length
if (size > limit) throw new HttpError(413, 'File too large')
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
async function main(): Promise<void> {
const db = new DatabaseSync('uploads.sqlite')
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, owner TEXT NOT NULL, csrf TEXT NOT NULL, expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS uploads (
owner TEXT NOT NULL, id TEXT NOT NULL, created INTEGER NOT NULL,
sha256 TEXT NOT NULL, size INTEGER NOT NULL, bytes BLOB NOT NULL,
PRIMARY KEY (owner, id)
);
`)
const reply = (response: ServerResponse, status: number, value: unknown) => {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(value))
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
response.setHeader('Cache-Control', 'no-store')
response.setHeader('X-Content-Type-Options', 'nosniff')
if (request.headers.host !== new URL(origin).host) throw new HttpError(403, 'Wrong host')
const path = new URL(request.url ?? '/', origin).pathname
if (request.method === 'POST' && request.headers.origin !== origin) {
throw new HttpError(403, 'Wrong origin')
}
if (path === '/login' && request.method === 'GET') {
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end(`<!doctype html><html lang="en"><meta name="viewport" content="width=device-width">
<meta name="color-scheme" content="light dark"><title>Local upload login</title><form method="post" action="/login">
<label>Account <select name="owner"><option>alice</option><option>bob</option></select></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button>Sign in</button></form></html>`)
return
}
if (path === '/login' && request.method === 'POST') {
const form = new URLSearchParams((await body(request, 2048)).toString('utf8'))
const owner = form.get('owner')
if ((owner !== 'alice' && owner !== 'bob') || form.get('password') !== passwords[owner]) {
throw new HttpError(401, 'Invalid local login')
}
const id = randomUUID()
db.prepare('INSERT INTO sessions VALUES (?, ?, ?, ?)').run(id, owner, randomUUID(), Date.now() + lifetime)
response.setHeader('Set-Cookie', `session=${id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400`)
response.writeHead(303, { Location: '/' }).end()
return
}
const cookie = request.headers.cookie?.split(';').map(part => part.trim()).find(part => part.startsWith('session='))
const session = db.prepare('SELECT * FROM sessions WHERE id = ? AND expires > ?').get(cookie?.slice(8) ?? '', Date.now())
if (path.startsWith('/api/') || path === '/received') {
if (!session) throw new HttpError(401, 'Sign in again')
}
if (path === '/api/upload-session' && request.method === 'GET') {
reply(response, 200, { owner: session?.owner, csrf: session?.csrf })
return
}
if (path === '/api/queued-uploads' && request.method === 'POST') {
if (!session || request.headers['x-upload-owner'] !== session.owner || request.headers['x-csrf-token'] !== session.csrf) {
throw new HttpError(403, 'Owner or CSRF mismatch')
}
const id = request.headers['idempotency-key']
const created = Number(request.headers['x-upload-created'])
if (typeof id !== 'string' || !uuid.test(id) || !Number.isSafeInteger(created)) {
throw new HttpError(400, 'Invalid upload identity')
}
if (request.headers['content-type'] !== 'application/octet-stream') throw new HttpError(415, 'Send raw bytes')
const bytes = await body(request, maxSize)
if (bytes.length === 0) throw new HttpError(400, 'Choose a nonempty file')
const sha256 = createHash('sha256').update(bytes).digest('hex')
let status = 200
db.exec('BEGIN IMMEDIATE')
try {
const previous = db.prepare('SELECT * FROM uploads WHERE owner = ? AND id = ?').get(session.owner, id)
const originalCreated = previous?.created ?? created
if (typeof originalCreated !== 'number' || Date.now() - originalCreated >= lifetime) {
throw new HttpError(410, 'Upload expired')
}
if (created > Date.now() + 60_000) throw new HttpError(400, 'Clock is ahead')
if (previous) {
if (previous.sha256 !== sha256 || previous.size !== bytes.length || previous.created !== created) {
throw new HttpError(409, 'Key already belongs to different content')
}
} else {
const usage = db.prepare('SELECT COALESCE(SUM(size), 0) AS total FROM uploads WHERE owner = ?').get(session.owner)
if (typeof usage?.total !== 'number' || usage.total + bytes.length > 100 * 1024 * 1024) {
throw new HttpError(429, 'Local account quota reached')
}
db.prepare('INSERT INTO uploads VALUES (?, ?, ?, ?, ?, ?)').run(session.owner, id, created, sha256, bytes.length, bytes)
status = 201
}
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
reply(response, status, { id, owner: session.owner, size: bytes.length, sha256 })
return
}
if (path === '/received' && request.method === 'GET') {
const rows = db.prepare('SELECT id, size, sha256 FROM uploads WHERE owner = ? ORDER BY id').all(session?.owner ?? '')
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end(`<!doctype html><html lang="en"><meta name="color-scheme" content="light dark"><title>Received uploads</title>
<h1>Received uploads</h1><p>${rows.length} stored file(s)</p><ul>${rows.map(row =>
`<li><a href="/api/files/${row.id}">${row.id}</a>: ${row.size} bytes, SHA-256 ${row.sha256}</li>`
).join('')}</ul><a href="/">Queue</a></html>`)
return
}
if (path.startsWith('/api/files/') && request.method === 'GET') {
const row = db.prepare('SELECT bytes FROM uploads WHERE owner = ? AND id = ?').get(session?.owner ?? '', path.slice(11))
if (!row || !(row.bytes instanceof Uint8Array)) throw new HttpError(404, 'File not found')
response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="upload.bin"' })
response.end(row.bytes)
return
}
const files: Record<string, [string, string]> = {
'/': ['index.html', 'text/html'],
'/upload-page.js': ['upload-page.js', 'text/javascript'],
'/upload-sw.js': ['upload-sw.js', 'text/javascript'],
}
const file = files[path]
if (request.method !== 'GET' || !file) throw new HttpError(404, 'Not found')
const content = await readFile(new URL(`./public/${file[0]}`, import.meta.url))
response.writeHead(200, { 'Content-Type': file[1] })
response.end(content)
}
const server = createServer((request, response) => {
void handle(request, response).catch(error => {
if (!(error instanceof HttpError)) console.error('Receiver request failed')
response.setHeader('Connection', 'close')
reply(response, error instanceof HttpError ? error.status : 500, {
error: error instanceof HttpError ? error.message : 'Upload not confirmed',
})
})
})
server.requestTimeout = 30_000
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('No listening address')
origin = `http://127.0.0.1:${address.port}`
console.log(JSON.stringify({ ready: origin, passwords }))
const stop = () => {
server.close(() => db.close())
server.closeAllConnections()
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
}
main().catch(error => { console.error('Receiver startup failed:', error.message); process.exitCode = 1 })
Integración de la sincronización en segundo plano
Guarda queue.ts. El cálculo del hash ocurre antes de la transacción de
inserción, y la obtención de datos termina antes de que comience una nueva transacción de
eliminación. Las transacciones de IndexedDB pueden cerrarse mientras hay operaciones asíncronas
no relacionadas pendientes; consulta la
documentación de idb sobre la duración de las transacciones.
Esperar a tx.done confirma que la escritura local quedó confirmada. La
eliminación requiere un comprobante 200/201 que coincida con la clave, el propietario, el tamaño y
el resumen, no solo una respuesta HTTP exitosa.
import { openDB } from 'idb'
const maxSize = 10 * 1024 * 1024
const lifetime = 24 * 60 * 60 * 1000
const lockName = 'durable-file-uploads'
async function digest(file) {
const bytes = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
return Array.from(new Uint8Array(bytes), byte => byte.toString(16).padStart(2, '0')).join('')
}
async function database() {
return openDB('durable-file-uploads', 1, {
upgrade(db) {
db.createObjectStore('uploads', { keyPath: 'id' })
},
})
}
export async function enqueue(file, owner) {
if (!owner || !(file instanceof File) || file.size === 0 || file.size > maxSize) {
throw new Error('Choose a file between 1 byte and 10 MiB while signed in.')
}
const sha256 = await digest(file)
const db = await database()
try {
const tx = db.transaction('uploads', 'readwrite')
const id = crypto.randomUUID()
// Observe request and transaction failures together, including quota-induced aborts.
await Promise.all([
(async () => {
// Counting and inserting in one transaction also bounds concurrent tabs.
if ((await tx.store.count()) >= 10) {
throw new Error('The queue is full. Retry or discard queued files first.')
}
await tx.store.add({ id, owner, file, name: file.name, sha256, created: Date.now() })
})(),
tx.done,
])
return id
} finally {
db.close()
}
}
export async function drain(signal = new AbortController().signal) {
return navigator.locks.request(lockName, { signal }, async () => {
const db = await database()
try {
// The count is bounded at ten. No readwrite transaction spans a fetch.
const entries = await db.getAll('uploads')
if (entries.length === 0) return { sent: 0, remaining: 0 }
let sent = 0
const sessionResponse = await fetch('/api/upload-session', {
credentials: 'same-origin',
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!sessionResponse.ok) throw new Error('Sign in again before retrying.')
const session = await sessionResponse.json()
if (
typeof session.owner !== 'string' ||
!session.owner ||
typeof session.csrf !== 'string' ||
!session.csrf
) {
throw new Error('Invalid upload session.')
}
for (const entry of entries) {
signal.throwIfAborted()
if (entry.owner !== session.owner) continue
if (Date.now() - entry.created >= lifetime) {
throw new Error('A queued file expired. Discard it before retrying.')
}
const response = await fetch('/api/queued-uploads', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
body: entry.file,
headers: {
'Content-Type': 'application/octet-stream',
'X-Upload-Created': String(entry.created),
'X-CSRF-TOKEN': session.csrf,
'X-Upload-Owner': entry.owner,
'Idempotency-Key': entry.id,
},
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!response.ok || ![200, 201].includes(response.status)) {
throw new Error(`Upload not confirmed (HTTP ${response.status}).`)
}
const receipt = await response.json()
if (receipt.id !== entry.id || receipt.owner !== entry.owner ||
receipt.size !== entry.file.size || receipt.sha256 !== entry.sha256) {
throw new Error('Invalid upload receipt.')
}
const tx = db.transaction('uploads', 'readwrite')
await Promise.all([tx.store.delete(entry.id), tx.done])
sent++
}
return { sent, remaining: entries.length - sent }
} finally {
db.close()
}
})
}
export async function discard(owner) {
// Serialize with uploads so a deletion cannot race a foreground or background replay.
await navigator.locks.request(lockName, async () => {
const db = await database()
try {
const tx = db.transaction('uploads', 'readwrite')
await Promise.all([
(async () => {
for (const entry of await tx.store.getAll()) {
if (entry.owner === owner) await tx.store.delete(entry.id)
}
})(),
tx.done,
])
} finally {
db.close()
}
})
}
Guarda upload-sw.ts. El worker almacena en caché solo la página estática y su
paquete, por lo que se puede volver a abrir sin conexión después de la primera visita exitosa en
línea. Nunca almacena en caché el inicio de sesión, las sesiones, los comprobantes ni las
respuestas de archivos. Una sincronización fallida rechaza waitUntil y deja
las entradas sin confirmar disponibles para reintentar.
import { drain } from './queue.ts'
const cacheName = 'upload-shell-v1'
const shell = ['/', '/upload-page.js']
self.addEventListener('install', event => {
event.waitUntil((async () => {
const cache = await caches.open(cacheName)
await cache.addAll(shell)
await self.skipWaiting()
})())
})
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()))
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
if (event.request.method === 'GET' && url.origin === self.location.origin && shell.includes(url.pathname)) {
event.respondWith(fetch(event.request).catch(async () => {
const response = await caches.match(url.pathname)
return response ?? Response.error()
}))
}
})
self.addEventListener('sync', event => {
if (event.tag === 'file-upload-sync') event.waitUntil(drain())
})
Guarda public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<title>Offline file queue</title>
</head>
<body>
<h1>Offline file queue</h1>
<p><a href="/login">Sign in or switch account</a> · <a href="/received">Received uploads</a></p>
<p id="account"></p>
<label>File <input id="file" type="file"></label>
<button id="queue" type="button">Queue file</button>
<button id="retry" type="button">Retry queued files</button>
<button id="stop" type="button">Stop foreground retry</button>
<button id="discard" type="button">Discard my queued files</button>
<p id="status" role="status"></p>
<script type="module" src="/upload-page.js"></script>
</body>
</html>
Guarda upload-page.ts. Los controles se desactivan durante una operación en primer
plano, lo que impide que los clics repetidos pongan en cola la misma selección dos veces. El
guardado local funciona incluso cuando Background Sync no está disponible. Al volver a abrir la
página sin conexión, el último ID de cuenta observado identifica la cola; el servidor sigue
comprobando la sesión actual de la cookie antes de aceptar cualquier byte.
import { discard, drain, enqueue } from './queue.ts'
const input = document.getElementById('file')
const status = document.getElementById('status')
const account = document.getElementById('account')
const controls = ['queue', 'retry', 'discard'].map(id => document.getElementById(id))
let owner = localStorage.getItem('upload-owner')
let busy = false
let controller = null
let registration = null
function available() {
return owner && 'indexedDB' in globalThis && navigator.locks
}
function render() {
account.textContent = owner ? `Local queue owner: ${owner}` : 'Sign in online before queuing files.'
input.disabled = busy || !available()
for (const button of controls) button.disabled = busy || !available()
}
async function schedule() {
if (!registration?.sync) return false
await registration.sync.register('file-upload-sync')
return true
}
async function action(operation) {
if (busy) return
busy = true
render()
try { await operation() }
catch { status.textContent = 'Retry stopped or failed. Some files may already be received; others remain queued.' }
finally { busy = false; controller = null; render() }
}
document.getElementById('queue').onclick = () => action(async () => {
const file = input.files?.[0]
if (!file) return
try {
await enqueue(file, owner)
input.value = ''
status.textContent = 'Saved locally. Use Retry queued files to upload now.'
} catch {
status.textContent = 'Could not save. Check the 10 MiB file limit, ten-file queue limit, and available storage.'
return
}
try {
if (await schedule()) status.textContent = 'Saved locally. Background retry requested.'
} catch { status.textContent = 'Saved locally. Background retry unavailable; use Retry queued files.' }
})
document.getElementById('retry').onclick = () => action(async () => {
controller = new AbortController()
const result = await drain(controller.signal)
status.textContent = `Delivered ${result.sent} file(s). ${result.remaining} remain queued.`
})
document.getElementById('stop').onclick = () => controller?.abort()
document.getElementById('discard').onclick = () => action(async () => {
await discard(owner)
status.textContent = 'Local queue discarded. Already received uploads remain on the server.'
})
render()
try {
const response = await fetch('/api/upload-session', { cache: 'no-store', redirect: 'error' })
if (response.ok) {
const session = await response.json()
if (!['alice', 'bob'].includes(session.owner)) throw new Error('Invalid session')
owner = session.owner
localStorage.setItem('upload-owner', owner)
} else if (response.status === 401) {
owner = null
localStorage.removeItem('upload-owner')
}
} catch { /* Offline: retain only the previously observed account ID, never credentials. */ }
render()
if (!available()) status.textContent = 'Durable uploads require sign-in, IndexedDB, and Web Locks.'
try {
if ('serviceWorker' in navigator) {
registration = await navigator.serviceWorker.register('/upload-sw.js')
await navigator.serviceWorker.ready
status.textContent = 'Ready for offline use. Background Sync is optional.'
}
} catch { status.textContent = 'Offline page setup failed. Keep this page open to queue and retry.' }
Desde el directorio padre original, compila ambos paquetes e inicia el receptor. La cadena
&& se detiene antes del inicio si falla cualquiera de las compilaciones,
aunque queden paquetes anteriores. Ctrl+C detiene el receptor y te devuelve a esa misma shell.
Si el puerto 8090 está ocupado, elige otro y sigue usando ese origen; el almacenamiento del
navegador está limitado al origen, incluido su puerto.
(
cd background-upload-demo &&
corepack yarn esbuild upload-page.ts --bundle --format=esm --target=es2022 --tsconfig-raw='{}' --outfile=public/upload-page.js &&
corepack yarn esbuild upload-sw.ts --bundle --format=iife --target=es2022 --tsconfig-raw='{}' --outfile=public/upload-sw.js &&
node server.mts --port=8090
)
La terminal imprime un objeto JSON con ready y el campo temporal
passwords. Abre http://127.0.0.1:8090/login, elige
alice, introduce su contraseña impresa y pulsa
Sign in. Espera a que aparezca
Ready for offline use. Background Sync is optional. antes de desconectarte.
Las contraseñas cambian al reiniciar; las sesiones existentes que no han vencido y las subidas
recibidas se conservan en background-upload-demo/uploads.sqlite.
- Desconecta el navegador o detén el receptor, elige un archivo no vacío de hasta 10 MiB y pulsa Queue file. Un mensaje que comienza con Saved locally. confirma la transacción de IndexedDB, no la entrega.
- Recarga la página o ciérrala y vuelve a abrirla en el mismo origen sin conexión. La página en caché se carga; no necesitas seleccionar el archivo de nuevo.
- Vuelve a conectarte. Los navegadores compatibles pueden entregar la cola mediante Background Sync. Para solicitar un intento en primer plano, pulsa Retry queued files.
- Abre Received uploads. Cada archivo almacenado tiene
una clave, un recuento de bytes, un resumen SHA-256 y un enlace de descarga. Descarga
upload.biny compara sus bytes con los del original. Un reintento de la misma entrada de la cola debe dejar una sola fila almacenada y el mismo comprobante.
Desconectar solo las solicitudes de una página en DevTools puede dejar su worker en línea. Para una prueba manual sin conexión, detén el receptor después de preparar la página; luego pon el archivo en cola y recarga. Reinícialo en el mismo puerto antes de reintentar. La programación del navegador no ofrece garantías, por lo que un intento en segundo plano que no se ejecuta es un motivo para usar el botón, no una prueba de que se perdió la cola.
Recupera el proceso sin dar por exitosa una subida no confirmada
Un 401 significa que necesitas volver a iniciar sesión. Cuando hay otra cuenta activa, los reintentos omiten las entradas del propietario original. Vuelve a iniciar sesión con la cuenta de ese propietario para entregarlas, o usa Discard my queued files para eliminar sus copias locales. Descartarlas nunca elimina los archivos ya recibidos en el servidor. No conserves archivos sensibles en un dispositivo compartido sin el consentimiento del usuario.
El archivo permanece en cola si vence, si hay un error HTTP, si el comprobante es incorrecto o si se interrumpe la solicitud. Es posible que algunas entradas anteriores ya hayan llegado. Revisa la lista de archivos recibidos antes de descartar; reintentar una entrada sin cambios es seguro dentro de su ventana de reintento. La cuota del servidor de 100 MiB por cuenta incluye todos los archivos conservados, por lo que un servidor lleno necesita una gestión deliberada de los datos, no clics repetidos. Una escritura local fallida debe mostrar un error de guardado, nunca un mensaje de entrega.
Stop foreground retry aborta solo el intento de la página actual. No puede deshacer una subida confirmada ni cancelar un intento en segundo plano programado por separado. El Web Lock también serializa el descarte local y la entrega, de modo que el primero no pueda eliminar una entrada mientras la segunda está en curso.
Para el despliegue, sustituye las contraseñas y sesiones de demostración por tu sistema de inicio de sesión existente, conserva los comprobantes o registros duraderos de vencimiento al eliminar objetos y aplica tu política de contenido multimedia antes de publicarlo. Este ejemplo de loopback acepta bytes arbitrarios no vacíos como descargas privadas; no realiza análisis de seguridad de archivos, no limita la tasa de intentos de inicio de sesión, no replica el almacenamiento ni promete recuperación después de que el navegador desaloje los datos. Para archivos más grandes, conserva las mismas comprobaciones de propiedad y confirmación al usar un protocolo reanudable.
