Design Decisions
WIPThe 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.completereturnsR.- Internal and public completion events carry
R. upload.completedemits it;completeditems keep it onitem.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/isIntentand stripped of prototype-polluting keys on the way in.