Chunk-Uploads vom Browser zu Google Storage mit JavaScript
Senden Sie große Dateien direkt aus Ihrem Browser an einen privaten Bucket in Google Cloud Storage (GCS), ohne die Datei über Node.js weiterzuleiten. Der Server authentifiziert genau einen Eigentümer und öffnet eine fortsetzbare Upload-Sitzung. Der Browser sendet passend ausgerichtete Chunks und fragt GCS nach einer Unterbrechung, wo er fortfahren soll. Das Beispiel ermöglicht Pausieren und Fortsetzen auf derselben geöffneten Seite sowie einen unabhängigen Bytevergleich nach Abschluss.
Warum Chunk-Uploads?
Nach einem Verbindungsabbruch können mehr Bytes gespeichert sein, als der Browser bestätigt hat. Mit dem fortsetzbaren Upload-Protokoll von GCS kann der Browser diese angenommene Position abfragen, bevor er weitere Daten sendet. Diese Anfragen erstellen ein einziges Objekt. Es gibt also keine separaten Chunk-Objekte, die zusammengefügt oder gelöscht werden müssen.
Google Cloud Storage konfigurieren
Verwenden Sie Bash unter Linux oder macOS, eine aktuelle Version von Node.js 24 LTS, Corepack und einen vorhandenen privaten Bucket. Die folgende Paketeinrichtung legt Yarn 4.18.1 und Google Auth Library 11.3.0 fest. Node.js 24.22.0 ist die getestete LTS-Version. Installieren Sie weiterhin gepflegte Sicherheitsupdates dieser Hauptversion.
Der Server benötigt storage.objects.create für den Bucket. Die auf den Bucket beschränkte
Rolle roles/storage.objectCreator stellt diese Berechtigung für dieses Beispiel bereit, das nur neue
Objekte erstellt. Der spätere Bytevergleich benötigt außerdem storage.objects.get,
verfügbar über roles/storage.objectViewer. Siehe die von Google beschriebenen
erforderlichen Upload-Berechtigungen.
Verwenden Sie vorhandene Application Default Credentials (ADC)
mit diesen Berechtigungen. Falls ADC nicht konfiguriert ist, installieren Sie die Google Cloud CLI
und führen Sie gcloud auth application-default login aus, bevor Sie den Server starten. Eine erfolgreiche
Anmeldung allein gewährt noch keinen Zugriff auf Ihren Bucket.
Fügen Sie dies in dem Verzeichnis ein, in dem Sie das neue Projekt
gcs-upload erstellen möchten. Das Skript lehnt ein vorhandenes Verzeichnis ab,
belässt Ihre Shell im aktuellen Verzeichnis und gibt dem Projekt eine eigene Yarn-Konfiguration und
Lockdatei:
(
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
)
Falls die Installation fehlschlägt, behalten Sie die erstellten Dateien und führen Sie
(cd gcs-upload && corepack yarn@4.18.1 install) erneut aus. Öffnen Sie nach erfolgreicher Installation ein Terminal
in gcs-upload und speichern Sie dort die folgenden vier Dateien.
Die JSON API behandelt CORS unabhängig von den CORS-Regeln des Buckets. Der Server übermittelt beim Erstellen der Sitzung den genauen Ursprung der Seite. Der Browser fügt diesen Ursprung dann seinen Chunk-Anfragen hinzu. CORS erlaubt dem Browser, Antworten zu lesen. Das Upload-Token steuert, wer eine Sitzung erstellen kann.
Backend: authentifizierte Upload-Sitzungen erstellen
Speichern Sie dies als server.mjs. Das konfigurierte Token identifiziert den
einzigen Eigentümer dieses lokalen Tools. Jede Sitzung erfordert dieses Token, und der Server wählt
einen nicht vorhersagbaren Objektnamen innerhalb von uploads/owner/. Clients können
weder Bucket-Namen noch Zielpfade oder Namen zu löschender Objekte vorgeben. Die Vorbedingung
ifGenerationMatch=0 verhindert außerdem, dass ein vorhandenes Objekt überschrieben wird.
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}`))
}
Die Google Auth Library hält die Google-Zugangsdaten auf dem Server. Die zurückgegebene Sitzungs-URL ist selbst ein Bearer-Zugangsnachweis: Jeder, der sie besitzt, kann an dieses eine Ziel hochladen. Sie ist keine signierte URL mit 15 Minuten Gültigkeit. Fortsetzbare GCS-Sitzungen laufen nach einer Woche ab. Halten Sie Sitzungs-URLs aus Logs und gemeinsam genutztem Speicher heraus.
Frontend: ein minimaler Dateiuploader für Chunk-Uploads
Speichern Sie dies als upload.mjs. Die Chunk-Größe von 8 MiB ist ein Vielfaches der
von GCS vorgeschriebenen Ausrichtung auf 256 KiB. Der letzte Chunk darf kleiner sein. Eine Antwort
mit 308 bedeutet, dass der Upload unvollständig ist. Der Code behandelt sie
daher vor der üblichen Prüfung auf HTTP-Fehler. Er liest den bestätigten Bytebereich aus, statt
anzunehmen, dass GCS die gesamte Anfrage angenommen hat. Siehe das
Protokoll für fortsetzbare Uploads.
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 })
})
}
Wiederholungsversuche mit exponentiellem Backoff
Netzwerkfehler, Ratenbegrenzungen und Serverfehler lösen zwischen Fortschrittsmeldungen bis zu fünf
Wiederholungsversuche aus. Jeder Versuch fragt zunächst mit einer leeren Anfrage
(PUT) die gespeicherte Position ab. Pausieren bricht sowohl die aktive
Anfrage als auch eine laufende Wartezeit vor dem nächsten Versuch ab. Berechtigungsfehler,
abgelaufene Sitzungen und fehlerhafte Bereichsangaben stoppen den Upload, statt eine Endlosschleife
auszulösen. Falls die abschließende Antwort verloren gegangen ist, kann die nächste Statusabfrage
den Abschluss bestätigen, ohne die Datei erneut hochzuladen.
Speichern Sie die Seite als 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>
Speichern Sie die Steuerelemente als 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
}
})
Ersetzen Sie den Bucket-Platzhalter und starten Sie dann den Server im Verzeichnis
gcs-upload. Fügen Sie bei der verdeckten Eingabeaufforderung ein zufälliges
Geheimnis mit mindestens 32 Zeichen aus Ihrem Passwortmanager ein. Öffnen Sie
http://127.0.0.1:8080, geben Sie dieses Geheimnis in
Upload token ein, wählen Sie eine nicht leere Datei
mit höchstens 10 GiB aus und klicken Sie auf
Upload / resume.
Verwenden Sie Pause zum Pausieren und dieselbe
Upload-Schaltfläche zum Fortsetzen. Verändern Sie die Datei während des Uploads nicht. Beenden Sie
den Server nach Abschluss mit Ctrl+C:
(
export BUCKET='your-existing-private-bucket'
read -r -s -p 'Upload token: ' UPLOAD_TOKEN &&
printf '\n' &&
export UPLOAD_TOKEN &&
node server.mjs
)
Gespeicherte Bytes überprüfen
Nachdem GCS den Abschluss bestätigt hat, zeigt die Seite
Uploaded to gefolgt vom Objektnamen an.
Vergleichen Sie die Bytes des Objekts mit der ausgewählten lokalen Datei, bevor Sie es
weiterverarbeiten. Speichern Sie dies als verify.mjs im selben Projekt. Das Skript
prüft Metadaten, bindet den Download an genau diese Generation und verarbeitet beide Dateien als
Datenströme mit SHA-256, ohne die gesamte Datei im Arbeitsspeicher zu halten.
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)
}
Führen Sie es in einem zweiten Terminal im Verzeichnis gcs-upload aus. Ersetzen
Sie dabei den Dateipfad und kopieren Sie den vollständigen Objektnamen von der Seite:
BUCKET='your-existing-private-bucket' node verify.mjs './large-file.bin' 'uploads/owner/your-object-id' 7200
Für Dateien bis 10 GiB beträgt das Standardzeitbudget 7.200 Sekunden (zwei Stunden) für
die gesamte Überprüfung: lokale Hash-Berechnung, Abruf der Zugangsdaten und OAuth-Aktualisierung,
Metadaten sowie den vollständigen Download. Setzen Sie SECONDS auf einen Wert
von 1 bis 86.400, wenn Ihre Verbindung ein anderes Zeitbudget benötigt. Allein ein Download von
10 GiB benötigt etwa 12 Mbit/s, um innerhalb von zwei Stunden abzuschließen. Das SDK hat hier kein
zusätzliches Timeout für den gesamten Antwortkörper.
Nach Ablauf des Zeitbudgets endet der Prozess mit Status 124 und gibt kein verifiziertes Ergebnis
aus. Prüfen Sie Zugriffsfehler separat und führen Sie die Überprüfung anschließend mit einem größeren
Zeitbudget erneut aus. Eine längere Frist behebt weder ungültige Zugangsdaten noch abweichende Bytes.
Das Prüfskript liest das gespeicherte Objekt und lädt es weder erneut hoch noch verändert es dieses.
Fortschritt über das Neuladen hinaus speichern
Die Datei und die Sitzung bleiben im Speicher dieser Seite. Beim Pausieren bleiben beide erhalten. Neuladen oder die Auswahl einer anderen Datei startet eine neue Sitzung. Eine unvollständige Sitzung läuft ab, ohne ein abgeschlossenes Objekt zu veröffentlichen. Dieses Beispiel stellt Sitzungen nach dem Neuladen nicht wieder her.
Eine Mehrbenutzeranwendung bräuchte eine authentifizierte serverseitige Zuordnung von Sitzungen zu ihren Eigentümern und eine Möglichkeit zu prüfen, ob eine erneut ausgewählte Datei denselben Inhalt hat. Ein Name, eine Größe oder eine gespeicherte Fortschrittsprozentzahl reicht nicht aus.
Bewährte Verfahren für Sicherheit und Leistung
Das Token identifiziert einen einzelnen vertrauenswürdigen Eigentümer. Lassen Sie den Server an die Loopback-Adresse gebunden. Dieses Beispiel bietet weder ein Anmeldesystem für mehrere Benutzer noch Kontingente. Sitzungs-URLs sind Upload-Zugangsdaten. Halten Sie sie daher aus Logs, dem Browserspeicher und gemeinsam geteilten Screenshots heraus. Google-Zugangsdaten bleiben in Node.js.
Der Browser sendet jeweils einen Chunk von 8 MiB und meldet Fortschritt erst nach einer Bestätigung. Pausieren bricht die aktuelle Anfrage und die Wartezeit vor einem erneuten Versuch ab. Beim Pausieren können einige Bytes bereits gespeichert sein. Der nächste Versuch fragt GCS nach dem angenommenen Offset. Leere Dateien und Dateien über 10 GiB werden abgelehnt. Bei einem endgültigen Sitzungsfehler müssen Sie die Datei erneut auswählen, um von vorn zu beginnen. Die Vorbedingung, dass nur neue Objekte erstellt werden dürfen, verhindert das Ersetzen eines vorhandenen Objekts, selbst wenn ein Zielname bereits vergeben ist.
Eine umfangreichere Oberfläche zur Dateiauswahl bietet Uppy. Auch seine Integration benötigt weiterhin einen Upload-Transport, der zu Ihrer Anwendung passt.
