Biblioteka frontendowa TypeScript do wysyłania dużych plików w częściach (chunkach) z raportowaniem postępu, obsługą anulowania oraz weryfikacją integralności danych po stronie serwera.
- Upload plików w częściach (
chunked upload) - Raportowanie postępu (
onprogress) - Obsługa anulowania (
abort) - Automatyczne obliczanie i weryfikacja sumy kontrolnej (
SHA-256domyślnie) - Obsługa throttlingu eventów postępu (limit czasowy i objętościowy)
- Integracja z backendowymi endpointami
uploadifinish
Wewnątrz projektu korzystającego z bibliotek Craftserve dodaj do .npmrc:
@craftserve:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
Następnie zainstaluj paczkę:
npm install @craftserve/ts-chunked-uploader
# lub
yarn add @craftserve/ts-chunked-uploaderimport { UploaderClient } from "@craftserve/ts-chunked-uploader";
const uploader = new UploaderClient({
endpoints: {
upload: "/api/uploads/{upload_id}/chunk",
finish: "/api/uploads/{upload_id}/finish",
},
headers: {
Authorization: "Bearer token",
},
});
uploader.onprogress((state) => {
console.log("Progress:", state.uploaded, "/", state.total, state.state);
});
const file = document.querySelector("input[type=file]")!.files![0];
await uploader.upload(file, 5 * 1024 * 1024); // wysyłaj w chunkach po 5 MBsetTimeout(() => uploader.abort(), 5000);Biblioteka nie definiuje endpointów ani ich nie tworzy — jedynie wywołuje dwa
ścieżki podane w endpoints. Backend musi zachowywać się następująco:
- Metoda:
PUT - URL: wzorzec z
{upload_id}, np./api/uploads/{upload_id}/chunk.{upload_id}to base64url (RFC 4648 §5, bez padding=) ze skrótu SHA‑256 całego pliku, doklejony.i 16 znaków losowego base64url — ten sufiks daje każdemu wywołaniuupload()własną ścieżkę tymczasową, nawet gdy dwa uploady niosą identyczną zawartość. Cały ten identyfikator jest tylko wartością dla URL‑a, nie wartością do porównywania (patrzfinishponiżej). Klient zawsze dokleja query?create=1— do każdego chunka, nie tylko do pierwszego. Parametroverwritejest ignorowany. URL jest budowany raz, przed pętlą po chunkach, więc backend musi traktowaćcreate=1idempotentnie („utwórz, jeśli nie istnieje"). Gdyby wymuszał odrzucenie przy istniejącym pliku, każdy upload większy niż jeden chunk padłby na drugim chunku — a4xxjest klasyfikowane jako błąd nieretryowalny. Zachowanie jest przypięte testemcreate=1 query parameter › is sent on EVERY chunk. - Nagłówki:
Range: bytes=<start>-<end-1>— tylko gdy plik jest dzielony na wiele chunków. Dla single‑chunk (size = -1albosize >= file.size) nagłówekRangenie jest wysyłany.Content-Type— typ pliku alboapplication/octet-stream.- Dowolne nagłówki dodatkowe z
config.headers(np.Authorization).
- Body: surowe bajty chunka (
Blob), bezmultipart/form-data. - Odpowiedź: dowolne
2xxtraktowane jest jako sukces.4xxto błąd nieretryowalny (klient propaguje wyjątek),5xxi błąd sieci są retryowane zgodnie zmaxChunkRetries/chunkRetryDelayMs.
-
Metoda:
GET -
URL: wzorzec z
{upload_id}, np./api/uploads/{upload_id}/finish. -
Odpowiedź:
200 OKz ciałem JSON o kształcieFinishResponse:interface FinishResponse { hash: string; // skrót pliku po stronie serwera w **standard base64** // (alfabet z '+/='). Opcjonalny prefiks algorytmu jest tolerowany, // np. "sha-256=…" length: number; // liczba zapisanych bajtów; MUSI równać się file.size // ─ wartość 0 jest poprawna dla pustego pliku }
Każdy inny status traktowany jest jako błąd (
Failed to finish upload). Klient porównujehashz lokalnie wyliczonym SHA‑256 w standard base64 (po stripowaniu prefiksualg=) orazlengthzfile.size; rozbieżność → wyjątekChecksum mismatch/length mismatch.
Uwaga o alfabetach. Klient celowo używa dwóch kodowań tej samej wartości SHA‑256:
- base64url w segmencie URL‑a
{upload_id}— bezpieczne ścieżkowo, bez+,/,=, z dołączonym losowym sufiksem. Ta wartość jest zwracana przezupload().- standard base64 w polu
hashzfinish(kontrakt z daemonem) — wartość porównywana lokalnie; przekazywana doonFinalize(jeśli skonfigurowane).
Jeśli skonfigurowane, jest wywoływane po udanej weryfikacji finish,
z standard‑base64 SHA‑256 pliku jako argumentem (wartością z finish,
nie z base64url używanym w URL ani zwracanym przez upload()). Wyjątek z
callbacka zatrzymuje upload i jest propagowany jako Failed to upload file: ….
| Parametr | Typ | Domyślnie | Opis |
|---|---|---|---|
endpoints.upload |
string |
— | URL endpointu do wysyłki chunków, np. /api/upload/{upload_id} |
endpoints.finish |
string |
— | URL do weryfikacji i zakończenia uploadu |
headers |
Record<string, string> |
— | Dodatkowe nagłówki (np. Authorization) |
alg |
string |
sha-256 |
Algorytm haszujący |
progressReportIntervalMs |
number |
1000 |
Minimalny odstęp czasu między raportami postępu (ms) |
progressReportBytes |
number |
1000000 |
Minimalna liczba bajtów między raportami postępu |
hashStreamingThresholdBytes |
number |
67108864 |
Powyżej tego rozmiaru suma kontrolna liczona strumieniowo; 0 = zawsze WebCrypto |
hashSliceBytes |
number |
8388608 |
Ile pliku jest w pamięci naraz przy hashowaniu strumieniowym |
maxChunkRetries |
number |
10 |
Liczba prób na chunk (pierwsza próba się liczy; 1 = bez retry) |
maxFinishRetries |
number |
3 |
Liczba prób wywołania finish |
chunkRetryDelayMs |
number |
10000 |
Odstęp między próbami (anulowalny przez abort) |
stallTimeoutMs |
number |
60000 |
Budżet bezruchu przy wysyłce chunka; 0 wyłącza |
responseTimeoutMs |
number |
300000 |
Budżet oczekiwania na odpowiedź serwera; 0 wyłącza |
onChunkRetry |
(info) => void |
— | Hook przed każdą ponowną próbą (info.phase: chunk/finish) |
onFinalize |
(sha256) => Promise<void> |
— | Callback po udanej weryfikacji finish |
XHR sam z siebie czeka w nieskończoność, więc zerwane („half-open")
połączenie potrafiło zawiesić upload na zawsze: onerror nie leci, więc
pętla retry jest nieosiągalna, a await nigdy się nie kończy. Klient pilnuje
tego dwoma niezależnymi budżetami:
stallTimeoutMs— budżet bezruchu. Uzbrajany przedsend()i przezbrajany przy każdym ruchu bajtów (upload.onprogress). Jest więc niezależny od przepustowości: chunk 25 MiB pełznący po łączu 1 Mbit/s bez przerwy go resetuje i nigdy nie zostanie ubity — łapane jest wyłącznie połączenie, na którym nic się nie dzieje.responseTimeoutMs— budżet odpowiedzi. Uzbrajany, gdy ciało żądania trafiło już do transportu (upload.loadend); od tego momentu nie ma więcej zdarzeń postępu, które mogłyby świadczyć o życiu połączenia. Domyślne 5 minut jest celowo hojne:upload.onprogressraportuje bajty oddane do bufora gniazda, a nie potwierdzone przez serwer, więc mały chunk potrafi pokazać 100% będąc wciąż w locie. Chodzi o ograniczenie połączenia, które nigdy nie odpowie — nie o egzekwowanie SLO. Zaciskaj dopiero mając telemetrię.
Przekroczenie któregokolwiek budżetu jest raportowane jako ChunkUploadError
z kind: "timeout" i retryable: true — czyli jako coś innego niż
anulowanie przez użytkownika (kind: "abort", retryable: false).
crypto.subtle.digest jest jednorazowe — potrzebuje całej wiadomości w
pamięci naraz. Dla uploadu wielogigabajtowego oznacza to file.arrayBuffer()
wielkości pliku, czyli OOM karty przeglądarki dokładnie na tych plikach,
których użytkownik najmniej chce stracić (i mnożnik, gdy kiedyś ruszy
równoległe wysyłanie kilku plików naraz).
W WebCrypto nie ma strumieniowego digestu, więc klient ma dwie ścieżki:
- do
hashStreamingThresholdBytes—crypto.subtle.digestna całym pliku. Stoi za tym BoringSSL i jest szybsze niż jakakolwiek implementacja w JS, więc to domyślna droga; - powyżej — inkrementalny SHA-256 karmiony kawałkami po
hashSliceBytes, dzięki czemu rezydentny jest tylko jeden kawałek.
Obie ścieżki dają identyczny digest — próg to kompromis
pamięć/szybkość, nigdy poprawność. Pilnuje tego sha256.test.ts,
porównując implementację inkrementalną z platformową na losowych wejściach,
każdej granicy paddingu i każdym sposobie pocięcia wiadomości; a
UploaderClient.hashing.test.ts sprawdza, że część hashowa upload_id nie
zależy od tego, która ścieżka się wykonała.
Hashowanie wielogigabajtowego pliku trwa, więc pętla strumieniowa sprawdza
abort między kawałkami — wcześniej anulowanie nie mogło się przebić, dopóki
cały plik nie został wczytany.
Każda porażka to ChunkUploadError z maszynowo czytelnym kind, statusem
HTTP i jawnym werdyktem retryable:
import { ChunkUploadError } from "@craftserve/ts-chunked-uploader";
try {
await uploader.upload(file, 25 * 1024 * 1024);
} catch (err) {
if (err instanceof ChunkUploadError) {
console.log(err.kind); // "http" | "network" | "timeout" | "abort" | "unknown"
console.log(err.status); // 507
console.log(err.retryable); // false
console.log(err.detail); // treść błędu z daemona (przycięta)
console.log(err.uploadId); // identyfikator ścieżki tymczasowej albo undefined
}
}uploadId jest ustawiony na każdym błędzie zgłoszonym po wyliczeniu
identyfikatora (błąd chunka, przerwanie, błąd finish) — pozwala powiązać
błąd z konkretną ścieżką tymczasową do sprzątnięcia. Jest nieobecny, gdy
błąd wystąpił podczas liczenia sumy kontrolnej, zanim identyfikator powstał.
Reguła retry: 5xx tak, 4xx nie — z czterema świadomymi wyjątkami.
507 Insufficient Storage i 501 Not Implemented nie są ponawiane
(pełny wolumen sam się nie opróżni; 507 ponawiane 10× co 10 s zamieniało
natychmiastowe „brak miejsca" w 90-sekundową zwiechę zakończoną tym samym
błędem). 408 i 429 są ponawiane, bo dokładnie o to proszą.
finish (GET, idempotentny) jest ponawiany przy błędach przejściowych, ale
niezgodność sumy kontrolnej lub długości nie jest — to twarde stwierdzenie
o bajtach na dysku, nie czkawka. onFinalize nie jest ponawiany: wykonuje
przeniesienie pliku, które nie jest idempotentne, a ponowienie po utraconej
odpowiedzi zgłosiłoby fałszywą porażkę dla pliku, który wylądował poprawnie.
{
"name": "@craftserve/ts-chunked-uploader",
"version": "1.0.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com/"
}
}npm login --registry=https://npm.pkg.github.com
# lub ustaw w .npmrc tokennpm run build
npm publishUtwórz .github/workflows/publish.yml:
name: Publish @craftserve/ts-chunked-uploader
on:
push:
tags:
- "v*.*.*"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://npm.pkg.github.com"
- run: npm ci
- run: npm run build
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}-
Aby opublikować nową wersję, zwiększ wersję w
package.jsoni dodaj tag:npm version patch git push origin main --tags
-
Każdy tag
vX.Y.Zautomatycznie wywoła publikację (jeśli używasz workflowa powyżej). -
W przypadku błędów „unauthorized” upewnij się, że masz poprawne uprawnienia
write:packages.