Multipart Strategy
WIPS3/MinIO-style chunked uploads with concurrent parts, per-part retry, and resume.
multipartStrategy slices a file into parts, signs each part on demand, PUTs them concurrently,
and finalizes with completeMultipart. It is resumable: completed part ETags are stored in the
cursor and skipped on resume.
import { multipartStrategy, MultipartStrategy } from '@gentleduck/upload/strategies'
strategies.set(multipartStrategy({ maxPartConcurrency: 4 }))
Config
MultipartStrategy.Config:
| Option | Default | Description |
|---|---|---|
maxPartConcurrency | 4 | Max parts uploaded at once (clamped to ≥ 1) |
allowedHosts | — | Case-insensitive host allow-list for signed part URLs |
allowPrivateHosts | false | Allow private/loopback IPs in signed URLs |
Every signed part URL is validated before use: it must be http/https, must not contain ..,
must match allowedHosts when set, and (unless allowPrivateHosts is true) must not point at
a private, loopback, link-local, or cloud-metadata address. If you leave allowedHosts unset,
the strategy warns once that signed URLs are host-unrestricted.
Intent shape
Your createIntent returns MultipartStrategy.Intent for large files:
type Intent = {
strategy: 'multipart'
fileId: string
uploadId: string // S3/GCS multipart session id
partSize: number // bytes per part (S3 minimum is 5 MB, except the last)
partCount: number // total parts
parts?: Array<{ // optional: all URLs pre-signed up front
partNumber: number
url: string
headers?: Record<string, string>
}>
}
Cursor shape
MultipartStrategy.Cursor is what persists between runs:
type Cursor = {
done: Array<{ partNumber: number; etag: string; size: number }>
completed?: true // set after completeMultipart, so resume won't re-finalize
}
In the cursor map you register the raw cursor type; the engine wraps it as
{ strategy: 'multipart', value } when it surfaces through events and persistence:
type Cursors = { multipart?: MultipartStrategy.Cursor }
Required backend methods
The strategy calls two methods on api.multipart:
const api: Contracts.Api.Me<Intents, Purpose, Result> = {
createIntent, complete,
multipart: {
async signPart({ fileId, uploadId, partNumber, attempt }, ctx) {
const res = await fetch('/api/uploads/sign-part', {
method: 'POST',
body: JSON.stringify({ fileId, uploadId, partNumber }),
signal: ctx.signal,
})
return res.json() // { url: string; headers?: Record<string, string> }
},
async completeMultipart({ fileId, uploadId, parts }, ctx) {
await fetch('/api/uploads/complete-multipart', {
method: 'POST',
body: JSON.stringify({ fileId, uploadId, parts }), // parts: { partNumber, etag }[]
signal: ctx.signal,
})
},
// Optional: listParts, abort
},
}
If intent.parts is present, the strategy uses those URLs directly and skips signPart (the
legacy pre-sign mode). On-demand signing is preferred: URLs stay fresh and only the parts you
actually need get signed.
How it runs
- Build the queue from
partCount, skipping parts already in the cursor. - Upload concurrently, up to
maxPartConcurrency, signing each part on demand. - Collect ETags. Each PUT must return an
ETag; expose it via CORS (Access-Control-Expose-Headers: ETag) or the strategy throws. - Persist the cursor after every completed part.
- Finalize with
completeMultipart, then mark the cursorcompleted: true. - Retry transient part failures (network/timeout/5xx) up to 3 times with exponential backoff (500 ms, 1 s, 2 s) before failing the item.
Progress is finishedBytes + inflightBytes over the total, so it stays smooth even with parts
in flight.
Pause & resume
resumable is true. Pausing aborts in-flight PUTs; the cursor already holds every completed
part. On resume the strategy reads the cursor, skips done parts, and continues. If the cursor is
marked completed, it skips completeMultipart too, avoiding double assembly.
When to use
- Files larger than the POST strategy's practical limit.
- Unreliable connections where losing one part beats losing the whole file.
- High-bandwidth links where concurrent parts improve throughput.