Skip to main content

Chapter 1: Your First Upload

WIP

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

Loading diagram...

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

src/upload.ts
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.

src/upload.ts
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

src/upload.ts
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

src/main.ts
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:

Loading diagram...

PhaseMeaning
validatingChecking against per-purpose rules
creating_intentCalling api.createIntent()
readyIntent received; waiting for start/autoStart
queuedWants a slot (concurrency cap reached)
uploadingTransferring bytes
completingCalling api.complete()
completedDone and finalized
errorFailed (possibly retryable)
paused / canceledPaused (resumable) / canceled

On addFiles the engine assigns a localId, computes a fingerprint, validates, then calls createIntent and lands in ready. On startAll, readyqueued → (scheduler) → uploadingcompletingcompleted.

Commands

Every action goes through dispatch. The command type is Engine.Command<P>:

CommandEffect
{ 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

Chapter 1 FAQ


Next: Chapter 2: Strategies & Backends