Skip to main content

Chapter 2: Strategies & Backends

WIP

Understand strategies, wire the POST strategy to a real presigned backend, and write your own.

Goal

Understand what a strategy is, connect the POST strategy to a real presigned-URL backend, and see how the registry keeps the engine pluggable. You'll also write a minimal custom strategy.

Loading diagram...

Step by step

What a strategy is

A strategy transfers bytes; the engine never touches HTTP directly. Every strategy is a Contracts.Strategy.Me<M, C, P, R, K>:

type Me<M, C, P, R, K> = {
  id: K              // must equal the intent's `strategy` value
  resumable: boolean // can it resume after a pause?
  start(ctx): Promise<void>
}

start receives a Contracts.Strategy.Ctx with everything it needs:

FieldDescription
ctx.fileThe File to upload
ctx.intentThe intent your backend returned (M[K])
ctx.signalAbort signal for pause/cancel
ctx.transportInjected network layer (Transport.Options)
ctx.apiYour backend adapter
ctx.reportProgressReport { uploadedBytes, totalBytes }
ctx.readCursor / ctx.persistCursorRead/save resume state

When it's time to upload, the engine reads the intent's strategy field, finds the matching strategy in the registry, and calls start().

Register the POST strategy

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

const strategies = createStrategyRegistry<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
strategies.set(PostStrategy())

The registry is a typed map from strategy id to implementation:

type Registry<M, C, P, R> = {
  get<K>(id: K): Contracts.Strategy.Me<M, C, P, R, K> | undefined
  has(id: string): id is keyof M & string
  set<K>(strategy: Contracts.Strategy.Me<M, C, P, R, K>): void
}

An intent with strategy: 'post' resolves via registry.get('post'). If nothing is registered for that id, the item fails with a strategy_missing error.

Implement a real presigned backend

Swap the Chapter 1 mock for real endpoints. Remember the second argument is ctx — use ctx.signal to make requests cancelable.

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}`)
    return res.json() // a PostStrategy.Intent
  },
  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()
  },
}

Your create-intent endpoint generates a fileId, builds a presigned S3 POST, and returns a PostStrategy.Intent:

{
  "strategy": "post",
  "fileId": "abc-123",
  "url": "https://my-bucket.s3.us-east-1.amazonaws.com",
  "fields": {
    "key": "uploads/abc-123/photo.jpg",
    "Policy": "...",
    "X-Amz-Signature": "..."
  },
  "expiresAt": "2026-08-01T12:00:00Z"
}

Build the store with autoStart

src/upload.ts
export const store = createUploadStore<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>({
  api,
  strategies,
  config: {
    maxConcurrentUploads: 3,
    autoStart: ['photo'],
  },
})

With autoStart: ['photo'], photos upload as soon as their intent is created — no manual startAll.

Upload from an input

src/main.ts
import { store } from './upload'

const input = document.querySelector<HTMLInputElement>('#file-input')!
input.addEventListener('change', () => {
  const files = Array.from(input.files ?? [])
  if (files.length) store.dispatch({ type: 'addFiles', files, purpose: 'photo' })
})

store.on('upload.progress', ({ localId, pct }) => console.log(`${localId}: ${pct.toFixed(1)}%`))
store.on('upload.completed', ({ localId, result }) => console.log(`${localId} done`, result.url))

The flow: user picks files → addFiles validates and calls createIntent → backend returns a PostStrategy.IntentautoStart queues it → PostStrategy.start posts the form to S3 with progress → engine calls complete.

Inside the POST strategy

start() is small — it hands the file and presigned fields to the transport:

async start(ctx) {
  const intent = ctx.intent // PostStrategy.Intent
  if (!intent.url) throw new UploadEngineError('validation_failed', { message: 'intent missing url' })

  await ctx.transport.postForm({
    url: intent.url,
    file: ctx.file,
    fields: intent.fields,
    filename: ctx.file.name,
    signal: ctx.signal,
    onProgress: (uploadedBytes, totalBytes) => ctx.reportProgress({ uploadedBytes, totalBytes }),
  })
}

postForm builds a FormData (fields first, file last — required by S3), POSTs via XHR, and reports progress. POST is resumable: false: a presigned POST is one atomic request; a failure restarts. For resumable transfers, see Chapter 4.

The transport layer

Transport.Options abstracts network calls (put, postForm, patch). createXHRTransport() is the browser implementation; the store installs it when you omit transport. Because the transport is injected into ctx, strategies never build their own requests — which makes them trivial to test with a mock transport.

Writing a custom strategy

Any protocol fits. Here's a minimal presigned-PUT strategy:

import type { Contracts } from '@gentleduck/upload/core'

type MyIntent = Contracts.Intent.Base<'my-put'> & { uploadUrl: string; token: string }
type MyCursor = { bytesUploaded: number }

function myPutStrategy<
  M extends Contracts.Intent.Map & { 'my-put': MyIntent },
  C extends Contracts.Cursor.Map<M> & { 'my-put'?: MyCursor },
  P extends string,
  R extends Contracts.Result.Base,
>(): Contracts.Strategy.Me<M, C, P, R, 'my-put'> {
  return {
    id: 'my-put',
    resumable: true,
    async start(ctx) {
      const offset = ctx.readCursor()?.bytesUploaded ?? 0
      await ctx.transport.put({
        url: ctx.intent.uploadUrl,
        body: ctx.file.slice(offset),
        headers: { authorization: `Bearer ${ctx.intent.token}` },
        signal: ctx.signal,
        onProgress: (uploaded) =>
          ctx.reportProgress({ uploadedBytes: offset + uploaded, totalBytes: ctx.file.size }),
      })
      ctx.persistCursor({ bytesUploaded: ctx.file.size } as C['my-put'])
    },
  }
}

strategies.set(myPutStrategy())

Checkpoint


Chapter 2 FAQ


Next: Chapter 3: React Integration