Skip to main content

Design Decisions

WIP

The architecture choices behind @gentleduck/upload and why they were made.

A short tour of the decisions that shape the engine. Each one is a tradeoff made in favor of predictability and type safety.

Command in, events out

There is one way to change state — dispatch(command) — and one way to observe it — subscribe for snapshots and on for typed events. Concentrating mutations behind a command pipeline (command → reducer → new state → effects → events) makes the engine testable and race-free, and gives plugins a single place to observe everything.

Typed results end-to-end

The result generic R is threaded through the whole system rather than degrading to unknown:

  • Contracts.Api.Me.complete returns R.
  • Internal and public completion events carry R.
  • upload.completed emits it; completed items keep it on item.result.

So UI code reads item.result.url with no cast — TypeScript already knows the shape.

Dedupe without fake intents

When findByChecksum matches, the engine finishes the item through a dedicated dedupe.ok event and marks it completedBy: 'dedupe'. It does not fabricate a synthetic intent or run a strategy. State stays honest about what actually happened.

Defaults resolved at construction

createUploadStore runs resolveUploadConfig once and installs createXHRTransport() when no transport is given. After construction the runtime always holds a fully-specified config, so no code path has to re-check optional fields.

The core never imports React

The reducer and runtime are React-free. The React binding lives in react/ and only reads the public store interface (subscribe, getSnapshot, dispatch, on). No import cycles, and the same store works in Node, a worker, Vue, or vanilla JS.

Immutable state updates

Every reducer step returns a new state object; nothing mutates the items map in place. This fixed useSyncExternalStore tearing — React can compare snapshots by reference and re-render exactly when something changed. Cleanup and eviction return new state too.

One event-emission layer

State-derived public events are emitted from a single point after transitions, not scattered inside handlers. That removes duplicate emissions and keeps event semantics consistent across the lifecycle.

Strategies stay at the edge

The engine selects a strategy purely by the strategy field on the intent and calls its start. It never imports a concrete strategy. You only ship the strategies you register, and custom protocols slot in without forking the engine.

Security-conscious by default

  • Error messages are static; tainted values live on context, closing an XSS vector (see Errors).
  • The multipart strategy validates every signed part URL — protocol, optional host allow-list, and private/loopback IP rejection — before handing it to the transport.
  • Persisted snapshots are treated as untrusted: guarded by isPurpose/isIntent and stripped of prototype-polluting keys on the way in.