Skip to main content

tus Strategy

WIP

Resumable uploads over the tus protocol — HEAD for the offset, chunked PATCH, cursor-persisted resume.

TusStrategy implements resumable uploads over the tus protocol. It is creation-less: your backend creates the tus upload server-side (via the tus creation extension) and returns a ready upload URL in the intent, so the strategy only performs the transfer half of the protocol — a HEAD to read the server's Upload-Offset, then a sequence of PATCH requests. The offset is persisted in the cursor, so a refresh-and-rebind (or a resumed session on a fresh device) continues where it left off.

import { TusStrategy } from '@gentleduck/upload/strategies'

strategies.set(TusStrategy({ allowedHosts: ['tus.example.com'], chunkSize: 8 * 1024 * 1024 }))

Configuration

TusStrategy({
  chunkSize?: number             // PATCH chunk size in bytes (default 8 MiB; intent overrides)
  allowedHosts?: string[]        // lock the tus host (recommended)
  allowPrivateHosts?: boolean    // allow loopback/RFC1918 hosts (default false)
  maxRetries?: number            // transient HEAD/PATCH retries (default 3)
})

Intent shape

Your createIntent returns TusStrategy.Intent with a ready upload URL:

type Intent = {
  strategy: 'tus'
  fileId: string
  url: string                       // ready tus upload URL (backend-created)
  chunkSize?: number                // optional per-upload chunk override
  headers?: Record<string, string>  // optional headers on every request (e.g. auth)
}

Because creation happens on the backend, the client never sends Upload-Length / Upload-Metadata and never parses a Location header — one fewer CORS surface and no presign logic in the browser.

Cursor shape

The cursor is the last server-acknowledged byte offset:

type Cursor = { offset: number }
type Cursors = { tus?: TusStrategy.Cursor }

How it runs

  1. Validate the upload URL through the SSRF guard.
  2. Resolve the offset — start from the persisted cursor, then confirm against the server with HEAD (the server's Upload-Offset is authoritative on resume). If the transport has no head(), the persisted cursor is used as-is.
  3. PATCH in chunks — for each slice, send Upload-Offset, Tus-Resumable: 1.0.0, and Content-Type: application/offset+octet-stream. Trust the server's returned Upload-Offset to advance; persist it after each accepted chunk.
// per chunk
const res = await ctx.transport.patch({
  url,
  body: file.slice(start, end),
  headers: {
    'Tus-Resumable': '1.0.0',
    'Upload-Offset': String(start),
    'Content-Type': 'application/offset+octet-stream',
  },
  signal: ctx.signal,
  onProgress: (loaded) => ctx.reportProgress({ uploadedBytes: start + loaded, totalBytes: total }),
})

Each HEAD/PATCH is wrapped in the shared retry helper (backoff on timeouts, 5xx, ECONNRESET). If the server ever fails to advance Upload-Offset, the strategy bails with an upload_failed error rather than looping forever.

Transport requirement

The tus strategy uses the transport's patch() for chunk uploads and, when present, head() to read the resume offset. Both createXHRTransport() (browser) and createFetchTransport() (Node/SSR/edge) implement them. See Contracts → Transport.

When to use

  • Resumable uploads against a tus-compatible server (tusd, tus-node-server, Uppy Companion, …).
  • Large files on unreliable connections where offset-based resume beats restarting.
  • When you want a single, standardized wire protocol across clients.

For S3-style storage without a tus server, use the Multipart Strategy instead.