Chapter 1: Your First Upload
WIPCreate the store, add a file, watch it move through the phase machine.
Goal
Stand up a working upload pipeline: define the four type parameters, implement a mock backend
contract, build the store with createUploadStore, add a file, and watch it flow through the
phases while progress and completion events fire.
Step by step
Install
npm install @gentleduck/upload
npm install @gentleduck/upload
One package: core engine, React bindings, and built-in strategies.
Define the four type parameters
Create src/upload.ts. Describe the pipeline with an intent map (M), cursor map (C),
purpose (P), and result (R).
import type { Contracts } from '@gentleduck/upload/core'
import type { PostStrategy } from '@gentleduck/upload/strategies'
// M — which strategies exist and the intent each returns
type PhotoIntents = { post: PostStrategy.Intent }
// C — resume state per strategy (POST has none)
type PhotoCursors = { post?: PostStrategy.Cursor }
// P — the kinds of uploads in the app
type PhotoPurpose = 'photo'
// R — what your backend returns from complete()
type PhotoResult = Contracts.Result.Base & { url: string }
Contracts.Result.Base is { fileId: string; key: string }. PostStrategy.Intent and
PostStrategy.Cursor come from the strategy's type namespace (the same PostStrategy you
call as a function).
Implement the backend contract
The contract type is Contracts.Api.Me<M, P, R>. Two methods are required. Each takes
call args plus a context ctx (which carries ctx.signal, ctx.file, and more). We mock
them for now.
const api: Contracts.Api.Me<PhotoIntents, PhotoPurpose, PhotoResult> = {
async createIntent({ purpose, contentType, size, filename }, ctx) {
// Real apps call the backend for a presigned POST here.
console.log(`intent for ${filename} (${size} bytes, ${contentType})`)
return {
strategy: 'post',
fileId: `file-${Date.now()}`,
url: 'https://your-bucket.s3.amazonaws.com',
fields: { key: `uploads/${filename}`, 'Content-Type': contentType },
}
},
async complete({ fileId }, ctx) {
console.log(`completing ${fileId}`)
return { fileId, key: `uploads/${fileId}`, url: `https://cdn.example.com/${fileId}` }
},
}
createIntent runs when a file enters the pipeline: your backend picks the strategy and
returns its intent. complete runs after the bytes land, to finalize and return the result.
Register a strategy and build the store
import { createUploadStore } from '@gentleduck/upload/core'
import { PostStrategy, createStrategyRegistry } from '@gentleduck/upload/strategies'
const strategies = createStrategyRegistry<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
strategies.set(PostStrategy())
export const store = createUploadStore<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>({
api,
strategies,
})
createStrategyRegistry() returns an empty registry; .set(PostStrategy()) adds the POST
strategy. Omitting transport installs the XHR transport automatically.
Add a file and start
import { store } from './upload'
store.on('upload.progress', ({ pct, uploadedBytes, totalBytes }) => {
console.log(`progress: ${pct.toFixed(1)}% (${uploadedBytes}/${totalBytes})`)
})
store.on('upload.completed', ({ localId, result }) => {
console.log(`completed ${localId}`, result)
})
store.on('upload.error', ({ localId, error }) => {
console.log(`failed ${localId}: ${error.message}`)
})
const file = new File(['hello world'], 'photo.jpg', { type: 'image/jpeg' })
store.dispatch({ type: 'addFiles', files: [file], purpose: 'photo' })
store.dispatch({ type: 'startAll' })
dispatch is the single entry point for commands. addFiles registers files; startAll
queues every ready item.
The phase machine
Each item flows through a sequence of phases that say what the engine is doing:
| Phase | Meaning |
|---|---|
validating | Checking against per-purpose rules |
creating_intent | Calling api.createIntent() |
ready | Intent received; waiting for start/autoStart |
queued | Wants a slot (concurrency cap reached) |
uploading | Transferring bytes |
completing | Calling api.complete() |
completed | Done and finalized |
error | Failed (possibly retryable) |
paused / canceled | Paused (resumable) / canceled |
On addFiles the engine assigns a localId, computes a fingerprint, validates, then calls
createIntent and lands in ready. On startAll, ready → queued → (scheduler) →
uploading → completing → completed.
Commands
Every action goes through dispatch. The command type is Engine.Command<P>:
| Command | Effect |
|---|---|
{ type: 'addFiles', files, purpose, meta? } | Register files |
{ type: 'start', localId } / { type: 'startAll', purpose? } | Begin upload(s) |
{ type: 'pause', localId } / { type: 'pauseAll', purpose? } | Pause |
{ type: 'resume', localId } | Resume a paused item |
{ type: 'cancel', localId } / { type: 'cancelAll', purpose? } | Cancel |
{ type: 'retry', localId } | Retry a failed item |
{ type: 'rebind', localId, file } | Re-attach a File after persistence restore |
{ type: 'remove', localId } | Drop an item from state |
Events
Subscribe with store.on(name, cb). It returns an unsubscribe function:
const unsub = store.on('upload.progress', ({ pct }) => console.log(pct))
unsub()
Common events: file.added, file.rejected, intent.creating, intent.created,
upload.started, upload.progress, upload.paused, upload.canceled, upload.completing,
upload.completed, upload.error.
Reading state
getSnapshot() returns immutable state with items: Map<string, Engine.Item>. Because
Engine.Item is a discriminated union over phase, narrowing unlocks phase-specific fields:
for (const item of store.getSnapshot().items.values()) {
if (item.phase === 'uploading') console.log(item.progress.pct)
if (item.phase === 'error') console.log(item.error.message, item.retryable)
}
Awaiting an upload
waitFor resolves once items reach a terminal phase:
store.dispatch({ type: 'addFiles', files: [file], purpose: 'photo' })
const localId = Array.from(store.getSnapshot().items.keys())[0]!
store.dispatch({ type: 'start', localId })
const [outcome] = await store.waitFor([localId])
if (outcome.status === 'completed') console.log('done', outcome.result)
else if (outcome.status === 'error') console.log('failed', outcome.error)
Checkpoint
photoduck/
src/
upload.ts -- types + api + store
main.ts -- dispatch + event listeners