Skip to main content

Chapter 4: Multipart Uploads

WIP

Upload large files in concurrent chunks with the multipart strategy, signPart, and completeMultipart.

Goal

Add the multipart strategy for large files: extend your type maps, register multipartStrategy, implement the signPart/completeMultipart backend methods, and see how concurrent parts and cursors give you resume.

Loading diagram...

Step by step

Extend the type maps

Add multipart to the intent and cursor maps. MultipartStrategy is the type namespace that pairs with the multipartStrategy function.

src/upload.ts
import type { PostStrategy, MultipartStrategy } from '@gentleduck/upload/strategies'

type PhotoIntents = {
  post: PostStrategy.Intent
  multipart: MultipartStrategy.Intent
}
type PhotoCursors = {
  post?: PostStrategy.Cursor
  multipart?: MultipartStrategy.Cursor
}

The intent your backend returns for large files:

// MultipartStrategy.Intent
{
  strategy: 'multipart'
  fileId: string
  uploadId: string   // S3/GCS multipart session id
  partSize: number   // bytes per part (S3 min 5 MB, except the last)
  partCount: number  // total parts
  parts?: Array<{ partNumber: number; url: string; headers?: Record<string, string> }>
}

The resume cursor tracks completed parts:

// MultipartStrategy.Cursor
{
  done: Array<{ partNumber: number; etag: string; size: number }>
  completed?: true // set after completeMultipart, so resume won't re-finalize
}

The cursor map holds the raw cursor type. The engine wraps it as { strategy: 'multipart', value } when it surfaces through events and persistence.

Register multipartStrategy

src/upload.ts
import { PostStrategy, multipartStrategy, createStrategyRegistry } from '@gentleduck/upload/strategies'

const strategies = createStrategyRegistry<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
strategies.set(PostStrategy())
strategies.set(multipartStrategy({ maxPartConcurrency: 4 }))

MultipartStrategy.Config:

OptionDefaultDescription
maxPartConcurrency4Parts uploaded at once
allowedHostsHost allow-list for signed part URLs
allowPrivateHostsfalseAllow private/loopback IPs in signed URLs

Set allowedHosts in production — otherwise the strategy warns once that signed URLs are host-unrestricted.

Implement the multipart backend methods

The strategy calls api.multipart.signPart and api.multipart.completeMultipart (each with args and ctx):

src/upload.ts
const api: Contracts.Api.Me<PhotoIntents, PhotoPurpose, PhotoResult> = {
  async createIntent({ purpose, contentType, size, filename }, ctx) {
    const res = await fetch('/api/uploads/create-intent', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ purpose, contentType, size, filename }),
      signal: ctx.signal,
    })
    if (!res.ok) throw new Error(`create-intent failed: ${res.status}`)
    // Backend chooses: small -> PostStrategy.Intent, large -> MultipartStrategy.Intent
    return res.json()
  },
  async complete({ fileId, filename, contentType, size }, ctx) {
    const res = await fetch('/api/uploads/complete', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ fileId, filename, contentType, size }),
      signal: ctx.signal,
    })
    if (!res.ok) throw new Error(`complete failed: ${res.status}`)
    return res.json()
  },
  multipart: {
    async signPart({ fileId, uploadId, partNumber }, ctx) {
      const res = await fetch('/api/uploads/sign-part', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ fileId, uploadId, partNumber }),
        signal: ctx.signal,
      })
      if (!res.ok) throw new Error(`sign-part ${partNumber} failed: ${res.status}`)
      return res.json() // { url: string; headers?: Record<string, string> }
    },
    async completeMultipart({ fileId, uploadId, parts }, ctx) {
      const res = await fetch('/api/uploads/complete-multipart', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ fileId, uploadId, parts }), // parts: { partNumber, etag }[]
        signal: ctx.signal,
      })
      if (!res.ok) throw new Error(`complete-multipart failed: ${res.status}`)
      return res.json()
    },
  },
}

Per part: the strategy calls signPart for a presigned PUT URL, PUTs the slice, reads the ETag, and after all parts calls completeMultipart with { partNumber, etag }[].

Part size comes from the backend

createIntent returns partSize and partCount:

// 200 MB with 10 MB parts
{ strategy: 'multipart', fileId: 'abc', uploadId: 'upl-xyz', partSize: 10 * 1024 * 1024, partCount: 20 }
File sizePart sizeNotes
< ~100 MBUse POST
100 MB 1 GB10 MBGood balance
1 5 GB50 MBFewer requests
5 GB+100 MBS3 caps at 10,000 parts

S3 requires a 5 MB minimum per part (except the last).

Upload and track progress

From the UI, large files behave like any other upload:

src/main.ts
store.on('upload.progress', ({ localId, pct, uploadedBytes, totalBytes }) => {
  const mb = (b: number) => (b / 1024 / 1024).toFixed(1)
  console.log(`${localId}: ${pct.toFixed(1)}% (${mb(uploadedBytes)}/${mb(totalBytes)} MB)`)
})

// Multipart resume state surfaces as a wrapped cursor.
store.on('upload.cursor', ({ localId, cursor }) => {
  if (cursor.strategy === 'multipart' && cursor.value) {
    console.log(`${localId}: ${cursor.value.done.length} parts done`)
  }
})

Progress aggregates finished-part bytes plus in-flight bytes for one smooth percentage.

How concurrent parts work

Part-level concurrency (maxPartConcurrency) is separate from file-level concurrency (maxConcurrentUploads).

Loading diagram...

  1. Queue parts, skipping those already in cursor.done.
  2. Upload up to maxPartConcurrency at a time; sign each on demand.
  3. Collect ETags — expose them via CORS (Access-Control-Expose-Headers: ETag) or the strategy throws "missing ETag".
  4. Persist the cursor after each part.
  5. Finalize with completeMultipart, then mark the cursor completed.
  6. Retry transient part failures (network/timeout/5xx) up to 3× with backoff (500 ms, 1 s, 2 s).

The legacy parts array

If intent.parts is present, the strategy uses those pre-signed URLs and skips signPart. On-demand signing is preferred: URLs stay fresh, fewer up-front calls, and only the parts you actually need get signed.

Pause and resume

resumable: true. Pausing aborts in-flight PUTs; the cursor already has every completed part. On resume the strategy reads cursor.done, skips those parts, and continues. If the cursor is completed, it also skips completeMultipart — no double assembly.

Checkpoint


Chapter 4 FAQ


Next: Chapter 5: Pause, Resume & Retry