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). There is 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, because 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, so 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.