Skip to main content

Introduction

WIP

A modular, strategy-based file upload engine with React bindings.

What is Duck Upload?

Duck Upload (@gentleduck/upload) is a framework-agnostic engine for moving files from a browser into object storage. It owns the hard parts of uploading — a lifecycle state machine, concurrency scheduling, retries with backoff, pause/resume, deduplication, and crash-safe persistence — and leaves the transport protocol to pluggable strategies.

The mental model is close to a data-fetching library like TanStack Query: a small typed core holds all the state and does the scheduling, adapters plug into the edges, and a thin React binding reads the core. Nothing about React leaks into the engine, so the same store runs in Node, a worker, Vue, or plain JavaScript.

The four type parameters

Every public API is generic over the same four parameters. Learn them once and the rest of the library reads naturally:

ParamNameWhat it describes
MIntent mapEvery strategy your backend can pick, keyed by strategy id, mapped to the intent shape it returns
CCursor mapPer-strategy resume state, keyed by the same ids (optional per key)
PPurposeA string union categorizing uploads ('avatar' | 'attachment')
RResultThe typed payload your backend returns from complete()

P drives per-purpose validation, autoStart, and the purpose filter on batch commands. R flows end-to-end, so item.result is fully typed with no casts.

Package layout

@gentleduck/upload            # everything (core + react + strategies)
@gentleduck/upload/core       # engine, contracts, persistence, errors, transport
@gentleduck/upload/react      # <UploadProvider> + hooks
@gentleduck/upload/strategies # PostStrategy, multipartStrategy, registry helper

Architecture at a glance

Loading diagram...

Quick start

The whole setup is: describe the types, implement the backend contract, register strategies, create the store.

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

// 1. Describe the pipeline.
type Intents = { post: PostStrategy.Intent; multipart: MultipartStrategy.Intent }
type Cursors = { post?: PostStrategy.Cursor; multipart?: MultipartStrategy.Cursor }
type Purpose = 'avatar' | 'attachment'
type Result = Contracts.Result.Base & { url: string }

// 2. Implement your backend contract.
const api: Contracts.Api.Me<Intents, Purpose, Result> = {
  async createIntent({ purpose, contentType, size, filename }, ctx) {
    const res = await fetch('/api/uploads/intent', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ purpose, contentType, size, filename }),
      signal: ctx.signal,
    })
    if (!res.ok) throw new Error(`intent failed: ${res.status}`)
    return res.json()
  },
  async complete({ fileId, filename, contentType, size }, ctx) {
    const res = await fetch(`/api/uploads/${fileId}/complete`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ filename, contentType, size }),
      signal: ctx.signal,
    })
    if (!res.ok) throw new Error(`complete failed: ${res.status}`)
    return res.json()
  },
}

// 3. Register strategies (the registry starts empty; add with .set()).
const strategies = createStrategyRegistry<Intents, Cursors, Purpose, Result>()
strategies.set(PostStrategy())
strategies.set(multipartStrategy({ maxPartConcurrency: 4 }))

// 4. Build the store.
export const store = createUploadStore<Intents, Cursors, Purpose, Result>({
  api,
  strategies,
  config: { autoStart: ['avatar'] },
})

Drive it

Everything happens through dispatch. Read state with getSnapshot, listen with on.

const file = new File(['…'], 'photo.jpg', { type: 'image/jpeg' })
store.dispatch({ type: 'addFiles', files: [file], purpose: 'attachment' })

const unsub = store.on('upload.completed', ({ localId, result }) => {
  console.log('done', localId, result.url)
})

store.dispatch({ type: 'startAll' })

Await results

waitFor resolves once the given items reach a terminal phase:

const ids = Array.from(store.getSnapshot().items.keys())
for (const outcome of await store.waitFor(ids)) {
  if (outcome.status === 'completed') {
    console.log(outcome.result.fileId, outcome.result.key)
  }
}

Design goals

  • One source of truth. All mutations run through a reducer; snapshots are immutable so useSyncExternalStore never tears.
  • Strategies stay at the edge. The engine picks a strategy by the strategy field on the intent and never imports strategy code.
  • Typed from backend to UI. R reaches item.result and the upload.completed event without a cast.
  • Types live next to their module. No mega types folder; each concern owns its shapes.

Where to go next