Téléverser des fichiers avec un Web Worker et un récepteur local
Un Web Worker peut traiter des fichiers hors du thread principal de votre page et signaler la progression du téléversement par des messages. Cet exemple relie un worker dédié à un récepteur local exécutable : sélectionnez un fichier, téléversez-le par blocs séquentiels, puis téléchargez le fichier que le récepteur a effectivement accepté.
Choisir les opérations à confier à un worker
Un simple téléversement asynchrone n’a pas besoin de worker. Utilisez-en un si votre pipeline doit également analyser ou transformer des fichiers ; les workers peuvent effectuer des requêtes, mais ne peuvent pas mettre à jour le DOM de la page. C’est la page qui affiche leurs messages. Consultez le guide sur l’utilisation des Web Workers.
Ici, le hachage d’un petit fichier illustre une étape de traitement. Web Crypto est lui-même asynchrone : cet exemple ne prétend donc pas que le worker accélère le téléversement ou réduit la consommation totale de mémoire. Pour plusieurs fichiers, le tutoriel sur les pools de workers et les flux couvre la mise en file d’attente et la lecture incrémentale des flux. Nous nous limiterons ici à un fichier et un worker.
Utilisez Node.js 24.15.0 ou une version plus récente encore maintenue, Corepack avec Yarn 4, Bash et
un navigateur prenant en charge les workers de type module, Web Crypto et
AbortSignal.timeout(). L’exemple complet a été testé sous Linux avec Node.js 24.15.0 et 26.8.1
et Chromium 145. Au 2 octobre 2026, Node 24 est en phase LTS et Node 26 en phase Current ; choisissez
une version corrective à jour dans la liste des versions de Node.
Si Corepack n’est pas installé, suivez les
instructions d’installation de Yarn avant de continuer.
Créer le projet local et la page
Collez ce bloc dans Bash. Il refuse un répertoire existant, isole le projet Yarn avec son propre
fichier de verrouillage et revient à votre répertoire initial si l’installation échoue. En cas
d’échec, le nouveau répertoire de démonstration peut subsister ; inspectez-le et choisissez un
nouveau nom avant de réessayer. Tous les fichiers suivants doivent être placés dans
worker-upload-demo.
if (
mkdir worker-upload-demo &&
cd worker-upload-demo &&
printf '%s\n' '{"name":"worker-upload-demo","private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'globalFolder: .yarn/global' 'npmRegistryServer: https://registry.npmjs.org' > .yarnrc.yml &&
touch yarn.lock &&
mkdir src &&
corepack yarn add --dev --exact vite@8.3.1 typescript@6.0.3 @types/node@26.6.3
); then
cd worker-upload-demo
else
printf '%s\n' 'Setup failed; inspect the new directory before retrying.' >&2
false
fi
Enregistrez ce contenu dans index.html. Le récepteur servira la page générée et les
routes de téléversement depuis la même origine sur l’interface de bouclage. Ouvrez l’URL HTTP qu’il
affiche plutôt que d’ouvrir directement ce fichier.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dedicated worker upload</title>
</head>
<body>
<main>
<h1>Dedicated worker upload</h1>
<label for="fileInput">File to upload, up to 16 MiB</label>
<input type="file" id="fileInput" />
<button type="button" id="uploadBtn">Upload</button>
<button type="button" id="cancelBtn" disabled>Cancel</button>
<label id="progressLabel" for="uploadProgress">Upload progress</label>
<progress id="uploadProgress" aria-labelledby="progressLabel" value="0" max="100"></progress>
<p id="status" role="status">Choose a file.</p>
<output id="checksum" aria-label="Receiver SHA-256"></output>
<p><a id="download" hidden download="received.bin">Download received file</a></p>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Définir et vérifier le contrat d’échange de messages
Enregistrez le fichier src/worker-types.ts. Un message de fin comprend la somme de contrôle
et l’URL de téléchargement fournies par le récepteur, afin que la page puisse afficher un résultat
observable.
export interface WorkerMessage {
file: File
}
export type WorkerResponse =
| { type: 'processed' }
| { type: 'progress'; percent: number }
| { type: 'confirming' }
| { type: 'complete'; sha256: string; url: string }
| { type: 'error'; message: string }
Enregistrez le fichier tsconfig.json pour la page :
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": [],
"strict": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/main.ts", "src/worker-types.ts"]
}
Enregistrez le fichier tsconfig.worker.json. Vérifier les workers séparément évite de mélanger
les déclarations globales du DOM et celles des workers.
Vite génère le worker de type module, tandis que TypeScript vérifie
ses types.
{
"extends": "./tsconfig.json",
"compilerOptions": { "lib": ["ES2022", "WebWorker"] },
"include": ["src/upload.worker.ts", "src/worker-types.ts"]
}
Enregistrez le fichier tsconfig.server.json pour le récepteur Node :
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["server.mts"]
}
Envoyer un bloc à la fois depuis le worker
Enregistrez le fichier src/upload.worker.ts. Les fichiers jusqu’à 5 MiB sont hachés avant
l’envoi ; les fichiers plus volumineux ne font pas l’objet d’un hachage intégral. Chaque requête envoie
un Blob brut, attend un accusé de réception HTTP, puis passe au décalage
suivant. Les fichiers vides ne nécessitent aucune requête de bloc, mais exigent tout de même une
finalisation.
xhr.upload fournit la progression du transfert.
Ces événements ne confirment pas l’acceptation par le serveur. La barre reste sous 100 % jusqu’à la
réussite de la requête finale, même lorsque tous les octets ont déjà quitté le navigateur.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
declare const self: DedicatedWorkerGlobalScope
const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_FILE_SIZE = 16 * 1024 * 1024
function send(message: WorkerResponse): void {
self.postMessage(message)
}
function uploadChunk(chunk: Blob, url: string, onProgress: (ratio: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total)
}
xhr.open('PUT', url)
xhr.timeout = 60_000
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error('Chunk rejected'))
}
xhr.onerror = () => reject(new Error('Upload network error'))
xhr.ontimeout = () => reject(new Error('Upload timed out'))
xhr.onabort = () => reject(new Error('Upload canceled'))
xhr.send(chunk)
})
}
async function run({ file }: WorkerMessage): Promise<void> {
if (file.size > MAX_FILE_SIZE) throw new Error('File exceeds the demo limit')
let expectedHash: string | undefined
if (file.size <= CHUNK_SIZE) {
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
expectedHash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
send({ type: 'processed' })
}
const created = await fetch(`/uploads?size=${file.size}`, {
method: 'POST', signal: AbortSignal.timeout(60_000),
})
if (!created.ok) throw new Error('Could not create upload')
const entry: unknown = await created.json()
if (!entry || typeof entry !== 'object' || !('id' in entry)
|| typeof entry.id !== 'string' || !/^[0-9a-f-]{36}$/.test(entry.id)) {
throw new Error('Invalid upload ID')
}
const url = `/uploads/${entry.id}`
for (let start = 0; start < file.size; start += CHUNK_SIZE) {
const chunk = file.slice(start, start + CHUNK_SIZE)
await uploadChunk(chunk, `${url}?offset=${start}`, (ratio) => {
const percent = (start + ratio * chunk.size) / file.size * 100
send({ type: 'progress', percent: Math.min(99, percent) })
})
}
send({ type: 'confirming' })
const completed = await fetch(`${url}/complete`, {
method: 'POST', signal: AbortSignal.timeout(60_000),
})
if (!completed.ok) throw new Error('Finalization failed')
const result: unknown = await completed.json()
if (!result || typeof result !== 'object' || !('sha256' in result) || !('size' in result)
|| typeof result.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(result.sha256)
|| result.size !== file.size || (expectedHash !== undefined && result.sha256 !== expectedHash)) {
throw new Error('Invalid completion acknowledgment')
}
send({ type: 'complete', sha256: result.sha256, url })
}
self.onmessage = (event: MessageEvent<WorkerMessage>) => {
run(event.data).catch(() => send({ type: 'error', message: 'Upload failed. Please try again.' }))
}
Afficher la progression et libérer le worker
Enregistrez le fichier src/main.ts. Le File sélectionné est
capturé une seule fois, et la désactivation des contrôles empêche les téléversements simultanés.
Chaque chemin de sortie arrête le worker. Le contrôle d’identité ignore
également un message en file d’attente provenant d’un worker dont l’exécution a déjà été annulée.
import type { WorkerMessage, WorkerResponse } from './worker-types.ts'
const fileInput = document.getElementById('fileInput')
const uploadBtn = document.getElementById('uploadBtn')
const cancelBtn = document.getElementById('cancelBtn')
const uploadProgress = document.getElementById('uploadProgress')
const statusElement = document.getElementById('status')
const checksum = document.getElementById('checksum')
const download = document.getElementById('download')
if (!(fileInput instanceof HTMLInputElement) || !(uploadBtn instanceof HTMLButtonElement)
|| !(cancelBtn instanceof HTMLButtonElement) || !(uploadProgress instanceof HTMLProgressElement)
|| !(statusElement instanceof HTMLElement) || !(checksum instanceof HTMLOutputElement)
|| !(download instanceof HTMLAnchorElement)) {
throw new Error('Missing upload controls')
}
let worker: Worker | null = null
const finish = (message: string): void => {
worker?.terminate()
worker = null
uploadBtn.disabled = false
fileInput.disabled = false
cancelBtn.disabled = true
statusElement.textContent = message
}
uploadBtn.addEventListener('click', () => {
if (worker !== null) return
const file = fileInput.files?.[0]
uploadProgress.value = 0
checksum.value = ''
download.hidden = true
download.removeAttribute('href')
if (!file) {
statusElement.textContent = 'Please select a file.'
return
}
if (file.size > 16 * 1024 * 1024) {
statusElement.textContent = 'Choose a file of at most 16 MiB.'
return
}
uploadBtn.disabled = true
fileInput.disabled = true
cancelBtn.disabled = false
statusElement.textContent = 'Preparing upload.'
try {
const current = new Worker(new URL('./upload.worker.ts', import.meta.url), { type: 'module' })
worker = current
current.onmessage = (event: MessageEvent<WorkerResponse>) => {
if (worker !== current) return
const response = event.data
switch (response.type) {
case 'processed':
statusElement.textContent = 'File hashed. Uploading.'
break
case 'progress':
uploadProgress.value = response.percent
statusElement.textContent = `Uploading: ${Math.round(response.percent)}%`
break
case 'confirming':
statusElement.textContent = 'Confirming upload.'
break
case 'complete':
checksum.value = response.sha256
download.href = response.url
download.hidden = false
uploadProgress.value = 100
finish('Upload complete.')
break
case 'error':
finish(response.message)
break
}
}
current.onerror = (event) => {
if (worker !== current) return
event.preventDefault()
finish('The upload worker failed. Please try again.')
}
current.onmessageerror = () => {
if (worker === current) finish('Could not read the upload worker response.')
}
current.postMessage({ file } satisfies WorkerMessage)
} catch {
finish('Could not start the upload worker.')
}
})
cancelBtn.addEventListener('click', () => finish('Upload canceled.'))
window.addEventListener('pagehide', () => finish('Upload stopped.'))
Exécuter le récepteur local compatible
Enregistrez le fichier server.mts. Node exécute ce module ES avec la
suppression native des annotations de type TypeScript. Le récepteur
crée un fichier associé à un identifiant généré, accepte les blocs au prochain décalage attendu et
ne propose le téléchargement qu’après avoir vérifié la taille finale et calculé SHA-256. Il limite
la charge utile de chaque bloc à 5 MiB, ajoute les blocs au fichier sur disque et lit le fichier
stocké en flux pour calculer sa somme de contrôle.
Il s’agit d’un protocole pédagogique sur localhost, avec une limite de 16 MiB par fichier. Il ne
prévoit ni authentification, ni nouvelle tentative, ni reprise, ni expiration, ni quota global
d’espace disque. Les fichiers restent dans received/ ; un redémarrage fait perdre
le registre des téléversements en mémoire et les URL de téléchargement. Après avoir arrêté la
démonstration, supprimez de ce répertoire les fichiers dont vous n’avez plus besoin. Le nom de
fichier fourni par le client n’est jamais utilisé comme chemin de stockage.
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { appendFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { parseArgs } from 'node:util'
const CHUNK_SIZE = 5 * 1024 * 1024
const MAX_FILE_SIZE = 16 * 1024 * 1024
const receivedRoot = new URL('./received/', import.meta.url)
interface Upload {
path: URL
size: number
received: number
complete: boolean
busy: boolean
}
const uploads = new Map<string, Upload>()
function reply(response: ServerResponse, status: number, value: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json' })
response.end(JSON.stringify(value))
}
async function readBody(request: IncomingMessage, limit: number): Promise<Buffer> {
const parts: Buffer[] = []
let length = 0
for await (const part of request) {
if (!(part instanceof Buffer)) throw new Error('Unexpected request body')
length += part.length
if (length > limit) throw new Error('Request body exceeds its limit')
parts.push(part)
}
return Buffer.concat(parts, length)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const url = new URL(request.url ?? '/', 'http://localhost')
if (request.method === 'GET' && (url.pathname === '/' || /^\/assets\/[\w.-]+\.(js|css)$/.test(url.pathname))) {
const path = url.pathname === '/' ? '/index.html' : url.pathname
const data = await readFile(new URL(`./dist${path}`, import.meta.url))
response.writeHead(200, {
'Content-Type': path.endsWith('.html') ? 'text/html' : path.endsWith('.css') ? 'text/css' : 'text/javascript',
})
response.end(data)
return
}
if (request.method === 'POST' && url.pathname === '/uploads') {
const sizeText = url.searchParams.get('size')
const size = sizeText !== null && /^\d+$/.test(sizeText) ? Number(sizeText) : NaN
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_FILE_SIZE) {
reply(response, 400, { error: 'Invalid file size' })
return
}
await readBody(request, 0)
const id = randomUUID()
const path = new URL(`${id}.bin`, receivedRoot)
await writeFile(path, Buffer.alloc(0), { flag: 'wx' })
uploads.set(id, { path, size, received: 0, complete: false, busy: false })
reply(response, 201, { id })
return
}
const match = /^\/uploads\/([0-9a-f-]{36})(\/complete)?$/.exec(url.pathname)
const upload = match?.[1] !== undefined ? uploads.get(match[1]) : undefined
if (!upload) {
reply(response, 404, { error: 'Upload not found' })
return
}
if (request.method === 'GET' && !match?.[2] && upload.complete) {
response.writeHead(200, { 'Content-Type': 'application/octet-stream' })
createReadStream(upload.path).on('error', () => response.destroy()).pipe(response)
return
}
if (upload.busy || upload.complete) {
reply(response, 409, { error: 'Upload is busy or already complete' })
return
}
upload.busy = true
try {
if (request.method === 'PUT' && !match?.[2]) {
const offsetText = url.searchParams.get('offset')
const offset = offsetText !== null && /^\d+$/.test(offsetText) ? Number(offsetText) : NaN
const expected = Math.min(CHUNK_SIZE, upload.size - upload.received)
if (offset !== upload.received || expected <= 0) {
reply(response, 409, { error: 'Unexpected chunk offset' })
return
}
const data = await readBody(request, expected)
if (data.length !== expected) {
reply(response, 400, { error: 'Unexpected chunk length' })
return
}
await appendFile(upload.path, data)
upload.received += data.length
reply(response, 204, null)
return
}
if (request.method === 'POST' && match?.[2] === '/complete') {
await readBody(request, 0)
if (upload.received !== upload.size || (await stat(upload.path)).size !== upload.size) {
reply(response, 409, { error: 'Upload is incomplete' })
return
}
const hash = createHash('sha256')
for await (const part of createReadStream(upload.path)) hash.update(part)
upload.complete = true
reply(response, 200, { size: upload.size, sha256: hash.digest('hex') })
return
}
reply(response, 405, { error: 'Method not allowed' })
} finally {
upload.busy = false
}
}
async function main(): Promise<void> {
const { values } = parseArgs({ options: { port: { type: 'string', default: '0' } } })
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
await readFile(new URL('./dist/index.html', import.meta.url))
await mkdir(receivedRoot, { recursive: true })
const server = createServer((request, response) => {
handle(request, response).catch(() => {
if (!response.headersSent) reply(response, 500, { error: 'Request failed' })
else response.destroy()
})
})
server.requestTimeout = 60_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('Missing listening address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Receiver startup failed')
process.exitCode = 1
})
Depuis le répertoire de démonstration, collez ce bloc pour vérifier les trois environnements,
générer la page et le worker, puis exécuter le récepteur. L’enchaînement &&
empêche le lancement d’une ancienne version compilée si la compilation échoue. Le port zéro demande
au système d’exploitation un port disponible ; ouvrez l’URL affichée par le récepteur. Utilisez
Ctrl+C dans ce terminal pour l’arrêter. Pour choisir un port fixe, remplacez
--port 0 par, par exemple, --port 8000 ; un port occupé fait échouer
le démarrage.
corepack yarn tsc --project tsconfig.json &&
corepack yarn tsc --project tsconfig.worker.json &&
corepack yarn tsc --project tsconfig.server.json &&
corepack yarn vite build &&
node server.mts --port 0
Choisissez un petit fichier et cliquez sur Upload. Lorsque Upload complete. apparaît, utilisez Download received file et comparez les octets téléchargés à ceux de votre fichier d’origine. Le SHA-256 affiché correspond au fichier du récepteur ; seule la branche réservée aux petits fichiers le compare aussi à une empreinte calculée côté client. L’achèvement confirme le respect de ce contrat de téléversement local, sans affirmer que le fichier a passé avec succès une analyse antimalware ou un autre pipeline de traitement.
Essayez un fichier de plus de 5 MiB pour tester plusieurs requêtes et un dernier bloc court. Cliquez sur Cancel tant qu’un téléversement n’est pas terminé, puis lancez-en un autre. L’annulation arrête le worker et son activité côté client ; les octets acceptés peuvent rester sur le récepteur, et une finalisation déjà acceptée par le serveur ne peut pas être annulée en interrompant la réception de sa réponse. Une nouvelle tentative crée un nouvel identifiant. Découper un fichier en blocs ne rend pas ce protocole capable de reprendre les transferts.
Pour les transferts avec reprise, le plugin Tus d’Uppy utilise le protocole tus avec un serveur tus compatible. Ce contrat côté serveur diffère de celui de cette démonstration.
