Créer un outil d’envoi de fichiers maison en JavaScript et HTML
Conservez le champ de fichier natif et construisez votre interface personnalisée autour de lui. Ce tutoriel vous propose un outil d’envoi JavaScript sans framework, avec sélection au clavier, glisser-déposer, progression et relances, ainsi qu’un serveur local qui compare les octets reçus à la somme de contrôle SHA-256 du fichier sélectionné.

Configurer un projet d’envoi local
Il vous faut Node.js 24 ou une version ultérieure, un navigateur et un terminal. L’exemple utilise les API Request et File intégrées à Node, il n’y a donc aucun paquet à installer. Ce tutoriel a été testé sous Linux avec Node.js 24.2.0, 26.5.0 et 26.8.1, ainsi qu’avec Chromium 145 et 152.
Nous allons envoyer un seul fichier JPEG, PNG ou PDF non vide à la fois, jusqu’à 10 MiB. Chaque tentative envoie le fichier entier sous forme de données de formulaire multipart. Le code du navigateur et du serveur reste ainsi assez compact pour fonctionner ensemble sans implémenter de protocole d’assemblage de morceaux.
Exécutez ceci dans un shell POSIX, comme Bash, depuis un répertoire où vous conservez vos expériences :
mkdir custom-uploader &&
cd custom-uploader &&
touch index.html styles.css script.js server.ts
La commande refuse un répertoire existant. Si une étape échoue, arrêtez-vous et corrigez le problème avant de continuer ; choisissez un autre nom de nouveau répertoire si nécessaire. Collez les quatre exemples suivants dans les fichiers vides qui viennent d’être créés. Les envois ne créeront ni n’écraseront aucun fichier : le récepteur calcule le hachage des octets en mémoire et les supprime après avoir répondu. Une nouvelle exécution vérifie à nouveau les octets. Gardez cette démo sur votre propre machine.
Mettre en place la structure HTML
Enregistrez ceci sous index.html. Le champ de fichier étiqueté reste visible et accessible avec Tab. La
zone de dépôt est un autre moyen de sélectionner un fichier, et le bouton Upload distinct lance la
requête. Les messages d’état restent affichés dans une région dynamique annoncée de façon non
intrusive.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Custom file uploader</title>
<link rel="stylesheet" href="styles.css" />
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Upload a file</h1>
<form id="upload-form" aria-label="File upload">
<section id="drop-zone" aria-label="Drop a file">
<label for="file-input">Choose a file</label>
<input id="file-input" type="file" accept="image/jpeg,image/png,application/pdf"
aria-describedby="file-help selection" />
<p id="file-help">Choose or drop one JPEG, PNG, or PDF, up to 10 MiB.</p>
</section>
<p id="selection">No file selected.</p>
<button id="upload" type="submit" disabled>Upload</button>
<button id="retry" type="button" disabled>Retry</button>
</form>
<p><label for="progress">Request body sent</label></p>
<progress id="progress" max="100" value="0"></progress>
<p id="status" role="status" aria-live="polite" aria-atomic="true">Choose a file to begin.</p>
<pre id="receipt" aria-label="Server receipt"></pre>
<noscript>This uploader needs JavaScript enabled.</noscript>
</main>
</body>
</html>
Styliser l’outil d’envoi de fichiers avec CSS
Enregistrez ceci sous styles.css. Stylisez le bouton de sélection du champ et son contour de focus
sans masquer le champ. Utiliser display: none le retirerait de la navigation au clavier et des
technologies d’assistance ; consultez l’exemple de champ de fichier de MDN.
body {
font: 1rem/1.5 system-ui, sans-serif;
margin: 2rem auto;
padding: 0 1rem;
max-width: 40rem;
color: #172b4d;
background: #fff;
}
#drop-zone {
border: 2px dashed #52647c;
border-radius: 0.5rem;
padding: 1.5rem;
}
#drop-zone.dragover { background: #e8f1ff; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button {
font: inherit;
padding: 0.5rem 1rem;
margin: 0.5rem 0;
cursor: pointer;
}
:focus-visible { outline: 3px solid #075ac7; outline-offset: 3px; }
button:disabled { cursor: default; }
progress { width: 100%; }
#status { min-height: 3rem; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
Implémenter la sélection et l’envoi de fichiers en JavaScript
Enregistrez ceci sous script.js. XMLHttpRequest expose des
événements de progression d’envoi.
Ils mesurent la transmission du corps de la requête, surcharge multipart comprise. Atteindre 100 %
ne signifie pas que le serveur a accepté le fichier. Seule une réponse HTTP 200 avec une taille et
une somme de contrôle concordantes produit le message « Accepted ».
Le comportement pendant l’envoi est explicite : désactiver le sélecteur et les boutons, ignorer les dépôts et les soumissions supplémentaires, et conserver le fichier courant jusqu’à ce que la requête soit terminée. Une erreur réseau, un délai dépassé ou une erreur serveur active Retry, avec au plus trois tentatives par sélection. Les relances renvoient le fichier entier ; aucune relance n’est lancée automatiquement. Une requête rejetée ou un reçu invalide exige une nouvelle sélection.
const form = document.getElementById('upload-form')
const input = document.getElementById('file-input')
const zone = document.getElementById('drop-zone')
const selection = document.getElementById('selection')
const upload = document.getElementById('upload')
const retry = document.getElementById('retry')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
const receipt = document.getElementById('receipt')
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const maxSize = 10 * 1024 * 1024
let selected = null
let busy = false
let attempts = 0
let retryable = false
function updateControls() {
input.disabled = busy
upload.disabled = busy || !selected || attempts > 0
retry.disabled = busy || !selected || !retryable || attempts >= 3
}
function choose(files) {
if (busy) return
selected = null
attempts = 0
retryable = false
progress.value = 0
receipt.textContent = ''
selection.textContent = 'No file selected.'
const file = files[0]
if (files.length !== 1) {
status.textContent = 'Choose exactly one file.'
} else if (!allowedTypes.includes(file.type)) {
status.textContent = 'Choose a JPEG, PNG, or PDF with a recognized MIME type.'
} else if (file.size === 0 || file.size > maxSize) {
status.textContent = 'The file must be nonempty and no larger than 10 MiB.'
} else {
selected = file
selection.textContent = file.name
status.textContent = 'Ready to upload.'
}
updateControls()
}
input.addEventListener('change', () => {
choose(Array.from(input.files))
// Retain the File ourselves so selecting the same file can fire change again.
input.value = ''
})
zone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) zone.classList.add('dragover')
})
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'))
zone.addEventListener('drop', (event) => {
event.preventDefault()
zone.classList.remove('dragover')
choose(Array.from(event.dataTransfer.files))
})
function send(file, sha256) {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) progress.value = (event.loaded / event.total) * 100
})
xhr.upload.addEventListener('load', () => {
progress.value = 100
status.textContent = 'Body sent. Waiting for server acceptance…'
})
xhr.addEventListener('load', () => {
const result = xhr.response
if (xhr.status === 200 && result?.bytes === file.size && result?.sha256 === sha256) {
resolve({ ok: true, result })
} else {
resolve({
ok: false,
retryable: xhr.status >= 500,
message: xhr.status === 200
? 'Invalid server receipt.'
: `Server rejected the upload (HTTP ${xhr.status}).`,
})
}
})
xhr.addEventListener('error', () => {
resolve({ ok: false, retryable: true, message: 'Network error.' })
})
xhr.addEventListener('timeout', () => {
resolve({ ok: false, retryable: true, message: 'Request timed out.' })
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 30000
const body = new FormData()
body.append('file', file)
body.append('sha256', sha256)
xhr.send(body)
})
}
async function startUpload() {
if (busy || !selected || attempts >= 3 || (attempts > 0 && !retryable)) return
busy = true
retryable = false
attempts += 1
updateControls()
progress.value = 0
receipt.textContent = ''
status.textContent = `Preparing attempt ${attempts} of 3…`
try {
const digest = await crypto.subtle.digest('SHA-256', await selected.arrayBuffer())
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
status.textContent = `Uploading ${selected.name} (attempt ${attempts} of 3)…`
const outcome = await send(selected, sha256)
if (outcome.ok) {
status.textContent = `Accepted: ${selected.name}. Size and SHA-256 match. No file was saved.`
receipt.textContent = JSON.stringify(outcome.result, null, 2)
} else {
retryable = outcome.retryable
const next = retryable && attempts < 3
? 'Choose Retry to send it again.'
: 'Select a file to start again.'
status.textContent = `${outcome.message} ${next}`
}
} catch {
status.textContent = 'Could not prepare or send this file. Select it again.'
} finally {
busy = false
updateControls()
}
}
form.addEventListener('submit', (event) => {
event.preventDefault()
void startUpload()
})
retry.addEventListener('click', () => void startUpload())
Le navigateur calcule la somme de contrôle avec
crypto.subtle.digest().
Cela charge le petit fichier en mémoire. Servez la page à l’URL de loopback affichée par le serveur
ci-dessous pour que Web Crypto soit disponible ; n’ouvrez pas index.html directement. Ne définissez pas
Content-Type :
FormData fournit son propre délimiteur multipart.
Ajouter le récepteur local
Enregistrez ceci sous server.ts. Il sert uniquement nos trois fichiers de navigateur et accepte
exactement deux champs multipart : file et sha256. La limite du corps accorde 16 KiB aux
en-têtes multipart en plus du fichier de 10 MiB. Le récepteur calcule sa propre somme de contrôle
avant de répondre.
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 = 10 * 1024 * 1024
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const assets = new Map<string, { body: Buffer; type: string }>()
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css'],
['/script.js', 'script.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
let origin = ''
function reply(res: ServerResponse, status: number, data: object): void {
res.writeHead(status, { '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
}
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 size = 0
// Keep the socket open long enough to return 413 when stopping iteration early.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
size += chunk.length
if (size > maxSize + 16 * 1024) {
reply(res, 413, { error: 'Request body is too large.' })
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 === 0 || file.size > maxSize) {
reply(res, 413, { error: 'File must be nonempty and no larger than 10 MiB.' })
return
}
if (!allowedTypes.includes(file.type)) {
reply(res, 415, { error: 'Unsupported declared MIME type.' })
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, { name: file.name, bytes: file.size, sha256 })
}
const server = createServer((req, res) => {
void handle(req, res).catch(() => {
reply(res, 500, { error: 'Unable to process the upload.' })
})
})
server.requestTimeout = 30000
server.listen(0, '127.0.0.1', () => {
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}`)
})
L’attribut accept est une indication pour le sélecteur, pas une validation.
Le navigateur et ce récepteur vérifient tous deux le type MIME déclaré ; aucun des deux ne
prouve que les octets constituent une image ou un PDF valide ou sûr. La somme de contrôle établit la
concordance des octets, pas leur fiabilité. Cette démo n’a ni comptes utilisateur, ni stockage
persistant, ni analyse de sécurité du contenu, ni décodage de format. Elle se lie à l’adresse de
loopback et vérifie l’origine du navigateur, mais ces vérifications ne constituent pas une
authentification des utilisateurs. Un service public a besoin de sa propre autorisation, d’une
protection CSRF pour les sessions basées sur des cookies, d’une validation du contenu et d’une
politique de stockage.
Exécuter l’exemple et vérifier le résultat
Depuis custom-uploader, exécutez :
node server.ts
Ouvrez l’URL affichée, par exemple http://127.0.0.1:49152. Le serveur choisit un port disponible, qui peut
changer au redémarrage. Il lit les fichiers du navigateur au démarrage : redémarrez-le donc après
les avoir modifiés. Arrêtez-le avec Ctrl+C une fois terminé.
Appuyez sur Tab pour placer le focus sur « Choose a file », ouvrez le sélecteur au clavier et sélectionnez un petit PNG. Allez avec Tab jusqu’à Upload et activez-le. L’état final doit indiquer « Accepted », et le reçu doit afficher le nom du fichier, sa taille en octets et une valeur SHA-256 de 64 caractères. Le navigateur a comparé cette valeur à sa propre somme de contrôle ; rien n’a été enregistré sur le serveur.
Essayez aussi ces cas d’échec et d’interaction :
- Déposez un fichier dans la zone encadrée, puis envoyez-le. Le dépôt de deux fichiers doit demander exactement un fichier.
- Essayez un fichier vide, un fichier texte et un fichier de plus de 10 MiB. Chacun doit produire une explication persistante sans lancer de requête. Les fichiers dont le type MIME est vide ou non reconnu sont aussi rejetés, même si leur extension semble acceptable.
- Utilisez les outils Réseau de votre navigateur pour ralentir un envoi. Pendant qu’il est en cours, le sélecteur et les boutons doivent être désactivés, et les dépôts doivent laisser la sélection active intacte. La barre peut atteindre 100 % alors que l’état indique encore l’attente de l’acceptation par le serveur.
- La page étant déjà chargée, passez le navigateur hors ligne et lancez l’envoi. Après « Network error », repassez en ligne et choisissez Retry. Le même fichier sélectionné doit alors être envoyé avec succès. Restez hors ligne pendant les trois tentatives pour voir la limite de relances, puis sélectionnez à nouveau le même fichier pour démarrer une nouvelle série de tentatives.
Si une requête renvoie HTTP 400, vérifiez les noms des champs multipart ; 403 signifie que l’origine ou l’hôte ne correspondait pas à l’URL affichée. HTTP 413 signale la limite de taille, 415 un type MIME déclaré non pris en charge, et 422 une somme de contrôle non concordante. Atteindre 100 % puis recevoir l’une de ces erreurs correspond à un envoi échoué. Le délai client de 30 secondes couvre la requête et la réponse ; augmentez-le délibérément si vous expérimentez avec des connexions plus lentes.
Quand vous avez besoin d’envois avec reprise
Ici, relancer signifie renvoyer le fichier entier tant que la page est ouverte. Un rechargement fait oublier la sélection et le nombre de tentatives. Si vous ajoutez un stockage persistant, gérez la perte d’une réponse de succès : une relance ne doit pas créer d’enregistrements en double. Pour ce flux de travail, utilisez une clé d’idempotence imposée par le serveur.
Pour les gros fichiers qui doivent reprendre à partir d’un décalage d’octets déjà accepté, utilisez un client et un serveur tus. Cela nécessite un protocole avec reprise des deux côtés ; découper un fichier en morceaux uniquement dans le JavaScript du navigateur ne le fournit pas.
