Implémenter des envois par glisser-déposer en HTML5 et JavaScript
Un téléversement par lots doit produire un résultat pour chaque fichier, même lorsqu’une requête échoue. Créez un téléverseur JavaScript qui vous permet de sélectionner ou de déposer plusieurs fichiers, les envoie un par un et garde visibles la progression et l’accusé de réception du serveur pour chaque fichier.
Configurer un téléverseur par lots local
Il vous faut un navigateur, un shell POSIX tel que Bash, et Node.js 24.15 ou plus récent dans la
branche maintenue 24.x, ou Node.js 26.5 ou plus récent dans la branche 26.x. Il n’y a aucun paquet à
installer ni aucune étape de compilation.
Le serveur utilise les API intégrées de Node pour Request, File et l’analyse multipart. Conservez l’extension .mts :
Node la traite comme un module ES
même au sein d’un projet CommonJS.
Ce guide a été testé sous Linux avec Node.js 24.15.0, 26.5.0 et 26.8.1, ainsi qu’avec Chromium 145.
Cet exemple vérifie la concordance des octets, puis supprime le fichier téléversé. Il accepte tout type de fichier, y compris les fichiers vides, jusqu’à 5 MiB chacun, avec au plus 10 fichiers par sélection. Si vous n’avez besoin que d’un seul fichier avec de nouvelles tentatives manuelles, utilisez le guide du téléverseur de fichier unique.
Depuis le répertoire où vous conservez vos expérimentations, créez un nouveau dossier :
mkdir html5-upload-demo
S’il existe déjà, arrêtez-vous et choisissez un autre nom de répertoire ; n’écrasez pas un projet
existant.
Enregistrez les quatre blocs de code suivants sous index.html, styles.css, upload.js et server.mts dans
ce dossier.
Créer un formulaire de glisser-déposer de base
Enregistrez sous index.html. Le champ natif reste visible et accessible au clavier.
L’attribut multiple
permet de choisir plusieurs fichiers en une seule sélection. Une nouvelle sélection remplace la file
d’attente tant qu’elle est inactive.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Batch file uploader</title>
<link rel="stylesheet" href="styles.css" />
<script src="upload.js" defer></script>
</head>
<body>
<main>
<h1>Upload a batch of files</h1>
<form id="upload-form" aria-label="Batch upload">
<section id="drop-zone" aria-label="Drop files">
<label for="file-input">Choose files</label>
<input id="file-input" type="file" multiple aria-describedby="file-help" />
<p id="file-help">Choose or drop up to 10 files, each no larger than 5 MiB.</p>
</section>
<button id="upload-button" type="submit" disabled>Upload batch</button>
</form>
<p id="batch-status" role="status" aria-atomic="true">Choose files to begin.</p>
<ol id="queue" aria-label="File queue"></ol>
<noscript>This uploader needs JavaScript enabled.</noscript>
</main>
</body>
</html>
Styliser la file d’attente
Enregistrez sous styles.css. Les couleurs système suivent le thème clair ou sombre du navigateur. Les noms
longs et les sommes de contrôle passent à la ligne au lieu d’élargir la page.
:root { color-scheme: light dark; }
body {
font: 1rem/1.5 system-ui, sans-serif;
max-width: 45rem;
margin: 2rem auto;
padding: 0 1rem;
color: CanvasText;
background: Canvas;
}
#drop-zone { border: 2px dashed currentColor; padding: 1rem; }
#drop-zone.drag-over { outline: 3px solid Highlight; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button { font: inherit; padding: 0.5rem; }
button { margin-top: 1rem; }
:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; }
#queue { padding-left: 1.5rem; }
#queue li { margin-block: 1rem; overflow-wrap: anywhere; }
progress { display: block; width: 100%; }
Implémenter les interactions de glisser-déposer
Enregistrez sous upload.js. Chaque ligne de la file affiche le nom du fichier et sa taille en octets avant
l’envoi ; cet exemple ne décode pas les fichiers pour afficher des aperçus d’images. Les
vérifications à la sélection donnent un retour immédiat, et le récepteur applique indépendamment
ses propres limites de fichiers et de corps de requête.
Nous utilisons XMLHttpRequest.upload
pour suivre la progression du corps de la requête. Chaque barre correspond à un fichier et inclut la
surcharge multipart. Une barre à 100 % signifie que le corps a été envoyé ; la ligne attend encore
la réponse. Seule une réponse HTTP 200 contenant le nombre d’octets et le SHA-256 attendus devient
un résultat accepté.
La file d’attente est verrouillée pendant tout le lot : désactivez le sélecteur et le bouton d’envoi, ignorez les dépôts et les envois supplémentaires, et poursuivez après tout échec individuel. Chaque requête dispose d’un délai de 30 secondes, attente de la réponse comprise. Les résultats restent visibles jusqu’à la sélection suivante ; le renvoi de la même file d’attente déjà terminée est désactivé.
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const zone = document.getElementById('drop-zone')
const button = document.getElementById('upload-button')
const status = document.getElementById('batch-status')
const list = document.getElementById('queue')
const maxSize = 5 * 1024 * 1024
const number = new Intl.NumberFormat('en-US')
let queue = []
let busy = false
let started = false
function updateControls() {
input.disabled = busy
button.disabled = busy || started || queue.length === 0
}
function choose(files) {
if (busy || files.length === 0) return
queue = []
started = false
list.replaceChildren()
const oversized = files.find((file) => file.size > maxSize)
if (files.length > 10) {
status.textContent = 'Choose at most 10 files.'
} else if (oversized) {
status.textContent = `${oversized.name}: exceeds the 5 MiB limit. Select the batch again.`
} else {
queue = files.map((file, index) => {
const row = document.createElement('li')
const name = document.createElement('strong')
name.textContent = `${file.name} (${number.format(file.size)} bytes)`
const label = document.createElement('label')
label.htmlFor = `progress-${index}`
label.textContent = `Request body sent: ${file.name}`
const progress = document.createElement('progress')
progress.id = label.htmlFor
progress.setAttribute('aria-label', label.textContent)
progress.max = 100
progress.value = 0
const message = document.createElement('p')
message.textContent = 'Queued.'
row.append(name, label, progress, message)
list.append(row)
return { file, progress, message }
})
status.textContent = `${queue.length} files ready. Choose Upload batch to start.`
}
updateControls()
}
input.addEventListener('change', () => {
choose(Array.from(input.files))
// Keep our File objects; allow the same selection to fire change next time.
input.value = ''
})
zone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) zone.classList.add('drag-over')
})
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'))
zone.addEventListener('drop', (event) => {
event.preventDefault()
zone.classList.remove('drag-over')
choose(Array.from(event.dataTransfer.files))
})
function send(item, sha256) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) item.progress.value = (event.loaded / event.total) * 100
else item.progress.removeAttribute('value')
})
xhr.upload.addEventListener('load', () => {
item.progress.value = 100
item.message.textContent = 'Body sent. Waiting for server acknowledgment…'
})
xhr.addEventListener('load', () => {
const receipt = xhr.response
if (xhr.status === 200 && receipt?.bytes === item.file.size && receipt?.sha256 === sha256) {
item.message.textContent = `Accepted. ${number.format(receipt.bytes)} bytes; SHA-256 ${receipt.sha256}.`
resolve(true)
} else {
item.message.textContent = xhr.status === 200
? 'Failed: invalid server receipt.'
: `Failed: server returned HTTP ${xhr.status}.`
resolve(false)
}
})
xhr.addEventListener('error', () => {
item.message.textContent = 'Failed: network error. Server acceptance is unknown.'
resolve(false)
})
xhr.addEventListener('timeout', () => {
item.message.textContent = 'Failed: request timed out. Server acceptance is unknown.'
resolve(false)
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 30000
const body = new FormData()
body.append('file', item.file)
body.append('sha256', sha256)
xhr.send(body)
})
}
async function uploadBatch() {
if (busy || started || queue.length === 0) return
busy = true
started = true
updateControls()
let accepted = 0
let failed = 0
for (const item of queue) {
status.textContent = `Uploading ${item.file.name}…`
item.message.textContent = 'Preparing checksum…'
try {
const digest = await crypto.subtle.digest('SHA-256', await item.file.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
item.message.textContent = 'Sending request body…'
if (await send(item, sha256)) accepted += 1
else failed += 1
} catch {
item.message.textContent = 'Failed: could not prepare or send this file.'
failed += 1
}
}
status.textContent = `Batch finished: ${accepted} accepted, ${failed} failed. Select files for a new batch.`
busy = false
updateControls()
}
form.addEventListener('submit', (event) => {
event.preventDefault()
void uploadBatch()
})
La somme de contrôle utilise crypto.subtle.digest()
et ne lit qu’un seul fichier en mémoire à la fois. Servez la page depuis l’URL de bouclage affichée
afin que Web Crypto soit disponible. Ne définissez pas Content-Type : le navigateur fournit la
délimitation multipart
pour FormData.
Ajouter un récepteur qui accuse réception de chaque fichier
Enregistrez sous server.mts. Il ne sert que les trois ressources du navigateur et accepte un champ file et un
champ sha256 par requête. Il lit le corps complet avant l’analyse, en autorisant 16 KiB de surcharge
multipart au-delà de la limite de 5 MiB par fichier. Une requête qui dépasse cette limite de corps
reçoit une réponse HTTP 413.
Dans une réponse réussie, la somme de contrôle provient des octets effectivement lus par le récepteur.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
const maxSize = 5 * 1024 * 1024
const maxBody = maxSize + 16 * 1024
let origin = ''
const assets = new Map<string, { body: Buffer; type: string }>()
function reply(res: ServerResponse, code: number, data: object): void {
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(data))
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host) {
reply(res, 403, { error: 'Use the printed loopback URL.' })
return
}
if (req.method === 'GET' && req.url === '/favicon.ico') {
res.writeHead(204).end()
return
}
const asset = assets.get(req.url ?? '')
if (req.method === 'GET' && asset) {
res.writeHead(200, { 'Content-Type': asset.type })
res.end(asset.body)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
reply(res, 404, { error: 'Not found.' })
return
}
if (req.headers.origin !== origin) {
reply(res, 403, { error: 'Use the uploader on this server.' })
return
}
const chunks: Buffer[] = []
let bytes = 0
// Keep the socket available for an error response when leaving iteration early.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > maxBody) {
reply(res, 413, { error: 'Request body exceeds the limit.' })
req.resume()
return
}
chunks.push(chunk)
}
let data: FormData
try {
data = await new Request(origin, {
method: 'POST',
headers: { 'Content-Type': req.headers['content-type'] ?? '' },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(res, 400, { error: 'Invalid multipart body.' })
return
}
const file = data.get('file')
const expected = data.get('sha256')
if ([...data.keys()].length !== 2 || !(file instanceof File) || typeof expected !== 'string') {
reply(res, 400, { error: 'Expected one file and one checksum.' })
return
}
if (file.size > maxSize) {
reply(res, 413, { error: 'File exceeds the 5 MiB limit.' })
return
}
const sha256 = createHash('sha256').update(new Uint8Array(await file.arrayBuffer())).digest('hex')
if (sha256 !== expected) {
reply(res, 422, { error: 'Checksum does not match.' })
return
}
reply(res, 200, { bytes: file.size, sha256 })
}
async function main(): Promise<void> {
const portText = process.env.PORT ?? '0'
const port = Number(portText)
if (!/^\d+$/.test(portText) || !Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer from 0 to 65535.')
}
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css'],
['/upload.js', 'upload.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
const server = createServer((req, res) => {
void handle(req, res).catch(() => {
if (!res.destroyed && !res.writableEnded) reply(res, 500, { error: 'Unable to process upload.' })
})
})
server.requestTimeout = 35000
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 TCP address')
origin = `http://127.0.0.1:${address.port}`
console.log(`Open ${origin}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : ''
console.error(code === 'EADDRINUSE'
? 'Port is already in use. Choose another PORT or leave it unset.'
: 'Could not start uploader. Check PORT and the three browser files.')
process.exitCode = 1
})
Le serveur se lie à l’interface de bouclage et choisit un port disponible lorsque PORT n’est pas défini ou
vaut zéro. Vous pouvez définir PORT dans l’environnement du serveur si vous avez besoin d’un port fixe.
Des ressources manquantes, une valeur de PORT invalide
ou un port occupé interrompent le démarrage avec un code de sortie non nul.
Lancer le lot et lire ses résultats
Depuis le répertoire parent dans lequel vous avez créé html5-upload-demo, collez :
(cd html5-upload-demo && node server.mts)
Ouvrez l’URL affichée, par exemple http://127.0.0.1:49152. Laissez ce terminal ouvert ; Ctrl+C arrête le
serveur et vous ramène au répertoire parent. Redémarrez après avoir modifié une ressource, car le
serveur charge ces fichiers au démarrage.
Avec Tab, accédez à Choose files, ouvrez le sélecteur et sélectionnez deux fichiers de tailles différentes. Avec Tab, accédez à Upload batch et activez-le. Chaque ligne devrait afficher Accepted., son nombre d’octets et une somme de contrôle SHA-256. Le statut final indique deux fichiers acceptés et zéro fichier en échec. Rien n’a été enregistré sur le serveur.
Essayez de déposer des fichiers dans la zone encadrée pour une seconde sélection. Pendant l’exécution d’un lot, son champ et son bouton sont désactivés, et le dépôt d’autres fichiers laisse les lignes actuelles intactes. Sur une connexion locale rapide, une barre peut sauter directement à 100 % ; les événements de progression ne sont ni une animation fluide ni une mesure du traitement côté serveur.
Une requête rejetée laisse sa ligne en échec en place, et le fichier suivant de la file est tout de même envoyé. HTTP 400 indique des données multipart mal formées ou des champs incorrects, 403 une non-concordance d’origine ou d’hôte, 413 une limite de taille dépassée et 422 une non-concordance de somme de contrôle. Un accusé de réception HTTP 200 mal formé ou incohérent entraîne aussi un échec. Si le récepteur devient injoignable ou dépasse le délai, l’acceptation reste inconnue : une réponse manquante ne permet pas de savoir si un serveur a terminé son travail. Pour réessayer, sélectionnez les fichiers en échec comme nouveau lot ; chaque nouvelle tentative renvoie l’intégralité de leur contenu. Recharger la page efface la file d’attente.
Décider de ce qui a sa place dans un téléverseur public
Ce récepteur local vérifie les tailles et la concordance des octets, accepte des contenus arbitraires et les supprime. Une somme de contrôle ne prouve pas qu’un fichier est sûr ou entièrement décodable. Les vérifications d’hôte et d’origine ne constituent pas une authentification des utilisateurs. Avant de conserver des fichiers téléversés dans un service public, ajoutez une autorisation, une validation du contenu, une politique de stockage et une protection CSRF lorsque des sessions basées sur des cookies l’exigent ; les recommandations d’OWASP sur le téléversement expliquent ces contrôles. Stockez les fichiers téléversés en dehors de la racine web et gérez la perte d’un accusé de réception sans créer d’enregistrements en double, par exemple avec une clé d’idempotence imposée par le serveur.
Conservez le champ de fichier visible, les barres de progression étiquetées, les contours de focus et la région live du lot lorsque vous adaptez l’interface. Les échecs sont accompagnés de texte et restent lisibles sans dépendre de la couleur. Un pourcentage unique pour l’ensemble du lot nécessiterait une règle de pondération définie ; faire la moyenne des pourcentages donne à un tout petit fichier autant de poids qu’à un gros.
Pour une interface plus riche, le plugin XHRUpload d’Uppy prend en charge les
téléversements multipart vers un récepteur compatible. Pour la reprise des téléversements, son
plugin Tus nécessite un serveur compatible avec tus. Aucune de ces options
ne permet à ce récepteur /upload de reprendre des téléversements.
