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). 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.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, 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/isIntentand stripped of prototype-polluting keys on the way in.