Optimiser le téléversement de fichiers par blocs et en parallèle
Pour téléverser des blocs en parallèle, attribuez à chaque bloc une position stable et ne publiez le fichier qu’une fois que le récepteur a vérifié le résultat complet. Vous allez construire ici les deux côtés : un navigateur envoie trois blocs à la fois, et un serveur Node.js local rend le fichier assemblé disponible au téléchargement après avoir vérifié son empreinte SHA-256.
Choisir un téléversement réduit et reproductible
Cet exemple s’adresse aux développeurs qui découvrent comment s’articulent les téléversements parallèles par blocs. Utilisez Node.js 24.15.0 ou une version maintenue ultérieure, ainsi qu’un navigateur Chromium à jour ; l’exemple a été testé sous Linux avec Node.js 24.15.0 et Chromium 145. Node.js 24 est une version LTS. Aucun paquet n’est à installer.
La page accepte un fichier JPEG, PNG ou PDF non vide d’au plus 8 MiB. Cette faible limite est
délibérée : le récepteur stocke les fichiers en mémoire, et le navigateur calcule l’empreinte de
fichiers entiers avec
crypto.subtle.digest(),
qui n’accepte pas d’entrée en flux. Il s’agit d’une leçon de protocole en local, pas d’un service de
stockage de gros fichiers. Redémarrer le serveur fait perdre tous les fichiers. Recharger la page fait
perdre l’état de téléversement du client.
Attribuer une position à chaque bloc
Utilisez des blocs de 256 KiB, numérotés à partir de zéro. Un fichier de 524 295 octets comporte
trois blocs : deux de 262 144 octets et un de sept octets.
Blob.slice(start, end)
exclut la position de fin, si bien que les tranches adjacentes ne se chevauchent pas et ne laissent
aucun trou.
Le protocole comporte cinq opérations :
POST /uploadsréserve une taille de fichier et une empreinte SHA-256, puis renvoie un ID généré par le serveur.PUT /uploads/:id/:indexécrit le bloc brut à sa position numérotée. Une nouvelle tentative identique réussit ; un corps différent à une position déjà acceptée échoue avec HTTP 409.POST /uploads/:id/completevérifie que chaque position est présente et que l’empreinte du fichier entier correspond. Répéter cette opération renvoie la même empreinte.GET /uploads/:id/filene renvoie les octets qu’après la finalisation. Le navigateur vérifie aussi ces octets.DELETE /uploads/:idsupprime le téléversement, y compris un fichier finalisé.
L’ordre d’arrivée ne détermine pas l’ordre dans le fichier. Un accusé de réception perdu peut entraîner une requête en double, c’est pourquoi les écritures de blocs et la finalisation doivent être idempotentes. La création n’est pas relancée automatiquement : une réponse de création perdue laisse un ID inconnu, que le serveur finit par faire expirer.
Créer la page
Créez un nouveau répertoire vide et enregistrez-y côte à côte les trois fichiers suivants. Enregistrez
ce premier fichier sous index.html :
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Parallel chunk upload</title>
</head>
<body>
<main>
<h1>Parallel chunk upload</h1>
<label for="file">JPEG, PNG, or PDF, up to 8 MiB</label>
<input id="file" type="file" accept="image/jpeg,image/png,application/pdf" />
<button id="upload" type="button">Upload</button>
<button id="cancel" type="button" disabled>Cancel</button>
<p id="status" role="status">Choose a file.</p>
<a id="download" hidden download="upload.bin">Download verified file</a>
</main>
<script type="module" src="/client.js"></script>
</body>
</html>
Recevoir et publier les blocs
Enregistrez ceci sous server.mts. L’extension .mts en fait un module ES, même au sein d’un projet CommonJS.
Le serveur utilise
stripTypeScriptTypes()
de Node pour servir le fichier suivant en tant que JavaScript. Dans Node.js 24.15.0, cette API émet un
avertissement expérimental.
Quatre téléversements peuvent exister simultanément, téléversements finalisés compris. Chacun expire 60 secondes après sa création, même si des requêtes arrivent encore. Six requêtes peuvent être actives, chacune avec un délai de 10 secondes. Les corps entrants sont lus avant la recherche des données de session, de sorte qu’un corps en attente ne peut ni maintenir en vie une session supprimée ni y écrire plus tard.
import { createHash, randomUUID } from 'node:crypto'
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 = 256 * 1024
const MAX_FILE_SIZE = 8 * 1024 * 1024
const MAX_UPLOADS = 4
const MAX_REQUESTS = 6
const TTL_MS = 60_000
type Upload = {
bytes: Buffer
digest: string
seen: Set<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
// Leave the socket open long enough to send a useful error response.
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 html = await readFile(new URL('./index.html', import.meta.url))
const client = stripTypeScriptTypes(
await readFile(new URL('./client.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 body = await readBody(req, method === 'PUT' ? CHUNK_SIZE : 0)
expire()
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
if (method === 'GET' && (path === '/' || path === '/client.js')) {
res.setHeader('Content-Type', path === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(path === '/' ? html : client)
return
}
if (method === 'POST' && path === '/uploads') {
const length = req.headers['upload-length']
const digest = req.headers['upload-sha256']
const size = Number(length)
if (typeof length !== 'string' || !/^[1-9]\d*$/.test(length) ||
!Number.isSafeInteger(size) || size > MAX_FILE_SIZE) {
throw new HttpError(400, 'File must be between 1 byte and 8 MiB')
}
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) {
throw new HttpError(400, 'Expected a SHA-256 digest')
}
if (uploads.size >= MAX_UPLOADS) throw new HttpError(503, 'Upload capacity reached')
const id = randomUUID()
uploads.set(id, {
bytes: Buffer.alloc(size), digest, seen: new Set(), complete: false,
expires: Date.now() + TTL_MS,
})
res.writeHead(201).end(id)
return
}
const match = /^\/uploads\/([a-f0-9-]{36})(?:\/(\d+|complete|file))?$/.exec(path)
if (!match) throw new HttpError(404, 'Unknown route')
const [, id, operation] = match
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')
const count = Math.ceil(upload.bytes.length / CHUNK_SIZE)
if (method === 'PUT' && operation !== undefined && /^\d+$/.test(operation)) {
const index = Number(operation)
if (!Number.isSafeInteger(index) || index >= count) {
throw new HttpError(400, 'Invalid chunk index')
}
const start = index * CHUNK_SIZE
const target = upload.bytes.subarray(start, Math.min(start + CHUNK_SIZE, upload.bytes.length))
if (body.length !== target.length) throw new HttpError(400, 'Wrong chunk length')
if (upload.seen.has(index)) {
if (!body.equals(target)) throw new HttpError(409, 'Conflicting chunk')
} else {
body.copy(target)
upload.seen.add(index)
}
res.writeHead(204).end()
return
}
if (method === 'POST' && operation === 'complete') {
if (upload.seen.size !== count) throw new HttpError(409, 'Missing chunks')
if (createHash('sha256').update(upload.bytes).digest('hex') !== upload.digest) {
throw new HttpError(422, 'Digest mismatch')
}
upload.complete = true
res.end(upload.digest)
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 >= MAX_REQUESTS) {
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()
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 session totalisent au plus 32 MiB. Par ailleurs, une requête admise peut conserver un tampon entrant de 256 KiB ou référencer un fichier sortant de 8 MiB jusqu’à la fermeture de sa réponse. La suppression et l’expiration ne libèrent pas l’emplacement de cette requête plus tôt. Ce sont des limites de tampons applicatifs, pas une borne sur l’utilisation totale de la mémoire par Node ni sur le moment du ramasse-miettes. Les délais d’en-têtes et la limite de connexions bornent aussi les connexions en attente de ce serveur local ; consultez la documentation HTTP de Node.js.
Envoyer au plus trois blocs à la fois
Enregistrez ceci sous client.ts. Chaque lot attend que toutes ses requêtes soient terminées avant
qu’un autre lot ne démarre. Un bloc lent retarde donc son lot, mais la limite est facile à inspecter
et les nouvelles tentatives ne peuvent pas multiplier le nombre de requêtes de blocs actives.
const CHUNK_SIZE = 256 * 1024
const MAX_FILE_SIZE = 8 * 1024 * 1024
const CONCURRENCY = 3
const input = document.getElementById('file')
const uploadButton = document.getElementById('upload')
const cancelButton = document.getElementById('cancel')
const status = document.getElementById('status')
const download = document.getElementById('download')
if (!(input instanceof HTMLInputElement) || !(uploadButton instanceof HTMLButtonElement) ||
!(cancelButton instanceof HTMLButtonElement) || !(status instanceof HTMLParagraphElement) ||
!(download instanceof HTMLAnchorElement)) throw new Error('Missing upload controls')
class HttpError extends Error {
status: number
constructor(status: number) { super(`HTTP ${status}`); this.status = status }
}
async function validateFile(file: File): Promise<void> {
if (file.size === 0 || file.size > MAX_FILE_SIZE) throw new Error('Choose a file between 1 byte and 8 MiB.')
const signatures: Record<string, number[]> = {
'image/jpeg': [0xff, 0xd8, 0xff],
'image/png': [0x89, 0x50, 0x4e, 0x47],
'application/pdf': [0x25, 0x50, 0x44, 0x46],
}
const expected = signatures[file.type]
if (!expected) throw new Error('Choose a JPEG, PNG, or PDF.')
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer())
if (!expected.every((byte, index) => header[index] === byte)) throw new Error('Invalid file signature.')
}
async function sha256(blob: Blob): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer())
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function waitForRetry(ms: number, signal: AbortSignal): Promise<void> {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const onAbort = () => { clearTimeout(timer); reject(signal.reason) }
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.addEventListener('abort', onAbort, { once: true })
})
}
async function request(
path: string, options: RequestInit, signal: AbortSignal, retry = true,
): Promise<Blob> {
for (let attempt = 0; ; attempt++) {
signal.throwIfAborted()
try {
const response = await fetch(path, {
...options,
signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
headers: { ...options.headers, 'X-Upload-Demo': '1' },
})
if (!response.ok) throw new HttpError(response.status)
return await response.blob()
} catch (error) {
signal.throwIfAborted()
if (!retry || attempt === 2 ||
(error instanceof HttpError && ![408, 429, 500, 502, 503, 504].includes(error.status))) {
throw error
}
await waitForRetry(250 * 2 ** attempt, signal)
}
}
}
let running: AbortController | null = null
cancelButton.addEventListener('click', () => running?.abort())
input.addEventListener('change', () => {
if (running) return
download.hidden = true
status.textContent = 'Ready to upload.'
})
uploadButton.addEventListener('click', async () => {
if (running) return
const file = input.files?.[0]
if (!file) { status.textContent = 'Choose a file.'; return }
const controller = new AbortController()
const signal = controller.signal
running = controller
input.disabled = uploadButton.disabled = true
cancelButton.disabled = false
download.hidden = true
let id: string | undefined
let verified = false
try {
status.textContent = 'Checking file…'
await validateFile(file)
const digest = await sha256(file)
signal.throwIfAborted()
// Finish creation so cancellation can learn the ID and delete it.
id = await (await request('/uploads', {
method: 'POST', headers: { 'Upload-Length': String(file.size), 'Upload-SHA256': digest },
}, new AbortController().signal, false)).text()
signal.throwIfAborted()
const count = Math.ceil(file.size / CHUNK_SIZE)
let acknowledged = 0
for (let first = 0; first < count; first += CONCURRENCY) {
signal.throwIfAborted()
const batch = []
for (let index = first; index < Math.min(first + CONCURRENCY, count); index++) {
const chunk = file.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)
batch.push(request(`/uploads/${id}/${index}`, { method: 'PUT', body: chunk }, signal).then(() => {
signal.throwIfAborted()
acknowledged += chunk.size
status.textContent = `${Math.round(100 * acknowledged / file.size)}% of bytes acknowledged.`
}))
}
const results = await Promise.allSettled(batch)
const failure = results.find((result) => result.status === 'rejected')
if (failure) throw failure.reason
}
status.textContent = 'All chunks acknowledged. Verifying…'
const confirmation = await request(`/uploads/${id}/complete`, { method: 'POST' }, signal)
if (await confirmation.text() !== digest) throw new Error('Unexpected confirmation.')
const result = await request(`/uploads/${id}/file`, {}, signal)
if (result.size !== file.size || await sha256(result) !== digest) throw new Error('Downloaded bytes differ.')
signal.throwIfAborted()
verified = true
download.href = `/uploads/${id}/file`
download.hidden = false
status.textContent = 'Upload verified. Download is available until the upload expires.'
} catch (error) {
status.textContent = signal.aborted ? 'Upload canceled.' :
`Upload failed: ${error instanceof Error ? error.message : 'Please try again.'}`
} finally {
if (id && !verified) {
try {
await request(`/uploads/${id}`, { method: 'DELETE' }, new AbortController().signal)
} catch {
status.textContent += ' Cleanup could not be confirmed; the server will expire the upload.'
}
}
running = null
input.disabled = uploadButton.disabled = false
cancelButton.disabled = true
}
})
Le pourcentage compte les octets confirmés, y compris un dernier bloc plus court. Il ne mesure pas les octets en cours de transfert. Même 100 % ne signifie pas un succès : la finalisation et la vérification du téléchargement qui suit doivent réussir avant que le lien n’apparaisse. Davantage de requêtes parallèles peuvent améliorer le débit lorsqu’une requête laisse de la capacité inutilisée, mais elles ajoutent aussi une surcharge. Mesurez avec votre propre récepteur et votre propre réseau ; cette démo ne revendique aucune vitesse.
Lancer et interrompre un téléversement
Depuis le répertoire contenant les trois fichiers enregistrés, exécutez :
node server.mts
Ouvrez l’URL http://127.0.0.1:PORT affichée. N’ouvrez pas index.html directement. Sélectionnez un fichier
et cliquez sur Upload. Après l’affichage de
All chunks acknowledged. Verifying…, le lien
Download verified file apparaît. La page a récupéré et
vérifié le fichier du serveur ; cliquer sur le lien lance un téléchargement distinct, dont
l’emplacement d’enregistrement est contrôlé par votre navigateur. Le serveur suggère toujours upload.bin
comme nom du fichier téléchargé.
Tant que des opérations sont en cours, le champ de sélection de fichier et le bouton de téléversement sont désactivés. Cliquez sur Cancel pour interrompre les requêtes et les attentes avant nouvelle tentative. La création et le calcul local de l’empreinte se terminent avant que l’annulation ne soit prise en compte ; le nettoyage tente ensuite de supprimer l’ID connu. Les contrôles restent désactivés jusqu’à la fin du nettoyage. Un nettoyage en échec ou une réponse de création perdue peut laisser des données jusqu’à l’expiration. Annuler une requête ne peut pas défaire une finalisation déjà traitée par le serveur, c’est pourquoi le nettoyage supprime aussi les téléversements finalisés.
AbortSignal.any() et AbortSignal.timeout()
combinent l’annulation par l’utilisateur avec un délai d’expiration de cinq secondes par tentative de
requête. Les échecs réseau et les statuts HTTP temporaires listés bénéficient d’au plus trois
tentatives, avec des délais de 250 ms et 500 ms entre les tentatives. Les autres erreurs HTTP
échouent immédiatement. Ces délais d’expiration du navigateur utilisent le temps actif et peuvent se
mettre en pause pendant que la page est suspendue ; le délai et l’expiration côté serveur sont
indépendants.
Une réponse 409 signifie que des blocs manquent ou qu’un doublon est en conflit. Une réponse 422
signifie que l’empreinte assemblée est incorrecte. Une réponse 503 peut signifier que les quatre
emplacements de téléversement sont occupés, fichiers finalisés compris ; attendez l’expiration et
recommencez. Les ID manquants ou expirés renvoient 404. Arrêtez le serveur avec Ctrl+C une fois
terminé. Pour utiliser un port libre précis, passez son numéro après server.mts ; un port occupé
provoque un arrêt avec une erreur au lieu de l’affichage d’une URL prête.
Garder la frontière locale explicite
Le récepteur n’écoute que sur 127.0.0.1, vérifie l’hôte et l’origine du navigateur, exige un en-tête
personnalisé pour les mutations et n’utilise jamais un nom de fichier fourni comme chemin. Il sert des
octets opaques en tant que pièces jointes. La vérification de signature côté client détecte une erreur
de sélection de fichier ; ni un préfixe correspondant ni une empreinte correspondante ne prouvent
qu’un fichier est sûr. Le récepteur impose de manière indépendante les longueurs, les positions, la
capacité et l’empreinte, mais ne valide pas la structure des images ou des PDF et n’effectue aucune
analyse antimalware.
N’exposez pas ce serveur comme service public de téléversement. Un récepteur déployé nécessite une authentification, une autorisation et des quotas par utilisateur, HTTPS, un stockage durable et une validation du contenu adaptée à ses consommateurs. Les ID servent ici à isoler les téléversements locaux ; ils ne constituent pas un système de permissions de compte.
Choisir la prochaine étape
Essayez un fichier légèrement plus grand que deux blocs et observez les requêtes dans le panneau réseau de votre navigateur. Le dernier bloc devrait être plus petit, et l’URL du fichier ne devrait devenir utilisable qu’après la finalisation. C’est cette frontière qu’il faut conserver lorsque vous remplacez la mémoire par un stockage durable.
Pour reprendre après un rechargement, utilisez un protocole maintenu et conservez suffisamment d’état
pour vous resynchroniser avec le récepteur. tus définit la découverte du décalage avec HEAD
et la reprise avec PATCH ; les téléversements partiels parallèles utilisent son extension
facultative de concaténation. Cette démo à blocs numérotés est un protocole distinct, pas un client
tus. Le plugin tus d’Uppy
constitue une étape suivante pratique si vous voulez un client navigateur maintenu pour un serveur tus
compatible.
