Skip to main content

PUT Strategy

WIP

Single-request presigned HTTP PUT — the S3 putObject / signed-PUT path.

PutStrategy sends the whole file as the body of one presigned HTTP PUT request. It is the most common direct-to-storage pattern — S3 putObject, GCS and Azure signed PUT — and the lightest way to hand bytes to object storage. It is not resumable: a failure restarts from zero (but transient network errors are retried automatically).

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

strategies.set(PutStrategy({ allowedHosts: ['uploads.example.com'] }))

Configuration

PutStrategy({
  allowedHosts?: string[]        // lock the PUT host (recommended)
  allowPrivateHosts?: boolean    // allow loopback/RFC1918 hosts (default false)
  maxRetries?: number            // transient-failure retries (default 3)
})

When allowedHosts is omitted the strategy warns once and every host is allowed. Set it to your storage host(s) to close the SSRF surface — see Security.

Intent shape

Your createIntent returns PutStrategy.Intent:

type Intent = {
  strategy: 'put'
  fileId: string
  url: string                       // presigned PUT URL
  headers?: Record<string, string>  // signature-required headers (e.g. Content-Type)
  expiresAt?: string                // optional expiry hint
}

Any headers your signature covers (commonly Content-Type) go in headers and are sent verbatim on the PUT.

Cursor shape

Nothing to resume, so the cursor is empty:

type Cursor = Record<string, never>
type Cursors = { put?: PutStrategy.Cursor }

How it runs

start() validates the URL, then PUTs the file body through ctx.transport.put, streaming progress. The call is wrapped in the shared retry helper, so transient network failures (timeouts, 5xx, ECONNRESET) back off and retry up to maxRetries:

async start(ctx) {
  await ctx.transport.put({
    url: ctx.intent.url,
    body: ctx.file,
    headers: ctx.intent.headers ?? {},
    signal: ctx.signal,
    onProgress: (uploadedBytes, totalBytes) => ctx.reportProgress({ uploadedBytes, totalBytes }),
  })
}

If the intent is missing its url, the strategy throws a validation error.

Security

The presigned URL is backend-supplied and flows straight to the transport, so a compromised backend or a MITM could return a file:, javascript:, or private-network URL. Every URL runs through the shared validateUploadUrl SSRF guard before any byte leaves the client. Lock it down with allowedHosts, and keep allowPrivateHosts at its default false to block loopback, link-local, RFC1918, and cloud-metadata addresses.

When to use

  • Direct-to-storage uploads where the backend can presign a single PUT.
  • Small to medium files that don't need part-level resume.
  • The simplest S3 / GCS / Azure object-put path.

For large files or flaky connections, reach for the Multipart Strategy (concurrent parts, per-part resume) or the tus Strategy (offset-based resume). A common pattern is to register several and let createIntent pick per file by size.