Envois segmentés du navigateur vers Google Storage en JavaScript
Envoyez de gros fichiers directement depuis votre navigateur vers un bucket Google Cloud Storage (GCS) privé, sans faire transiter le fichier par Node.js. Le serveur authentifie un propriétaire unique et ouvre une session d’envoi avec reprise ; le navigateur envoie des segments alignés et interroge GCS pour savoir où reprendre après une interruption. L’exemple comprend des commandes de pause et de reprise dans la même page ouverte, ainsi qu’une vérification indépendante des octets après la fin de l’envoi.
Pourquoi segmenter les envois ?
Une coupure de connexion peut laisser un nombre d’octets stockés supérieur au nombre d’octets confirmés au navigateur. Le protocole d’envoi avec reprise de GCS permet au navigateur de demander cette position acceptée avant d’envoyer davantage de données. Ces requêtes construisent un seul objet ; il n’y a donc aucun objet de segment distinct à assembler ou à supprimer.
Configurer Google Cloud Storage
Utilisez Bash sous Linux ou macOS, une version LTS actuelle de Node.js 24, Corepack et un bucket privé existant. La configuration des paquets ci-dessous fixe les versions à Yarn 4.18.1 et Google Auth Library 11.3.0. Node.js 24.22.0 est la version LTS testée ; continuez à appliquer les mises à jour de sécurité maintenues pour cette version majeure.
Le serveur a besoin de storage.objects.create sur le bucket. Le rôle
roles/storage.objectCreator, limité à ce bucket, fournit cette autorisation pour cet exemple
qui permet uniquement la création d’objets. La vérification ultérieure des octets nécessite aussi
storage.objects.get, disponible via roles/storage.objectViewer. Consultez les
autorisations requises pour l’envoi dans la documentation de Google.
Utilisez des Application Default Credentials (ADC)
existantes disposant de ces autorisations. Si les ADC ne sont pas configurées, installez Google
Cloud CLI et exécutez gcloud auth application-default login avant de démarrer le serveur. Une connexion
réussie ne suffit pas à établir l’accès à votre bucket.
Collez ceci dans le répertoire où vous souhaitez créer le projet gcs-upload.
La commande refuse un répertoire existant, laisse votre shell dans son répertoire actuel et dote
le projet de sa propre configuration Yarn et de son propre fichier de verrouillage :
(
set -eu
command -v node >/dev/null || { printf 'Install Node.js first.\n' >&2; exit 1; }
command -v corepack >/dev/null || { printf 'Install Corepack first.\n' >&2; exit 1; }
mkdir gcs-upload
cd gcs-upload
printf '%s\n' '{"name":"gcs-upload","private":true,"type":"module","packageManager":"yarn@4.18.1","dependencies":{"google-auth-library":"11.3.0"}}' > package.json
printf '%s\n' 'nodeLinker: node-modules' 'ignorePath: true' 'enableGlobalCache: false' > .yarnrc.yml
: > yarn.lock
corepack yarn@4.18.1 install
)
Si l’installation échoue, conservez les fichiers créés et réessayez (cd gcs-upload && corepack yarn@4.18.1 install).
Une fois l’installation réussie, ouvrez un terminal dans gcs-upload et
enregistrez-y les quatre fichiers suivants.
L’API JSON gère CORS indépendamment des règles CORS du bucket. Le serveur fournit l’origine exacte de la page lors de la création de la session. Le navigateur ajoute ensuite cette origine à ses requêtes d’envoi de segments. CORS permet au navigateur de lire les réponses ; le jeton d’envoi contrôle qui peut créer une session.
Côté serveur : créer des sessions d’envoi authentifiées
Enregistrez ceci sous server.mjs. Le jeton configuré identifie l’unique
propriétaire de cet outil local. Chaque session nécessite ce jeton, et le serveur choisit un nom
d’objet imprévisible dans uploads/owner/. Les clients ne peuvent fournir ni noms de
buckets, ni chemins de destination, ni noms d’objets à supprimer. La précondition
ifGenerationMatch=0 empêche également l’écrasement d’un objet existant.
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { GoogleAuth } from 'google-auth-library'
const ORIGIN = 'http://127.0.0.1:8080'
const MAX_SIZE = 10 * 1024 ** 3
const assets = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/upload.mjs', ['upload.mjs', 'text/javascript; charset=utf-8']],
['/app.mjs', ['app.mjs', 'text/javascript; charset=utf-8']],
])
export function createUploadServer({ auth, bucket, token }) {
if (!bucket || !token || token.length < 32) throw new Error('Set BUCKET and a 32-character token')
const digest = (value) => createHash('sha256').update(value).digest()
const expected = digest(`Bearer ${token}`)
return createServer((req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Referrer-Policy', 'no-referrer')
const reply = (status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function handle() {
const asset = assets.get(req.url)
if (req.method === 'GET' && asset) {
const body = await readFile(new URL(asset[0], import.meta.url))
res.writeHead(200, { 'Content-Type': asset[1] })
res.end(body)
return
}
if (req.method !== 'POST' || req.url !== '/sessions') return reply(404, { error: 'Not found' })
if (!timingSafeEqual(expected, digest(req.headers.authorization ?? ''))) {
return reply(401, { error: 'Invalid upload token' })
}
if (req.headers.origin !== ORIGIN) return reply(403, { error: 'Invalid origin' })
const chunks = []
let bytes = 0
for await (const chunk of req) {
bytes += chunk.length
if (bytes > 1024) return reply(413, { error: 'Request too large' })
chunks.push(chunk)
}
let input
try {
input = JSON.parse(Buffer.concat(chunks, bytes).toString('utf8'))
} catch {
return reply(400, { error: 'Invalid JSON' })
}
const size = input?.size
if (!Number.isSafeInteger(size) || size < 1 || size > MAX_SIZE) {
return reply(400, { error: 'Choose a nonempty file of at most 10 GiB' })
}
const name = `uploads/owner/${randomUUID()}`
const url = new URL(`https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o`)
url.search = new URLSearchParams({ uploadType: 'resumable', name, ifGenerationMatch: '0' })
const client = await auth.getClient()
const response = await client.request({
url: url.href,
method: 'POST',
headers: { Origin: ORIGIN, 'X-Upload-Content-Length': String(size) },
data: { contentType: 'application/octet-stream' },
timeout: 30_000,
retry: false,
})
const sessionUrl = response.headers.get('location')
if (!sessionUrl || new URL(sessionUrl).origin !== 'https://storage.googleapis.com') {
throw new Error('Invalid session response')
}
reply(201, { sessionUrl, name })
}
handle().catch(() => reply(502, { error: 'Could not create upload session' }))
})
}
if (import.meta.main) {
const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/devstorage.read_write'] })
const server = createUploadServer({ auth, bucket: process.env.BUCKET, token: process.env.UPLOAD_TOKEN })
server.on('error', () => {
console.error('Could not start upload server. Check whether port 8080 is already in use.')
process.exitCode = 1
})
server.listen(8080, '127.0.0.1', () => console.log(`Open ${ORIGIN}`))
}
Google Auth Library conserve les informations d’authentification Google sur le serveur. L’URL de session renvoyée constitue elle-même une information d’authentification au porteur : quiconque la possède peut envoyer des données vers cette seule destination. Ce n’est pas une URL signée valable 15 minutes ; les sessions avec reprise de GCS expirent après une semaine. Ne consignez pas les URL de session dans les journaux et ne les placez pas dans un stockage partagé.
Côté navigateur : un outil d’envoi segmenté minimal
Enregistrez ceci sous upload.mjs. La taille de segment de 8 MiB est un multiple
de l’alignement de 256 KiB exigé par GCS ; le dernier segment peut être plus petit. Une réponse
308 signifie que l’envoi est incomplet ; le code la traite donc avant la
vérification habituelle des erreurs HTTP. Il lit la plage d’octets confirmée au lieu de supposer
que GCS a accepté toute la requête. Consultez le
protocole d’envoi avec reprise.
const CHUNK_SIZE = 8 * 1024 * 1024
export async function uploadFileWithChunks(file, sessionUrl, { signal, onProgress = () => {} } = {}) {
if (file.size < 1 || file.size > 10 * 1024 ** 3) throw new Error('Invalid file size')
let offset = 0
let probe = true
let failures = 0
while (true) {
signal?.throwIfAborted()
const end = Math.min(offset + CHUNK_SIZE, file.size)
let response
try {
response = await fetch(sessionUrl, {
method: 'PUT',
credentials: 'omit',
signal,
headers: {
'Content-Range': probe ? `bytes */${file.size}` : `bytes ${offset}-${end - 1}/${file.size}`,
},
body: probe ? new Blob([]) : file.slice(offset, end),
})
} catch (error) {
signal?.throwIfAborted()
if (++failures > 5) throw error
await backoff(failures, signal)
probe = true
continue
}
await response.body?.cancel()
if (response.status === 200 || response.status === 201) {
onProgress(100)
return
}
if (response.status === 429 || response.status >= 500) {
if (++failures > 5) throw new Error('Upload retries exhausted')
await backoff(failures, signal)
probe = true
continue
}
if (response.status !== 308) throw new Error(`Upload failed (HTTP ${response.status})`)
const range = response.headers.get('range')
const match = range === null ? null : /^bytes=0-(\d+)$/.exec(range)
if (range !== null && !match) throw new Error('Invalid acknowledged range')
const next = match ? Number(match[1]) + 1 : 0
if (!Number.isSafeInteger(next) || next < offset || next >= file.size || (!probe && next > end)) {
throw new Error('Invalid acknowledged position')
}
if (!probe && next === offset) {
if (++failures > 5) throw new Error('Upload made no progress')
await backoff(failures, signal)
probe = true
continue
}
if (next > offset) failures = 0
offset = next
onProgress((offset / file.size) * 100)
probe = false
}
}
function backoff(attempt, signal) {
signal?.throwIfAborted()
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', abort)
resolve()
}, 500 * 2 ** (attempt - 1))
signal?.addEventListener('abort', abort, { once: true })
})
}
Réessayer avec un espacement exponentiel des tentatives
Les erreurs réseau, les limites de débit et les erreurs serveur déclenchent jusqu’à cinq nouvelles
tentatives entre deux mises à jour de la progression. Chaque nouvelle tentative commence par
demander la position stockée avec une requête PUT vide. Une pause
interrompt à la fois la requête active et tout délai avant une nouvelle tentative. Les refus
d’autorisation, les sessions expirées et les plages mal formées arrêtent l’envoi au lieu de
provoquer une boucle infinie. Si une réponse finale a été perdue, la prochaine requête d’état peut
confirmer la fin de l’envoi sans envoyer à nouveau le fichier.
Enregistrez la page sous index.html :
<!doctype html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Resumable GCS upload</title>
<label>Upload token <input id="token" type="password" autocomplete="off" /></label>
<label>File <input id="file" type="file" /></label>
<button id="upload">Upload / resume</button>
<button id="pause" disabled>Pause</button>
<progress id="progress" max="100" value="0" aria-label="Upload progress"></progress>
<p id="status" role="status">Choose a file.</p>
<script type="module" src="/app.mjs"></script>
</html>
Enregistrez les commandes sous app.mjs :
import { uploadFileWithChunks } from './upload.mjs'
const fileInput = document.getElementById('file')
const tokenInput = document.getElementById('token')
const upload = document.getElementById('upload')
const pause = document.getElementById('pause')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
let session
let controller
fileInput.addEventListener('change', () => {
session = undefined
progress.value = 0
})
pause.addEventListener('click', () => controller?.abort())
upload.addEventListener('click', async () => {
const file = fileInput.files[0]
if (!file) return
controller = new AbortController()
upload.disabled = fileInput.disabled = tokenInput.disabled = true
pause.disabled = false
status.textContent = 'Uploading…'
try {
if (!session) {
const response = await fetch('/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokenInput.value}` },
body: JSON.stringify({ size: file.size }),
signal: controller.signal,
})
if (!response.ok) throw new Error(`Session failed (HTTP ${response.status})`)
session = await response.json()
}
await uploadFileWithChunks(file, session.sessionUrl, {
signal: controller.signal,
onProgress: (value) => { progress.value = value },
})
status.textContent = `Uploaded to ${session.name}`
session = undefined
} catch {
status.textContent = controller.signal.aborted
? 'Paused. Click Upload / resume to continue.'
: 'Upload failed. Retry, or reselect the file to start a new session.'
} finally {
upload.disabled = fileInput.disabled = tokenInput.disabled = false
pause.disabled = true
}
})
Remplacez le nom de bucket fictif par le vôtre, puis démarrez le serveur depuis
gcs-upload. À l’invite masquée, collez un secret aléatoire d’au moins
32 caractères provenant de votre gestionnaire de mots de passe. Ouvrez
http://127.0.0.1:8080, saisissez ce secret dans Upload token,
choisissez un fichier non vide de 10 GiB maximum, puis cliquez sur Upload / resume.
Utilisez Pause pour mettre en pause et le
même bouton d’envoi pour reprendre. Ne modifiez pas le fichier pendant l’envoi. Arrêtez le serveur
avec Ctrl+C lorsque vous avez terminé :
(
export BUCKET='your-existing-private-bucket'
read -r -s -p 'Upload token: ' UPLOAD_TOKEN &&
printf '\n' &&
export UPLOAD_TOKEN &&
node server.mjs
)
Vérifier les octets stockés
La page affiche Uploaded to suivi du nom de
l’objet après confirmation par GCS de la fin de l’envoi. Avant d’utiliser l’objet en aval,
comparez ses octets avec ceux du fichier local sélectionné. Enregistrez ceci sous
verify.mjs dans le même projet. Le programme vérifie les métadonnées, fixe le
téléchargement à cette génération exacte et calcule le hachage SHA-256 des deux fichiers en flux,
sans conserver le fichier entier en mémoire.
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { GoogleAuth } from 'google-auth-library'
const [file, name, secondsText = '7200', ...extra] = process.argv.slice(2)
const seconds = Number(secondsText)
if (!file || !name || !process.env.BUCKET || extra.length ||
!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400) {
console.error('Usage: BUCKET=your-bucket node verify.mjs FILE OBJECT_NAME [SECONDS: 1–86400]')
process.exit(1)
}
let phase = 'local-file'
// A process deadline also covers ADC refresh, which has its own HTTP transport.
const deadline = setTimeout(() => {
console.error(`Verification deadline exceeded during ${phase}. Retry with a larger SECONDS value.`)
process.exit(124)
}, seconds * 1000)
try {
const info = await stat(file)
if (!info.isFile() || info.size < 1 || info.size > 10 * 1024 ** 3) {
throw new Error('Choose a nonempty regular file of at most 10 GiB')
}
async function hash(stream) {
const digest = createHash('sha256')
let bytes = 0
for await (const chunk of stream) {
bytes += chunk.length
digest.update(chunk)
}
return { bytes, sha256: digest.digest('hex') }
}
const local = await hash(createReadStream(file))
phase = 'credentials'
const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/devstorage.read_only'] })
const client = await auth.getClient()
await client.getAccessToken()
phase = 'metadata'
const url = new URL(`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(process.env.BUCKET)}/o/${encodeURIComponent(name)}`)
const metadata = await client.request({ url: url.href, retry: false, timeout: 0 })
const object = metadata.data
if (object.name !== name || !/^[1-9]\d*$/.test(object.generation) ||
Number(object.size) !== local.bytes) throw new Error('Object metadata does not match the local file')
url.search = new URLSearchParams({ alt: 'media', generation: object.generation })
phase = 'download'
const response = await client.request({ url: url.href, responseType: 'stream', retry: false, timeout: 0 })
const stored = await hash(response.data)
phase = 'integrity'
if (stored.bytes !== local.bytes || stored.sha256 !== local.sha256) {
throw new Error('Stored bytes do not match the local file')
}
console.log(`Verified ${stored.bytes} bytes (generation ${object.generation}).`)
} catch (error) {
const status = Number.isInteger(error?.response?.status) ? error.response.status : null
const data = error?.response?.data
const oauthError = ['invalid_grant', 'invalid_client', 'unauthorized_client'].includes(data?.error)
? data.error : null
const oauthSubtype = data?.error_subtype === 'invalid_rapt' ? 'invalid_rapt' : null
console.error(JSON.stringify({ error: 'verification-failed', phase, status, oauthError, oauthSubtype }))
console.error(`Verification failed during ${phase}. Check ADC and bucket access for credentials/metadata errors; check the selected file and object for an integrity error.`)
process.exitCode = 1
} finally {
clearTimeout(deadline)
}
Exécutez-le dans un second terminal depuis gcs-upload, en remplaçant le chemin
du fichier et en copiant le nom complet de l’objet depuis la page :
BUCKET='your-existing-private-bucket' node verify.mjs './large-file.bin' 'uploads/owner/your-object-id' 7200
Pour les fichiers jusqu’à 10 GiB, le délai par défaut est de 7 200 secondes (deux heures) pour
l’ensemble de la vérification : hachage local, obtention des informations d’authentification
et renouvellement OAuth, métadonnées et téléchargement complet. Définissez
SECONDS entre 1 et 86 400 si votre connexion nécessite un autre délai ; le
seul téléchargement de 10 GiB nécessite environ 12 Mbit/s pour tenir en deux heures. Ici, le SDK
n’applique aucun délai supplémentaire couvrant la totalité du corps de la réponse.
À l’expiration du délai, le processus se termine avec le code de sortie 124 et n’affiche aucun
résultat vérifié. Relancez-le avec un délai plus long après avoir examiné séparément les échecs
d’accès ; prolonger un délai ne peut corriger ni des informations d’authentification incorrectes,
ni une différence entre les octets. L’outil de vérification lit l’objet stocké sans le renvoyer ni
le modifier.
Conserver la progression après un rechargement
Le fichier et la session restent dans la mémoire de cette page. La mise en pause conserve les deux ; le rechargement de la page ou la sélection d’un autre fichier démarre une nouvelle session. Une session incomplète expire sans publier d’objet finalisé. Cet exemple ne récupère pas les sessions après un rechargement.
Une application multi-utilisateur nécessiterait une gestion authentifiée de la propriété des sessions côté serveur et un moyen de vérifier qu’un fichier sélectionné à nouveau a le même contenu. Un nom, une taille ou un pourcentage de progression enregistré ne suffit pas.
Bonnes pratiques de sécurité et de performance
Le jeton identifie un propriétaire de confiance unique. Gardez le serveur lié à l’interface de bouclage ; cet exemple ne fournit ni système de connexion multi-utilisateur, ni quotas. Les URL de session sont des informations d’authentification pour l’envoi ; ne les placez donc pas dans les journaux, le stockage du navigateur ou des captures d’écran partagées. Les informations d’authentification Google restent dans Node.js.
Le navigateur envoie un seul segment de 8 MiB à la fois et n’indique la progression qu’après confirmation. La mise en pause interrompt la requête en cours et le délai avant une nouvelle tentative ; certains octets peuvent déjà être stockés lorsque vous mettez l’envoi en pause. La tentative suivante demande à GCS la position acceptée. Les fichiers vides et ceux de plus de 10 GiB sont refusés. Une erreur de session irrécupérable nécessite de sélectionner à nouveau le fichier pour recommencer. La précondition limitant l’opération à la création empêche le remplacement d’un objet existant, même en cas de collision entre les noms de destination.
Pour une interface de sélection de fichiers plus complète, consultez Uppy. Son intégration nécessite toujours un mécanisme de transport adapté à votre application pour l’envoi.
