## 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.

## 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>`:

```typescript
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:

| Field | Description |
| --- | --- |
| `ctx.file` | The `File` to upload |
| `ctx.intent` | The intent your backend returned (`M[K]`) |
| `ctx.signal` | Abort signal for pause/cancel |
| `ctx.transport` | Injected network layer (`Transport.Options`) |
| `ctx.api` | Your backend adapter |
| `ctx.reportProgress` | Report `{ uploadedBytes, totalBytes }` |
| `ctx.readCursor` / `ctx.persistCursor` | Read/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**

```typescript title="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:

```typescript
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.

```typescript title="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`:

```json
{
  "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**

```typescript title="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**

```typescript title="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.Intent` → `autoStart` 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:

```typescript
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:

```typescript
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

Full `src/upload.ts`

```typescript
import type { Contracts } from '@gentleduck/upload/core'
import { createUploadStore } from '@gentleduck/upload/core'
import { PostStrategy, createStrategyRegistry } from '@gentleduck/upload/strategies'

type PhotoIntents = { post: PostStrategy.Intent }
type PhotoCursors = { post?: PostStrategy.Cursor }
type PhotoPurpose = 'photo'
type PhotoResult = Contracts.Result.Base & { url: string }

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()
  },
  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()
  },
}

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

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

***

## Chapter 2 FAQ

Why are strategies pluggable?

Different backends need different protocols (S3 POST, S3 multipart, TUS, custom). Keeping
them pluggable makes the engine protocol-agnostic, keeps your bundle to just the strategies
you use, and lets you add custom ones without forking.

Can I register several strategies?

Yes. Register as many as you need; the engine picks per file based on the intent's
`strategy` field. Small files can use `post`, large files `multipart` — decided in your
backend's `createIntent`.

What if the backend returns an unregistered strategy?

The item moves to `error` with a `strategy_missing` error naming the id. Register every
strategy your backend can return.

How do I test without a real backend?

Mock `Contracts.Api.Me` to return fixed intents/results, and pass a mock `Transport.Options`
as `transport`. Both are injected, so no monkey-patching. Chapter 8 covers this in depth.

What if the presigned URL expires?

The S3 request fails (often 403) and the item moves to `error`. Make it retryable in your
`retryPolicy`; on retry the engine re-runs `createIntent` for a fresh URL. Chapter 5 covers
retries.

***

Next: [Chapter 3: React Integration](/duck-upload/course/chapter-3)