Téléversements de fichiers résilients avec Background Sync
Enregistrez le fichier avant d’essayer de l’envoyer. Ce guide met un petit fichier en file d’attente dans IndexedDB, le conserve après un rechargement de la page hors ligne et le transmet à un récepteur local authentifié. Une clé stable et un accusé de réception lié aux octets du fichier permettent de réessayer sans risque lorsque le serveur a enregistré le téléversement, mais que sa réponse n’est jamais arrivée.
Background Sync offre à un service worker une nouvelle occasion de réessayer. Ce mécanisme nécessite un contexte sécurisé et sa disponibilité dans les navigateurs est limitée. Il ne garantit pas la livraison après la fermeture du navigateur. Conservez le bouton de nouvelle tentative au premier plan et n’oubliez pas que les données stockées par le navigateur peuvent être supprimées.
Comprendre les téléversements par blocs
Cet exemple renvoie un fichier entier à chaque tentative : d’un octet à 10 MiB, avec au plus 10 fichiers en file d’attente, tous comptes confondus, et une fenêtre de réessai de 24 heures. Le découpage en blocs réduit les données à retransmettre après une interruption, mais ne préserve pas un fichier conservé uniquement dans la mémoire de la page. Pour les fichiers volumineux, tus négocie les décalages ; vous devez toujours conserver les octets d’origine et authentifier le serveur.
Le guide du client de téléversement avec reprise (English) traite ce sujet distinct. Le contrat de son serveur diffère de celui du récepteur d’octets bruts ci-dessous.
Fetch exécute déjà les entrées-sorties réseau de manière asynchrone. Ici, un Web Lock sérialise les nouvelles tentatives de la page et du service worker au sein de l’origine. Des Web Workers dédiés n’ajouteraient pas de persistance et ne rendraient pas la publication côté serveur atomique.
Configurer le projet local
Utilisez Node.js 24.15.0 avec Corepack disponible, Yarn 4.12.0 et un navigateur prenant en charge IndexedDB, Web Locks et les service workers. Le fonctionnement côté navigateur a été testé avec Chromium 145. Utilisez exactement l’origine de bouclage affichée par le serveur ; HTTP sur une adresse de bouclage constitue un contexte sécurisé. Le déploiement nécessite HTTPS et l’authentification de votre application.
Collez ce qui suit dans Bash depuis le répertoire parent où vous souhaitez placer la démo. Le script refuse un répertoire de projet existant et laisse votre shell dans son répertoire d’origine, en cas de réussite comme d’échec. Le fichier de verrouillage local et la configuration explicite de l’outil de regroupement isolent le projet d’un éventuel projet englobant.
(
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
)
Enregistrez les fichiers suivants dans background-upload-demo. Créez son répertoire
public lors de l’enregistrement de public/index.html.
Aucune application existante, aucun compte cloud ni aucun point de terminaison de téléversement
n’est nécessaire.
Exécuter un récepteur qui valide ensemble les octets et les accusés de réception
Enregistrez server.mts. Le récepteur écoute uniquement sur l’interface de bouclage
et affiche de nouveaux mots de passe pour les deux comptes de démonstration au démarrage. La connexion
crée une session avec un cookie HttpOnly. Les requêtes de modification doivent aussi correspondre à
l’origine, au propriétaire de la session et au jeton CSRF. Le navigateur ne stocke qu’un identifiant
de compte avec sa file d’attente ; il récupère le jeton de la session en cours à chaque nouvelle
tentative.
Le récepteur stocke le BLOB, la taille, l’empreinte SHA-256, la clé et le propriétaire dans une seule ligne SQLite. Une transaction sérialise la vérification du quota et l’insertion. Le récepteur renvoie 201 après la validation, ou 200 avec le même accusé de réception lors d’un renvoi identique. Si les octets ou les horodatages ont changé, il renvoie 409. Les clés expirées reçoivent 410, y compris lorsqu’un renvoi tente de fournir un horodatage plus récent. Cette démo conserve les lignes de façon permanente ; le nettoyage des accusés de réception ne peut donc pas faire passer accidentellement une clé déjà acceptée pour une nouvelle clé.
La documentation de SQLite sur la validation atomique explique le périmètre de stockage. Cet exemple utilise l’API SQLite intégrée à Node, qui est au stade de version candidate dans la version de Node testée. La persistance suppose un disque local fonctionnel ; il ne s’agit pas d’un service de stockage répliqué.
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 })
Intégrer Background Sync
Enregistrez queue.ts. Le hachage a lieu avant la transaction d’insertion, et
l’appel réseau se termine avant le début d’une nouvelle transaction de suppression. Les transactions
IndexedDB peuvent se fermer alors que d’autres opérations asynchrones sont encore en attente ;
consultez la documentation d’idb sur la durée de vie des transactions.
Attendre tx.done confirme que l’écriture locale a été validée. La suppression
exige un accusé de réception 200/201 correspondant à la clé, au propriétaire, à la taille et à
l’empreinte, et non simplement une réponse HTTP indiquant une réussite.
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()
}
})
}
Enregistrez upload-sw.ts. Le worker ne met en cache que la page statique et son
fichier regroupé, ce qui permet de la rouvrir hors ligne après la première visite en ligne réussie.
Il ne met jamais en cache la connexion, les sessions, les accusés de réception ni les réponses
relatives aux fichiers. L’échec d’une synchronisation rejette waitUntil et laisse
les entrées sans accusé de réception disponibles pour une nouvelle tentative.
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())
})
Enregistrez 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>
Enregistrez upload-page.ts. Les commandes sont désactivées pendant une opération au
premier plan, ce qui empêche des clics répétés de mettre deux fois la même sélection en file d’attente.
L’enregistrement local réussit même lorsque Background Sync est indisponible. Après une réouverture
hors ligne, le dernier identifiant de compte observé sert d’étiquette à la file d’attente ; le serveur
vérifie toujours la session en cours associée au cookie avant d’accepter le moindre octet.
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.' }
Depuis le répertoire parent d’origine, générez les deux fichiers regroupés et démarrez le récepteur.
L’enchaînement avec && s’arrête avant le démarrage si l’une des deux
générations échoue, même si d’anciens fichiers regroupés sont encore présents. Ctrl+C arrête le
récepteur et vous ramène au même shell. Si le port 8090 est occupé, choisissez un autre port et
continuez d’utiliser cette origine ; le stockage du navigateur est propre à l’origine, y compris
à son port.
(
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
)
Le terminal affiche un objet JSON avec ready et des valeurs temporaires pour
passwords. Ouvrez http://127.0.0.1:8090/login, choisissez
alice, saisissez le mot de passe affiché pour ce compte, puis appuyez sur
Sign in. Attendez que
Ready for offline use. Background Sync is optional. s’affiche avant de passer hors ligne.
Les mots de passe changent au redémarrage ; les sessions existantes non expirées et les téléversements
reçus sont conservés dans background-upload-demo/uploads.sqlite.
- Passez le navigateur hors ligne ou arrêtez le récepteur, choisissez un fichier non vide de 10 MiB au maximum, puis appuyez sur Queue file. Un message commençant par Saved locally. confirme la validation de l’écriture dans IndexedDB, pas la livraison.
- Rechargez la page ou fermez-la puis rouvrez-la à la même origine, tout en restant hors ligne. La page mise en cache se charge ; vous n’avez pas besoin de sélectionner à nouveau le fichier.
- Rétablissez la connexion. Les navigateurs compatibles peuvent transmettre les fichiers de la file d’attente via Background Sync. Pour demander une tentative au premier plan, appuyez sur Retry queued files.
- Ouvrez Received uploads. Chaque fichier stocké possède
une clé, un nombre d’octets, une empreinte SHA-256 et un lien de téléchargement. Téléchargez
upload.binet comparez ses octets à ceux de l’original. Une nouvelle tentative pour la même entrée de la file d’attente doit laisser une seule ligne stockée et le même accusé de réception.
Le fait de couper uniquement les requêtes d’une page dans DevTools peut laisser son worker en ligne. Pour une vérification manuelle hors ligne, arrêtez le récepteur après avoir préparé la page, puis mettez un fichier en file d’attente et rechargez la page. Redémarrez le récepteur sur le même port avant de réessayer. La planification par le navigateur se fait au mieux, sans garantie d’exécution ; une tentative en arrière-plan qui ne s’exécute pas constitue donc une raison d’utiliser le bouton, et non une preuve que la file d’attente a été perdue.
Reprendre sans annoncer la réussite d’un téléversement non confirmé
Une réponse 401 signifie que vous devez vous reconnecter. Lorsqu’un autre compte est actif, les nouvelles tentatives ignorent les entrées du propriétaire d’origine. Reconnectez-vous avec le compte de ce propriétaire pour les transmettre, ou utilisez Discard my queued files pour supprimer leurs copies locales. Cette suppression locale n’efface jamais les fichiers déjà reçus par le serveur. Ne conservez pas de fichiers sensibles sur un appareil partagé sans le consentement de l’utilisateur.
Un fichier reste en file d’attente s’il a expiré, en cas d’échec HTTP, d’accusé de réception incorrect ou de requête interrompue. Certaines entrées précédentes peuvent déjà avoir été reçues. Consultez la liste des fichiers reçus avant de supprimer les entrées locales de la file d’attente ; réessayer une entrée inchangée ne présente pas de risque pendant sa fenêtre de réessai. Le quota du serveur de 100 MiB par compte inclut tous les fichiers conservés ; un serveur plein nécessite donc une gestion délibérée des données, et non des clics répétés. Si une écriture locale échoue, le message doit signaler l’échec de l’enregistrement, jamais une livraison.
Stop foreground retry interrompt uniquement la tentative de la page en cours. Cette commande ne peut pas revenir sur un téléversement validé ni annuler une tentative en arrière-plan planifiée séparément. Le Web Lock sérialise aussi la suppression locale et la livraison, de sorte qu’aucune des deux opérations ne peut supprimer une entrée pendant l’exécution de l’autre.
Pour le déploiement, remplacez les mots de passe et les sessions de démonstration par votre système de connexion existant, conservez les accusés de réception ou des traces durables d’expiration lors de la suppression d’objets, et appliquez votre politique relative aux médias avant leur publication. Cet exemple sur interface de bouclage accepte des suites d’octets arbitraires non vides et les rend disponibles en téléchargement privé ; il n’effectue pas d’analyse de sécurité des fichiers, ne limite pas la fréquence des tentatives de connexion, ne réplique pas le stockage et ne promet pas de récupération après suppression des données par le navigateur. Pour les fichiers plus volumineux, conservez les mêmes vérifications de propriété et de confirmation autour d’un protocole avec reprise.
