chapter 2: strategies & backends
WIPConfigure upload strategies and connect to a real backend API.
Goal
By the end of this chapter you will understand how upload strategies work, connect the POST strategy to a real presigned URL backend, and see how the strategy registry makes the engine pluggable.
Step by Step
Understand what a strategy does
A strategy is a function that knows how to transfer bytes from the browser to storage. The engine does not know anything about HTTP, S3, or presigned URLs -- that is the strategy's job.
Every strategy implements the UploadStrategy interface:
interface UploadStrategy<M, C, P, R, K> {
id: K // strategy name (e.g., 'post', 'multipart')
resumable: boolean // can this strategy resume after pause?
start(ctx: StrategyCtx<M, C, P, R, K>): Promise<void> // do the upload
}interface UploadStrategy<M, C, P, R, K> {
id: K // strategy name (e.g., 'post', 'multipart')
resumable: boolean // can this strategy resume after pause?
start(ctx: StrategyCtx<M, C, P, R, K>): Promise<void> // do the upload
}The start method receives a context (StrategyCtx) with everything it needs:
| Field | Type | Description |
|---|---|---|
ctx.file | File | The file to upload |
ctx.intent | M[K] | Intent data from your backend (URLs, fields, etc.) |
ctx.signal | AbortSignal | For cancellation and pause |
ctx.transport | UploadTransport | Network layer (XHR) for making requests |
ctx.api | UploadApi | Your backend API adapter |
ctx.reportProgress | (p) => void | Report upload progress to the engine |
ctx.readCursor | () => C[K] | Read resume state (for resumable strategies) |
ctx.persistCursor | (cursor) => void | Save resume state |
When the engine is ready to upload a file, it looks up the strategy by the strategy field
in the intent, and calls start().
Register the POST strategy
You already did this in Chapter 1. Let us look at it more carefully:
import { createStrategyRegistry, PostStrategy } from '@gentleduck/upload'
const strategies = createStrategyRegistry<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>()
strategies.set(PostStrategy<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>())import { createStrategyRegistry, PostStrategy } from '@gentleduck/upload'
const strategies = createStrategyRegistry<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>()
strategies.set(PostStrategy<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>())createStrategyRegistry() creates a typed registry. The registry is a simple map from
strategy ID to strategy implementation:
// Internally:
interface StrategyRegistry<M, C, P, R> {
get<K extends keyof M & string>(id: K): UploadStrategy<M, C, P, R, K> | undefined
has(id: string): id is keyof M & string
set<K extends keyof M & string>(strategy: UploadStrategy<M, C, P, R, K>): void
}// Internally:
interface StrategyRegistry<M, C, P, R> {
get<K extends keyof M & string>(id: K): UploadStrategy<M, C, P, R, K> | undefined
has(id: string): id is keyof M & string
set<K extends keyof M & string>(strategy: UploadStrategy<M, C, P, R, K>): void
}When the engine receives an intent with strategy: 'post', it calls strategies.get('post')
to find the implementation. If no strategy is registered for that ID, the upload fails with
a strategy_missing error.
Implement UploadApi for a real backend
In Chapter 1 we used a mock API. Now let us connect to a real backend that serves presigned POST URLs. Your backend needs two endpoints:
const api: UploadApi<PhotoIntentMap, PhotoPurpose, PhotoResult> = {
async createIntent({ purpose, contentType, size, filename }) {
const res = await fetch('/api/uploads/create-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ purpose, contentType, size, filename }),
})
if (!res.ok) throw new Error(`Failed to create intent: ${res.status}`)
// Your backend returns a PostIntent shape:
// { strategy: 'post', fileId: '...', url: '...', fields: { ... } }
return res.json()
},
async complete({ fileId }) {
const res = await fetch('/api/uploads/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId }),
})
if (!res.ok) throw new Error(`Failed to complete upload: ${res.status}`)
// Your backend returns: { fileId: '...', key: '...', url: '...' }
return res.json()
},
}const api: UploadApi<PhotoIntentMap, PhotoPurpose, PhotoResult> = {
async createIntent({ purpose, contentType, size, filename }) {
const res = await fetch('/api/uploads/create-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ purpose, contentType, size, filename }),
})
if (!res.ok) throw new Error(`Failed to create intent: ${res.status}`)
// Your backend returns a PostIntent shape:
// { strategy: 'post', fileId: '...', url: '...', fields: { ... } }
return res.json()
},
async complete({ fileId }) {
const res = await fetch('/api/uploads/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId }),
})
if (!res.ok) throw new Error(`Failed to complete upload: ${res.status}`)
// Your backend returns: { fileId: '...', key: '...', url: '...' }
return res.json()
},
}Your backend's create-intent endpoint should:
- Generate a unique
fileId - Create a presigned POST to S3/MinIO
- Return a
PostIntentobject with the presigned URL and form fields
Example backend response:
{
"strategy": "post",
"fileId": "abc-123",
"url": "https://my-bucket.s3.us-east-1.amazonaws.com",
"fields": {
"key": "uploads/abc-123/photo.jpg",
"bucket": "my-bucket",
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": "...",
"X-Amz-Date": "...",
"Policy": "...",
"X-Amz-Signature": "..."
},
"expiresAt": "2026-03-11T12:00:00Z"
}{
"strategy": "post",
"fileId": "abc-123",
"url": "https://my-bucket.s3.us-east-1.amazonaws.com",
"fields": {
"key": "uploads/abc-123/photo.jpg",
"bucket": "my-bucket",
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
"X-Amz-Credential": "...",
"X-Amz-Date": "...",
"Policy": "...",
"X-Amz-Signature": "..."
},
"expiresAt": "2026-03-11T12:00:00Z"
}Configure the client
Pass the real API and strategy to the client:
import {
createUploadClient,
createStrategyRegistry,
PostStrategy,
createXHRTransport,
} from '@gentleduck/upload'
const strategies = createStrategyRegistry<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>()
strategies.set(PostStrategy<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>())
export const uploadClient = createUploadClient<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>({
api,
strategies,
transport: createXHRTransport(),
config: {
maxConcurrentUploads: 3,
autoStart: ['photo'],
},
})import {
createUploadClient,
createStrategyRegistry,
PostStrategy,
createXHRTransport,
} from '@gentleduck/upload'
const strategies = createStrategyRegistry<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>()
strategies.set(PostStrategy<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>())
export const uploadClient = createUploadClient<PhotoIntentMap, PhotoCursorMap, PhotoPurpose, PhotoResult>({
api,
strategies,
transport: createXHRTransport(),
config: {
maxConcurrentUploads: 3,
autoStart: ['photo'],
},
})With autoStart: ['photo'], files with purpose 'photo' will start uploading automatically
after the intent is created -- no need to call dispatch({ type: 'startAll' }).
Upload a file
import { uploadClient } from './upload'
// With autoStart enabled, just adding files is enough
const input = document.querySelector<HTMLInputElement>('#file-input')!
input.addEventListener('change', () => {
const files = Array.from(input.files ?? [])
if (files.length > 0) {
uploadClient.dispatch({ type: 'addFiles', files, purpose: 'photo' })
}
})
// Track progress
uploadClient.on('upload.progress', ({ localId, pct }) => {
console.log(`${localId}: ${pct.toFixed(1)}%`)
})
uploadClient.on('upload.completed', ({ localId, result }) => {
console.log(`${localId}: done!`, result.url)
})import { uploadClient } from './upload'
// With autoStart enabled, just adding files is enough
const input = document.querySelector<HTMLInputElement>('#file-input')!
input.addEventListener('change', () => {
const files = Array.from(input.files ?? [])
if (files.length > 0) {
uploadClient.dispatch({ type: 'addFiles', files, purpose: 'photo' })
}
})
// Track progress
uploadClient.on('upload.progress', ({ localId, pct }) => {
console.log(`${localId}: ${pct.toFixed(1)}%`)
})
uploadClient.on('upload.completed', ({ localId, result }) => {
console.log(`${localId}: done!`, result.url)
})The flow is:
- User selects files
addFilesdispatched -- engine creates items, validates, callscreateIntent()- Backend returns
PostIntentwith presigned URL and fields autoStarttriggersstart-- engine looks upPostStrategyin the registryPostStrategy.start()builds aFormDatawith the presigned fields and the file- XHR transport POSTs the form to S3, reporting progress along the way
- On success, engine calls
api.complete()to finalize
How the POST Strategy Works Internally
The built-in PostStrategy is straightforward. Here is what happens inside start():
// Simplified from source
async start(ctx) {
const intent = ctx.intent // PostIntent { url, fields, fileId }
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 })
},
})
}// Simplified from source
async start(ctx) {
const intent = ctx.intent // PostIntent { url, fields, fileId }
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 })
},
})
}The transport's postForm method:
- Creates a
FormDataobject - Appends all presigned fields (key, policy, signature, etc.)
- Appends the file as the last field (required by S3)
- Sends the form via XHR POST to the presigned URL
- Reports progress via
xhr.upload.onprogress
The POST strategy sets resumable: false because a presigned POST is a single atomic request.
If it fails midway, you must start over. For resumable uploads, see Chapter 4 (multipart).
The PostIntent Type
The PostIntent type defines what your backend must return for the POST strategy:
type PostIntent = {
strategy: 'post' // discriminant -- must be 'post'
fileId: string // backend file identifier
url: string // presigned POST URL (form action)
fields: Record<string, string> // form fields (presigned policy, signature, etc.)
expiresAt?: string // optional expiration timestamp
}type PostIntent = {
strategy: 'post' // discriminant -- must be 'post'
fileId: string // backend file identifier
url: string // presigned POST URL (form action)
fields: Record<string, string> // form fields (presigned policy, signature, etc.)
expiresAt?: string // optional expiration timestamp
}The strategy field is the discriminant that the engine uses to look up the correct strategy
in the registry. It must match the strategy's id exactly.
The Transport Layer
The UploadTransport interface abstracts network operations:
interface UploadTransport {
put(args: {
url: string
body: Blob
headers?: Record<string, string>
signal: AbortSignal
onProgress?: (uploaded: number, total: number) => void
}): Promise<{ etag?: string; headers?: Record<string, string> }>
postForm(args: {
url: string
fields: Record<string, string>
file: File | Blob
filename?: string
signal: AbortSignal
onProgress?: (uploadedBytes: number, totalBytes: number) => void
}): Promise<{ etag?: string; headers?: Record<string, string> }>
patch(args: {
url: string
body: Blob | ArrayBuffer
headers?: Record<string, string>
signal: AbortSignal
onProgress?: (uploaded: number, total: number) => void
}): Promise<{ headers?: Record<string, string> }>
}interface UploadTransport {
put(args: {
url: string
body: Blob
headers?: Record<string, string>
signal: AbortSignal
onProgress?: (uploaded: number, total: number) => void
}): Promise<{ etag?: string; headers?: Record<string, string> }>
postForm(args: {
url: string
fields: Record<string, string>
file: File | Blob
filename?: string
signal: AbortSignal
onProgress?: (uploadedBytes: number, totalBytes: number) => void
}): Promise<{ etag?: string; headers?: Record<string, string> }>
patch(args: {
url: string
body: Blob | ArrayBuffer
headers?: Record<string, string>
signal: AbortSignal
onProgress?: (uploaded: number, total: number) => void
}): Promise<{ headers?: Record<string, string> }>
}createXHRTransport() returns the browser-native implementation. The transport is injected
into strategies via the context, so strategies never create their own HTTP requests. This makes
strategies testable -- you can provide a mock transport in tests.
Writing a Custom Strategy
You can write your own strategy for any upload protocol. Here is a minimal example:
import type { UploadStrategy } from '@gentleduck/upload'
type MyIntent = {
strategy: 'my-custom'
fileId: string
uploadUrl: string
token: string
}
type MyCursor = {
bytesUploaded: number
}
function myCustomStrategy(): UploadStrategy<
{ 'my-custom': MyIntent },
{ 'my-custom': MyCursor },
string,
UploadResultBase,
'my-custom'
> {
return {
id: 'my-custom',
resumable: true,
async start(ctx) {
const cursor = ctx.readCursor()
const offset = cursor?.bytesUploaded ?? 0
const blob = ctx.file.slice(offset)
await ctx.transport.put({
url: ctx.intent.uploadUrl,
body: blob,
headers: { Authorization: `Bearer ${ctx.intent.token}` },
signal: ctx.signal,
onProgress(uploaded, total) {
ctx.reportProgress({
uploadedBytes: offset + uploaded,
totalBytes: ctx.file.size,
})
},
})
ctx.persistCursor({ bytesUploaded: ctx.file.size })
},
}
}import type { UploadStrategy } from '@gentleduck/upload'
type MyIntent = {
strategy: 'my-custom'
fileId: string
uploadUrl: string
token: string
}
type MyCursor = {
bytesUploaded: number
}
function myCustomStrategy(): UploadStrategy<
{ 'my-custom': MyIntent },
{ 'my-custom': MyCursor },
string,
UploadResultBase,
'my-custom'
> {
return {
id: 'my-custom',
resumable: true,
async start(ctx) {
const cursor = ctx.readCursor()
const offset = cursor?.bytesUploaded ?? 0
const blob = ctx.file.slice(offset)
await ctx.transport.put({
url: ctx.intent.uploadUrl,
body: blob,
headers: { Authorization: `Bearer ${ctx.intent.token}` },
signal: ctx.signal,
onProgress(uploaded, total) {
ctx.reportProgress({
uploadedBytes: offset + uploaded,
totalBytes: ctx.file.size,
})
},
})
ctx.persistCursor({ bytesUploaded: ctx.file.size })
},
}
}Register it alongside the built-in strategies:
strategies.set(myCustomStrategy())strategies.set(myCustomStrategy())Checkpoint
Your project should look like this:
photoduck/
src/
upload.ts -- types + real api + client with POST strategy
main.ts -- file input + event listeners
package.json
tsconfig.json