Uploads resilientes de arquivos com Background Sync
Salve o arquivo antes de tentar enviá-lo. Este guia enfileira um arquivo pequeno no IndexedDB, preserva esse arquivo após recarregar a página offline e o entrega a um receptor local autenticado. Uma chave estável e um comprovante vinculado aos bytes do arquivo tornam seguras as novas tentativas quando o servidor salva o upload, mas a resposta nunca chega.
O Background Sync oferece ao service worker outra oportunidade de tentar novamente. Ele exige um contexto seguro e tem disponibilidade limitada nos navegadores. Isso não garante a entrega depois que você fecha o navegador. Mantenha o botão para tentar novamente em primeiro plano e lembre-se de que os dados armazenados no navegador podem ser removidos.
Entender uploads em partes
Este exemplo tenta reenviar um arquivo inteiro: de um byte a 10 MiB, com no máximo 10 arquivos na fila considerando todas as contas e uma janela de 24 horas para novas tentativas. A divisão em partes reduz a retransmissão após uma interrupção, mas não preserva um arquivo mantido apenas na memória da página. Para arquivos grandes, o tus negocia offsets; você ainda precisa reter os bytes originais e autenticar o servidor.
O guia do cliente de uploads retomáveis (English) aborda essa tarefa separadamente. O contrato do servidor desse cliente difere do contrato do receptor de bytes brutos apresentado abaixo.
O Fetch já executa a E/S de rede de forma assíncrona. Aqui, um Web Lock serializa as novas tentativas da página e do service worker em toda a origem. Web Workers dedicados não acrescentariam persistência nem tornariam atômica a publicação no servidor.
Configurar o projeto local
Use Node.js 24.15.0 com Corepack disponível, Yarn 4.12.0 e um navegador com IndexedDB, Web Locks e service workers. O fluxo no navegador foi testado com Chromium 145. Use a origem de loopback exata exibida pelo servidor; HTTP em loopback é um contexto seguro. A implantação exige HTTPS e a autenticação da sua aplicação.
Cole o trecho a seguir no Bash, a partir do diretório pai em que você quer criar a demonstração. O script não aceita um diretório de projeto já existente e mantém seu shell no diretório original em caso de sucesso ou falha. O arquivo de lock local e a configuração explícita do bundler isolam a demonstração de um projeto que contenha esse diretório.
(
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
)
Salve os arquivos a seguir dentro de background-upload-demo. Crie o diretório
public ao salvar public/index.html. Não é necessário ter uma
aplicação existente, uma conta na nuvem ou um endpoint de upload.
Executar um receptor que grave bytes e comprovantes juntos
Salve server.mts. Ele escuta apenas em loopback e exibe novas senhas para as duas
contas de demonstração na inicialização. O login cria uma sessão com cookie HttpOnly. As requisições
de mutação também precisam corresponder à origem, ao proprietário da sessão e ao token CSRF.
O navegador armazena apenas o ID de uma conta junto à fila; ele busca o token da sessão atual a cada
nova tentativa.
O receptor armazena o BLOB, o tamanho, o resumo SHA-256, a chave e o proprietário em um único registro no SQLite. Uma transação serializa a verificação de cota e a inserção. Ele retorna 201 após o commit, ou 200 com o mesmo comprovante em um reenvio idêntico. Bytes ou marcas de tempo alterados recebem 409. Chaves expiradas recebem 410, inclusive quando um reenvio tenta fornecer uma marca de tempo mais recente. Esta demonstração mantém os registros permanentemente, para que a limpeza de comprovantes não faça uma chave já aceita ser considerada nova novamente por acidente.
A documentação de commit atômico do SQLite explica os limites do armazenamento. O exemplo usa a API SQLite integrada ao Node, que está no estágio de candidata a lançamento na versão testada do Node. A persistência pressupõe um disco local em funcionamento; este não é um serviço de armazenamento 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 })
Integrar a sincronização em segundo plano
Salve queue.ts. O cálculo do hash ocorre antes da transação de inserção, e a
requisição de rede termina antes do início de uma nova transação de exclusão. As transações do
IndexedDB podem ser encerradas enquanto operações assíncronas não relacionadas ainda estão pendentes;
consulte a documentação sobre o tempo de vida das transações do idb.
Aguardar tx.done confirma que a gravação local foi efetivada. A exclusão exige
um comprovante 200/201 que corresponda à chave, ao proprietário, ao tamanho e ao resumo, em vez de
apenas uma resposta HTTP bem-sucedida.
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()
}
})
}
Salve upload-sw.ts. O service worker armazena em cache apenas a página estática e
seu bundle, permitindo reabri-la offline após a primeira visita online bem-sucedida. Ele nunca
armazena em cache login, sessões, comprovantes ou respostas de arquivos. Uma sincronização que falha
rejeita waitUntil e deixa as entradas sem confirmação disponíveis para novas
tentativas.
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())
})
Salve 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>
Salve upload-page.ts. Os controles ficam desativados durante uma operação em primeiro
plano, impedindo que cliques repetidos enfileirem a mesma seleção duas vezes. A gravação local
funciona mesmo quando o Background Sync está indisponível. Após reabrir a página offline, o último
ID de conta observado identifica a fila; o servidor ainda verifica a sessão atual identificada pelo
cookie antes de aceitar quaisquer bytes.
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.' }
A partir do diretório pai original, gere os dois bundles e inicie o receptor. A cadeia
&& interrompe a execução antes da inicialização se qualquer uma das
compilações falhar, mesmo que ainda haja bundles antigos. Ctrl+C encerra o receptor e devolve o
controle ao mesmo shell. Se a porta 8090 estiver ocupada, escolha outra porta e continue usando essa
origem; o armazenamento do navegador é delimitado pela origem, incluindo a porta.
(
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
)
O terminal exibe um objeto JSON com ready e
passwords de uso temporário. Abra http://127.0.0.1:8090/login, escolha
alice, insira a senha correspondente exibida e pressione
Sign in. Aguarde
Ready for offline use. Background Sync is optional. antes de desconectar.
As senhas mudam quando o receptor é reiniciado; as sessões existentes ainda não expiradas e os
uploads recebidos são preservados em background-upload-demo/uploads.sqlite.
- Coloque o navegador offline ou pare o receptor, escolha um arquivo não vazio de até 10 MiB e pressione Queue file. Uma mensagem que começa com Saved locally. confirma o commit no IndexedDB, não a entrega.
- Recarregue ou feche e reabra a página na mesma origem enquanto estiver offline. A página em cache é carregada; você não precisa selecionar o arquivo novamente.
- Reconecte. Navegadores compatíveis podem entregar os arquivos da fila por meio do Background Sync. Para solicitar uma tentativa em primeiro plano, pressione Retry queued files.
- Abra Received uploads. Cada arquivo armazenado tem uma
chave, uma contagem de bytes, um resumo SHA-256 e um link de download. Baixe
upload.bine compare seus bytes com os do arquivo original. Uma nova tentativa da mesma entrada da fila deve manter um único registro armazenado e o mesmo comprovante.
Desconectar apenas as requisições de uma página no DevTools pode deixar seu service worker online. Para testar manualmente o funcionamento offline, pare o receptor depois de preparar a página e, em seguida, enfileire o arquivo e recarregue a página. Reinicie o receptor na mesma porta antes de tentar novamente. O agendamento pelo navegador segue o princípio de melhor esforço; portanto, uma tentativa em segundo plano que não é executada é motivo para usar o botão, não uma prova de que a fila foi perdida.
Retomar sem afirmar que um upload não confirmado teve sucesso
Um 401 significa que você precisa fazer login novamente. Quando outra conta está ativa, as novas tentativas ignoram as entradas do proprietário original. Faça login novamente na conta desse proprietário para entregá-las ou use Discard my queued files para remover suas cópias locais. O descarte nunca exclui arquivos já recebidos pelo servidor. Não retenha arquivos sensíveis em um dispositivo compartilhado sem o consentimento do usuário.
Um arquivo expirado, uma falha HTTP, um comprovante incorreto ou uma requisição interrompida mantém a entrada na fila. Algumas entradas anteriores podem já ter chegado. Confira a lista de recebidos antes de descartar; é seguro tentar novamente uma entrada sem alterações dentro da janela de novas tentativas. A cota de 100 MiB aplicada pelo servidor separadamente a cada conta inclui todos os arquivos retidos, então um servidor cheio precisa de gerenciamento deliberado de dados, não de cliques repetidos. Uma falha na gravação local deve exibir uma falha ao salvar, nunca uma mensagem de entrega.
Stop foreground retry interrompe apenas a tentativa da página atual. Esse controle não pode desfazer um upload já gravado nem cancelar uma tentativa em segundo plano agendada separadamente. O Web Lock também serializa o descarte local e a entrega, impedindo que uma operação exclua uma entrada enquanto a outra está em andamento.
Para implantar, substitua as senhas e sessões de demonstração pelo seu sistema de login existente, mantenha comprovantes ou registros duráveis de expiração ao excluir objetos e aplique sua política de mídia antes da publicação. Este exemplo em loopback aceita sequências arbitrárias e não vazias de bytes como downloads privados; ele não faz varredura de segurança nos arquivos, não limita a taxa de tentativas de login, não replica o armazenamento nem promete recuperação após a remoção de dados pelo navegador. Para arquivos maiores, mantenha as mesmas verificações de propriedade e confirmação ao usar um protocolo retomável.
