Téléverser des fichiers avec des Web Workers et des flux bornés
Utilisez un worker lorsque votre pipeline de téléversement doit traiter des fichiers hors du thread
principal de la page. Cet exemple lit un File de manière incrémentale, envoie un seul bloc de 1 MiB à la fois
par worker et attend l’accusé de réception final du récepteur avant d’afficher un lien de
téléchargement. Deux workers peuvent traiter des fichiers différents pendant que les fichiers
restants attendent dans une file d’attente bornée.
Les défis des téléversements de fichiers classiques
Un téléversement asynchrone ordinaire peut ajouter un File directement à FormData. Il n’est pas
nécessaire de le lire d’abord avec FileReader ; consultez les exemples FormData de MDN.
Commencez par là si vous avez seulement besoin d’envoyer un fichier. Les workers n’augmentent pas la
bande passante réseau, et ce tutoriel n’avance aucune affirmation sur la vitesse ou la mémoire
totale.
La tâche distincte traitée ici consiste à contrôler les lectures incrémentales et les accusés de réception sur plusieurs fichiers. L’introduction aux workers (English) couvre les messages de base des workers. Le tutoriel sur les blocs parallèles couvre les requêtes simultanées au sein d’un même fichier ; ici, les requêtes de chaque fichier sont séquentielles.
Découvrir les Web Workers et les flux
Web Workers
Les workers peuvent exécuter le traitement de fichiers et effectuer des requêtes, mais ne peuvent
pas mettre à jour le DOM de la page. La page envoie chaque File par clonage structuré et affiche
les messages de son worker. Elle ne lit pas le fichier dans un ArrayBuffer pour l’envoyer. Consultez le
guide Utilisation des Web Workers.
Flux JavaScript
Blob.stream(), hérité par File,
fournit un flux d’octets et est disponible dans les workers. Ses limites de lecture ne correspondent
pas à celles de notre protocole de téléversement. Le générateur ci-dessous regroupe ces lectures en
blocs de 1 MiB, en conservant intact un dernier bloc plus court.
Vue d’ensemble de l’architecture
La page accepte jusqu’à quatre fichiers, de 8 MiB au maximum chacun, fichiers vides compris. Le pool exécute au plus deux tâches. Chaque worker crée un téléversement, lit un bloc, attend l’accusé de réception de sa requête POST multipart, puis demande finalement au récepteur de finaliser le téléversement. La progression compte les octets de fichier confirmés, et non les octets en cours de transfert.
Le récepteur est fourni ci-dessous. Il vérifie les décalages et les longueurs, conserve jusqu’à quatre fichiers en mémoire et expose les fichiers terminés en téléchargement. Les téléversements expirent 60 secondes après leur création, y compris les téléversements terminés. L’arrêt du serveur entraîne la perte de tous les fichiers. Il s’agit d’une démonstration locale du protocole, sans nouvelles tentatives, sans reprise après rechargement ni stockage durable.
Configurer la démo locale de téléversement
Utilisez Node.js 26.8.1 pour cet exemple local, ainsi qu’un navigateur prenant en charge les workers
de module, Blob.stream() et AbortSignal.timeout(). Le parcours complet a été testé sous Linux avec Chromium 145.
Au 1er octobre 2026, Node 26 est une version « Current » maintenue ; consultez le
calendrier des versions lorsque vous choisissez un environnement d’exécution pour le
déploiement. Ce récepteur est destiné à localhost.
Dans Bash, créez un nouveau répertoire. Si la création ou le changement de répertoire échoue, arrêtez-vous et choisissez un autre nom avant d’enregistrer des fichiers. Aucune installation de dépendances ni configuration de build d’un projet englobant n’est nécessaire.
mkdir worker-stream-demo && cd worker-stream-demo
Enregistrez le HTML sous index.html, placez les deux blocs de code du thread principal dans main.ts dans
l’ordre indiqué, et enregistrez le worker et le récepteur sous upload-worker.ts et server.mts. Le récepteur
supprime les annotations TypeScript lorsqu’il sert les scripts du navigateur ; les navigateurs
n’exécutent jamais directement les sources .ts.
Thread principal (main.js)
Enregistrez ceci sous index.html. Les scripts seront servis depuis la même origine locale.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Worker stream uploads</title>
</head>
<body>
<main>
<h1>Worker stream uploads</h1>
<label for="file-input">Up to four files, each at most 8 MiB</label>
<input id="file-input" type="file" multiple />
<button id="upload-files" type="button">Upload files</button>
<button id="cancel-uploads" type="button" disabled>Cancel uploads</button>
<p id="status" role="status">Choose files.</p>
<ul id="results" aria-label="Upload results"></ul>
</main>
<script type="module" src="/main.js"></script>
</body>
</html>
Commencez main.ts avec ce pool. Il détache une tâche terminée avant d’appeler l’interface, et fait
avancer la file d’attente même si un callback lève une exception. Un échec du script du worker ou de
la sérialisation d’un message annule tout le pool au lieu de remettre en service un worker
défaillant.
export type WorkerReply =
| { type: 'progress'; progress: number; message: string }
| { type: 'complete'; message: string; url: string }
| { type: 'error'; message: string }
type Callbacks = {
onProgress?: (progress: number, message: string) => void
onComplete?: (message: string, url: string) => void
onError?: (message: string) => void
}
type Task = { id: string; file: File }
class WorkerPool {
closed = false
workers: Worker[] = []
idleWorkers: Worker[] = []
taskQueue: Task[] = []
taskCallbacks = new Map<string, Callbacks>()
currentTasks = new Map<Worker, Task>()
constructor(script: string, size = 2) {
if (!Number.isInteger(size) || size < 1 || size > 2) throw new Error('Use one or two workers')
try {
for (let index = 0; index < size; index++) {
const worker = new Worker(script, { type: 'module' })
worker.onmessage = (event: MessageEvent<WorkerReply>) => this.handleWorkerMessage(worker, event.data)
worker.onerror = (event) => {
event.preventDefault()
this.terminate('An upload worker failed. Try again.')
}
worker.onmessageerror = () => this.terminate('An upload worker failed. Try again.')
this.workers.push(worker)
this.idleWorkers.push(worker)
}
} catch (error) {
this.terminate()
throw error
}
}
processFile(file: File, callbacks: Callbacks, id = crypto.randomUUID()): string {
if (this.closed) throw new Error('The upload pool is closed.')
if (this.taskCallbacks.size >= 4) throw new Error('The upload queue is full.')
const task = { id, file }
this.taskCallbacks.set(task.id, callbacks)
const worker = this.idleWorkers.pop()
if (worker) this.runTask(worker, task)
else this.taskQueue.push(task)
return task.id
}
runTask(worker: Worker, task: Task): void {
this.currentTasks.set(worker, task)
try {
worker.postMessage(task)
} catch {
this.terminate('An upload worker failed. Try again.')
}
}
handleWorkerMessage(worker: Worker, data: WorkerReply): void {
if (this.closed) return
const task = this.currentTasks.get(worker)
if (!task) return
const callbacks = this.taskCallbacks.get(task.id)
if (!callbacks) return
if (data.type === 'progress') {
callbacks.onProgress?.(data.progress, data.message)
return
}
this.currentTasks.delete(worker)
this.taskCallbacks.delete(task.id)
try {
if (data.type === 'complete') callbacks.onComplete?.(data.message, data.url)
else callbacks.onError?.(data.message)
} finally {
if (!this.closed) {
const next = this.taskQueue.shift()
if (next) this.runTask(worker, next)
else this.idleWorkers.push(worker)
}
}
}
terminate(message = 'Upload canceled.'): void {
if (this.closed) return
this.closed = true
for (const worker of this.workers) worker.terminate()
this.workers = []
this.idleWorkers = []
this.taskQueue = []
this.currentTasks.clear()
const callbacks = [...this.taskCallbacks.values()]
this.taskCallbacks.clear()
const errors: unknown[] = []
for (const callback of callbacks) {
try {
callback.onError?.(message)
} catch (error) {
errors.push(error)
}
}
if (errors.length > 0) throw new AggregateError(errors, 'Upload cancellation callbacks failed.')
}
}
Ajoutez ce bloc de code à la fin de main.ts. La sélection de fichiers et les soumissions répétées sont
désactivées pendant qu’un lot est en cours ou en nettoyage. Chaque ligne correspond à un seul
fichier, de sorte qu’un échec ne peut pas être confondu avec le résultat d’un autre fichier. Les
noms sont insérés en tant que texte, jamais interprétés comme du HTML ou des chemins de stockage.
const input = document.getElementById('file-input')
const upload = document.getElementById('upload-files')
const cancel = document.getElementById('cancel-uploads')
const status = document.getElementById('status')
const results = document.getElementById('results')
if (!(input instanceof HTMLInputElement) || !(upload instanceof HTMLButtonElement) ||
!(cancel instanceof HTMLButtonElement) || !status || !results) throw new Error('Missing controls')
let pool: WorkerPool | null = null
let running = false
cancel.addEventListener('click', () => pool?.terminate())
window.addEventListener('pagehide', () => pool?.terminate())
input.addEventListener('change', () => {
if (!running) status.textContent = 'Ready to upload.'
})
upload.addEventListener('click', async () => {
if (running) return
const files = Array.from(input.files ?? [])
if (files.length < 1 || files.length > 4 || files.some((file) => file.size > 8 * 1024 * 1024)) {
status.textContent = 'Choose one to four files, each at most 8 MiB.'
return
}
running = true
input.disabled = upload.disabled = true
cancel.disabled = false
results.replaceChildren()
status.textContent = 'Uploading…'
const failedIds: string[] = []
let failures = 0
try {
const batchPool = new WorkerPool('/upload-worker.js')
pool = batchPool
const jobs = files.map((file) => new Promise<void>((resolve) => {
const id = crypto.randomUUID()
const row = document.createElement('li')
const name = document.createElement('p')
name.textContent = file.name
const progress = document.createElement('progress')
progress.max = 100
progress.value = 0
progress.setAttribute('aria-label', `Acknowledged bytes for ${file.name}`)
const message = document.createElement('p')
message.setAttribute('role', 'status')
message.textContent = 'Queued.'
row.append(name, progress, message)
results.append(row)
batchPool.processFile(file, {
onProgress(value, text) { progress.value = value; message.textContent = text },
onComplete(text, url) {
progress.value = 100
message.textContent = text
const link = document.createElement('a')
link.href = url
link.download = 'upload.bin'
link.textContent = `Download ${file.name}`
row.append(link)
resolve()
},
onError(text) {
failures++
failedIds.push(id)
message.textContent = text
resolve()
},
}, id)
}))
await Promise.all(jobs)
status.textContent = failures === 0 ? 'All uploads complete.' : 'Some uploads did not complete.'
} catch {
status.textContent = 'Could not start the upload workers. Try again.'
} finally {
pool?.terminate()
pool = null
let cleanupFailed = false
for (const id of failedIds) {
try {
const response = await fetch(`/uploads/${id}`, {
method: 'DELETE', headers: { 'X-Upload-Demo': '1' }, signal: AbortSignal.timeout(5000),
})
if (!response.ok) cleanupFailed = true
} catch { cleanupFailed = true }
}
if (cleanupFailed) status.textContent += ' Cleanup was not confirmed; uploads will expire.'
running = false
input.disabled = upload.disabled = false
cancel.disabled = true
}
})
Implémentation du worker (upload-worker.js)
Enregistrez ceci sous upload-worker.ts. Attendre chaque POST empêche le générateur de produire un autre bloc
applicatif tant que la requête précédente est en attente. Le navigateur peut tout de même mettre en
mémoire tampon, en interne, les lectures de flux et les données de requête. La taille de bloc de
1 MiB ne constitue pas une limite de la mémoire totale du navigateur.
import type { WorkerReply } from './main.js'
declare const self: DedicatedWorkerGlobalScope
const CHUNK_SIZE = 1024 * 1024
async function* readChunks(file: Blob): AsyncGenerator<Uint8Array<ArrayBuffer>> {
const reader = file.stream().getReader()
let buffer = new Uint8Array(CHUNK_SIZE)
let used = 0
let finished = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) { finished = true; break }
let offset = 0
while (offset < value.length) {
const length = Math.min(CHUNK_SIZE - used, value.length - offset)
buffer.set(value.subarray(offset, offset + length), used)
used += length
offset += length
if (used === CHUNK_SIZE) {
yield buffer
buffer = new Uint8Array(CHUNK_SIZE)
used = 0
}
}
}
if (used > 0) yield buffer.subarray(0, used)
} finally {
if (!finished) await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
function send(message: WorkerReply): void { self.postMessage(message) }
async function request(path: string, options: RequestInit): Promise<Response> {
const response = await fetch(path, {
...options, headers: { ...options.headers, 'X-Upload-Demo': '1' },
signal: AbortSignal.timeout(5000),
})
if (!response.ok) throw new Error('Upload request rejected')
return response
}
self.onmessage = async (event: MessageEvent<{ file: File; id: string }>) => {
const { file, id } = event.data
try {
if (!(file instanceof File) || file.size > 8 * CHUNK_SIZE) throw new Error('Invalid file')
await request(`/uploads/${id}`, { method: 'POST', headers: { 'Upload-Length': String(file.size) } })
send({ type: 'progress', progress: 0, message: 'Uploading…' })
let acknowledged = 0
for await (const chunk of readChunks(file)) {
const form = new FormData()
form.append('offset', String(acknowledged))
form.append('chunk', new Blob([chunk]), 'chunk.bin')
await request(`/uploads/${id}/chunks`, { method: 'POST', body: form })
acknowledged += chunk.length
send({ type: 'progress', progress: 100 * acknowledged / file.size,
message: `${acknowledged} / ${file.size} bytes acknowledged.` })
}
send({ type: 'progress', progress: 100, message: 'Confirming…' })
const response = await request(`/uploads/${id}/complete`, { method: 'POST' })
if (await response.text() !== String(file.size)) throw new Error('Unexpected acknowledgment')
send({ type: 'complete', message: 'Upload complete.', url: `/uploads/${id}/file` })
} catch {
send({ type: 'error', message: 'Upload failed. Try again.' })
}
}
Cet exemple utilise des requêtes multipart distinctes, et non un corps de requête ReadableStream. Laissez le
navigateur définir la délimitation multipart de Content-Type. Un fichier vide n’envoie aucun bloc mais
nécessite tout de même une finalisation.
Pourquoi ne pas lire le fichier entier en une fois dans le worker ?
Déplacer dans un worker un appel arrayBuffer() portant sur le fichier entier crée toujours un tampon
applicatif de la taille du fichier entier. Ici, le regroupeur conserve un tampon de 1 MiB plus la
lecture de flux en cours, et chaque tâche active n’a qu’une seule requête de bloc en attente
d’accusé de réception. Un Blob et l’implémentation réseau peuvent créer des copies
supplémentaires. Les tâches en file d’attente conservent des références de fichiers plutôt que des
octets de fichier lus à l’avance.
Construire un petit pool de workers
Le pool ci-dessus compte deux workers et au plus quatre tâches en attente. Il s’agit d’une limite d’ordonnancement, et non d’une recommandation fondée sur le nombre de processeurs. Davantage de workers peuvent augmenter la surcharge de traitement, de mémoire et de requêtes sans augmenter le débit utile. Mesurez avec votre propre étape de traitement et votre propre récepteur.
Recevoir les blocs localement
Enregistrez ceci sous server.mts. L’extension .mts maintient sans ambiguïté l’exécution en mode
module ES, y compris dans un projet CommonJS. stripTypeScriptTypes()
supprime les annotations ; il ne vérifie pas les types des scripts.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { stripTypeScriptTypes } from 'node:module'
const CHUNK_SIZE = 1024 * 1024
const MAX_BODY = CHUNK_SIZE + 4096
const TTL_MS = 60_000
type Upload = { bytes: Buffer; received: number; complete: boolean; expires: number }
const uploads = new Map<string, Upload>()
class HttpError extends Error {
status: number
constructor(status: number, message: string) { super(message); this.status = status }
}
function expire(): void {
for (const [id, upload] of uploads) {
if (upload.expires <= Date.now()) uploads.delete(id)
}
}
async function readBody(req: IncomingMessage, limit: number): Promise<Buffer> {
const bytes = Buffer.alloc(limit)
let length = 0
// Keep the connection long enough to report an oversize body.
for await (const part of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(part)) throw new HttpError(400, 'Expected bytes')
if (length + part.length > limit) throw new HttpError(413, 'Body too large')
part.copy(bytes, length)
length += part.length
}
return bytes.subarray(0, length)
}
async function main(): Promise<void> {
const assets = new Map<string, string>([['/', await readFile(new URL('./index.html', import.meta.url), 'utf8')]])
for (const name of ['main', 'upload-worker']) {
assets.set(`/${name}.js`, stripTypeScriptTypes(await readFile(new URL(`./${name}.ts`, import.meta.url), 'utf8')))
}
const port = Number(process.argv[2] ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
let origin = ''
let active = 0
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host ||
(req.headers.origin !== undefined && req.headers.origin !== origin)) throw new HttpError(403, 'Use the printed local URL')
const method = req.method
if (method !== 'GET' && req.headers['x-upload-demo'] !== '1') throw new HttpError(403, 'Missing demo header')
const path = new URL(req.url ?? '/', origin).pathname
const isChunk = method === 'POST' && path.endsWith('/chunks')
const body = await readBody(req, isChunk ? MAX_BODY : 0)
// Parse before looking up an upload: pending work cannot retain a deleted session.
let chunk: Uint8Array | undefined
let offset: string | undefined
if (isChunk) {
const contentType = req.headers['content-type']
if (typeof contentType !== 'string' || !contentType.startsWith('multipart/form-data;')) throw new HttpError(400, 'Expected multipart data')
let form: FormData
try {
form = await new Request(origin, { method: 'POST', headers: { 'Content-Type': contentType }, body: new Uint8Array(body) }).formData()
} catch { throw new HttpError(400, 'Invalid multipart data') }
const value = form.get('chunk')
const position = form.get('offset')
if (!(value instanceof Blob) || value.size > CHUNK_SIZE || typeof position !== 'string' ||
!/^(0|[1-9]\d*)$/.test(position) || [...form.keys()].length !== 2) throw new HttpError(400, 'Invalid chunk fields')
chunk = new Uint8Array(await value.arrayBuffer())
offset = position
}
expire()
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
const asset = assets.get(path)
if (method === 'GET' && asset !== undefined) {
res.setHeader('Content-Type', path === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(asset)
return
}
const match = /^\/uploads\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:\/(chunks|complete|file))?$/.exec(path)
if (!match) throw new HttpError(404, 'Unknown route')
const [, id, operation] = match
if (method === 'POST' && operation === undefined) {
const size = req.headers['upload-length']
if (typeof size !== 'string' || !/^(0|[1-9]\d*)$/.test(size) || Number(size) > 8 * CHUNK_SIZE) throw new HttpError(400, 'Expected a size from 0 to 8 MiB')
if (uploads.has(id)) throw new HttpError(409, 'Upload already exists')
if (uploads.size >= 4) throw new HttpError(503, 'Upload capacity reached')
uploads.set(id, { bytes: Buffer.alloc(Number(size)), received: 0, complete: false, expires: Date.now() + TTL_MS })
res.writeHead(201).end()
return
}
if (method === 'DELETE' && operation === undefined) {
uploads.delete(id)
res.writeHead(204).end()
return
}
const upload = uploads.get(id)
if (!upload) throw new HttpError(404, 'Upload missing or expired')
if (isChunk && operation === 'chunks') {
const expected = Math.min(CHUNK_SIZE, upload.bytes.length - upload.received)
if (!chunk || chunk.length === 0 || chunk.length !== expected || offset !== String(upload.received) || upload.complete) throw new HttpError(409, 'Unexpected chunk offset or length')
upload.bytes.set(chunk, upload.received)
upload.received += chunk.length
res.writeHead(204).end()
return
}
if (method === 'POST' && operation === 'complete') {
if (upload.received !== upload.bytes.length) throw new HttpError(409, 'Missing bytes')
upload.complete = true
res.end(String(upload.received))
return
}
if (method === 'GET' && operation === 'file') {
if (!upload.complete) throw new HttpError(409, 'Upload is not complete')
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Disposition', 'attachment; filename="upload.bin"')
res.end(upload.bytes)
return
}
throw new HttpError(405, 'Unsupported operation')
}
const server = createServer({ requestTimeout: 10_000, headersTimeout: 10_000 }, (req, res) => {
if (active >= 6) { res.writeHead(503, { Connection: 'close' }).end('Too many requests'); return }
active++
let handled = false
let closed = false
const deadline = setTimeout(() => { req.destroy(); res.destroy() }, 10_000)
function release(): void {
if (handled && closed) { clearTimeout(deadline); active-- }
}
res.once('close', () => { closed = true; release() })
handle(req, res).catch((error: unknown) => {
const status = error instanceof HttpError ? error.status : 500
const message = error instanceof HttpError ? error.message : 'Request failed'
if (!res.destroyed) res.writeHead(status, { Connection: 'close' }).end(message)
}).finally(() => { handled = true; release() })
})
server.maxConnections = 16
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('Missing server address')
origin = `http://127.0.0.1:${address.port}`
setInterval(expire, 1000).unref()
process.once('SIGINT', () => {
server.close()
server.closeAllConnections()
})
console.log(`Open ${origin}`)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Could not start the server')
process.exitCode = 1
})
Les quatre tampons de fichiers enregistrés totalisent au plus 32 MiB. L’analyse des requêtes utilise des tampons supplémentaires ; chaque corps accepté est limité à 1 MiB plus 4 KiB de surcharge multipart, données de fin comprises. Il y a au plus six gestionnaires actifs. Un téléchargement peut conserver son tampon de fichier après suppression ou expiration, de sorte que l’emplacement du gestionnaire reste occupé jusqu’à la fin du traitement et de la fermeture de la réponse. Ces limites décrivent les opérations de l’application, et non la mémoire totale du processus ni le moment du ramasse-miettes.
Depuis le répertoire contenant les quatre fichiers enregistrés, exécutez cette commande au premier plan :
node server.mts
Ouvrez l’URL http://127.0.0.1:PORT affichée. Sélectionnez des fichiers et cliquez sur
Upload files. Chaque ligne passe à
Confirming… une fois ses blocs confirmés ; seule une réponse de
finalisation réussie produit Upload complete. et un lien de
téléchargement. Cliquez sur le lien pour enregistrer les octets reçus sous upload.bin via votre
navigateur. Un nombre d’octets identique n’est pas une somme de contrôle d’intégrité ; comparez le
fichier téléchargé avec l’original lorsque vous vérifiez cet exemple.
Arrêtez le serveur avec Ctrl+C, ce qui ferme ses connexions. Pour choisir un port libre particulier,
ajoutez son numéro à la fin de node server.mts.
Si le port est occupé ou si un fichier source est manquant, le processus se termine avec une erreur
au lieu d’afficher une URL prête.
Compatibilité des navigateurs
Vérifiez la prise en charge de Worker, Blob.stream() et
AbortSignal.timeout() dans les versions de navigateur que vous prenez en charge.
Cet exemple ne transfère pas de flux entre threads. Son délai d’expiration de cinq secondes pour les
requêtes côté navigateur repose sur le temps actif et peut se mettre en pause dans un worker
suspendu ; le récepteur dispose d’un délai distinct de dix secondes. Les autres navigateurs et
systèmes d’exploitation n’ont pas été testés pour ce tutoriel.
Bonnes pratiques de gestion de la mémoire
Gardez un seul bloc applicatif en transit par worker et une petite file d’attente de références de
fichiers. Le générateur annule un flux inachevé et libère le verrou de son lecteur lorsqu’une
requête échoue. La page termine tous les workers à la fin d’un lot, lors d’une annulation et lors de
pagehide.
Cliquez sur Cancel uploads pendant qu’un lot est en cours. Les lignes
actives et en file d’attente reçoivent Upload canceled. ; les lignes déjà
terminées conservent leurs résultats. La page tente de supprimer les identifiants des
téléversements échoués ou annulés et maintient les commandes désactivées jusqu’à la fin du
nettoyage. Une réponse de création perdue, la fermeture de la page ou une suppression échouée peut
laisser des octets jusqu’à l’expiration. Terminer un worker n’exécute pas ses blocs finally et
n’annule pas une requête déjà acceptée par le récepteur.
Sécurité et résilience
Le serveur écoute sur 127.0.0.1, vérifie l’hôte et l’origine du navigateur, et exige un en-tête
personnalisé pour les mutations. Il n’active pas l’accès cross-origin, n’interprète pas les noms de
fichiers comme des chemins et ne sert pas les octets téléversés sous forme de HTML exécutable. Ce
sont des garde-fous de démo locale, et non une authentification ni une validation du contenu.
N’exposez pas ce récepteur comme service public de téléversement.
Un service déployé nécessite une authentification, une autorisation, des quotas par utilisateur, HTTPS, un stockage durable et une validation adaptée à ses consommateurs. Ce protocole à décalages rejette délibérément les blocs dupliqués ou réordonnés. Ajouter des nouvelles tentatives exige un contrat d’idempotence ; la reprise après rechargement exige un état persistant et une réconciliation avec le récepteur.
Déboguer les Web Workers
Inspectez les requêtes et les messages des workers dans les outils de développement du navigateur. Une erreur de chargement de script annule le pool ; un échec HTTP devient Upload failed. Try again. dans la ligne correspondante. Une réponse 409 indique un décalage erroné, une longueur de bloc erronée ou un fichier incomplet. Une réponse 503 peut signifier que les quatre emplacements de téléversement sont occupés, y compris par des fichiers terminés ; attendez leur expiration avant de réessayer. Les identifiants manquants ou expirés renvoient 404.
Pièges courants
- Considérer 100 % d’octets confirmés comme une finalisation. Le dernier POST peut encore échouer.
- Réactiver le champ de sélection de fichiers avant que le travail annulé ne soit terminé. Cette page attend l’issue de sa tentative de nettoyage.
- Supposer que la taille des lectures de flux correspond à celle des blocs HTTP. Le regroupeur gère un reste plus court.
- Supposer qu’un worker terminé supprime l’état côté serveur. L’expiration reste nécessaire.
- Lire à l’avance chaque fichier en file d’attente, ce qui va à l’encontre du traitement incrémental.
Points clés à retenir
Essayez un fichier vide et un fichier binaire légèrement supérieur à 1 MiB, puis comparez chaque résultat téléchargé avec son entrée. Le périmètre utile est le parcours complet, d’une lecture incrémentale à un bloc confirmé puis à un fichier confirmé, avec un petit nombre de tâches actives. Pour des téléversements reprenables en production, partez d’un protocole maintenu comme tus et d’un récepteur compatible. Le plugin tus d’Uppy fournit un client navigateur maintenu.
