Chapter 2: Strategies & Backends
WIPUnderstand 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.
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:
| 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
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.
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
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
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:
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())