client
WIPUpload client configuration, plugins, hooks, and the createUploadClient entry point.
Overview
The client layer is the public entry point for creating an upload store. It
normalizes config, wires plugins, sets up the transport, and returns a stable
UploadStore.
Creating a Client
createUploadClient is the recommended way to create an upload store:
import { createUploadClient } from "@gentleduck/upload"
const client = createUploadClient({
api: uploadApi,
strategies: strategyRegistry,
config: {
maxConcurrentUploads: 3,
maxAttempts: 3,
progressThrottleMs: 100,
autoStart: ["avatar"],
maxItems: 100,
validation: {
avatar: { maxSizeBytes: 5 * 1024 * 1024, allowedTypes: ["image/*"] },
},
},
})import { createUploadClient } from "@gentleduck/upload"
const client = createUploadClient({
api: uploadApi,
strategies: strategyRegistry,
config: {
maxConcurrentUploads: 3,
maxAttempts: 3,
progressThrottleMs: 100,
autoStart: ["avatar"],
maxItems: 100,
validation: {
avatar: { maxSizeBytes: 5 * 1024 * 1024, allowedTypes: ["image/*"] },
},
},
})createUploadClient is a thin alias for createUploadStore. Both return the same
UploadStore interface.
Configuration
All config fields are optional. resolveUploadConfig() applies defaults at
construction time:
| Option | Type | Default | Description |
|---|---|---|---|
maxConcurrentUploads | number | 3 | Maximum simultaneous uploads |
progressThrottleMs | number | 100 | Minimum interval between progress updates (ms) |
maxAttempts | number | 3 | Maximum retry attempts per upload |
maxItems | number | 100 | Maximum items in state before cleanup |
autoStart | P[] | (purpose: P) => boolean | undefined | Auto-start uploads for matching purposes |
validation | Partial<Record<P, UploadValidationRules>> | {} | Per-purpose validation rules |
retryPolicy | (ctx) => RetryDecision | undefined | Custom retry decision function |
completedItemTTL | number | undefined | Auto-remove completed items after N ms |
Validation Rules
Validation rules are keyed by purpose and run during the addFiles command:
const config = {
validation: {
avatar: {
maxFiles: 1,
maxSizeBytes: 5 * 1024 * 1024,
minSizeBytes: 1024,
allowedTypes: ["image/jpeg", "image/png", "image/webp"],
allowedExtensions: [".jpg", ".jpeg", ".png", ".webp"],
},
document: {
maxSizeBytes: 100 * 1024 * 1024,
allowedTypes: ["application/pdf"],
},
},
}const config = {
validation: {
avatar: {
maxFiles: 1,
maxSizeBytes: 5 * 1024 * 1024,
minSizeBytes: 1024,
allowedTypes: ["image/jpeg", "image/png", "image/webp"],
allowedExtensions: [".jpg", ".jpeg", ".png", ".webp"],
},
document: {
maxSizeBytes: 100 * 1024 * 1024,
allowedTypes: ["application/pdf"],
},
},
}Files that fail validation are rejected with a RejectReason and emitted as
file.rejected events. They never enter the state machine.
Retry Policy
Default behavior retries up to maxAttempts with exponential backoff. Override with a
custom retryPolicy:
const config = {
retryPolicy: ({ phase, attempt, error }) => {
// Never retry auth errors
if (error.code === "auth") return { retryable: false }
// Respect rate limit headers
if (error.code === "rate_limit" && error.retryAfterMs) {
return { retryable: true, delayMs: error.retryAfterMs }
}
// Exponential backoff with cap
const delayMs = Math.min(500 * 2 ** (attempt - 1), 10_000)
return { retryable: true, delayMs }
},
}const config = {
retryPolicy: ({ phase, attempt, error }) => {
// Never retry auth errors
if (error.code === "auth") return { retryable: false }
// Respect rate limit headers
if (error.code === "rate_limit" && error.retryAfterMs) {
return { retryable: true, delayMs: error.retryAfterMs }
}
// Exponential backoff with cap
const delayMs = Math.min(500 * 2 ** (attempt - 1), 10_000)
return { retryable: true, delayMs }
},
}The RetryDecision type is either { retryable: false } or { retryable: true; delayMs: number }.
Store Options
The StoreOptions interface holds everything needed to build the store runtime:
| Option | Required | Description |
|---|---|---|
api | Yes | Backend API implementing UploadApi |
strategies | Yes | Strategy registry with registered strategies |
config | No | Upload configuration (defaults applied) |
transport | No | HTTP transport (defaults to createXHRTransport()) |
persistence | No | Persistence adapter and options |
plugins | No | Array of plugins |
hooks | No | Lifecycle hooks |
fingerprint | No | Custom file fingerprinting function |
validateFile | No | Custom validation function (runs after built-in rules) |
errorNormalizer | No | Custom error normalizer for raw errors |
initialState | No | Pre-hydrated state (e.g., from persistence) |
Plugins
Plugins extend the store's behavior without forking the engine. Each plugin gets a
minimal proxy with on, dispatch, and getSnapshot — enough to observe events and
drive behavior, not enough to break internal state.
import type { UploadPlugin } from "@gentleduck/upload"
const analyticsPlugin: UploadPlugin<MyIntents, MyCursors, Purpose, MyResult> = {
name: "analytics",
setup({ on, getSnapshot }) {
on("upload.completed", ({ localId, result }) => {
const item = getSnapshot().items.get(localId)
trackEvent("upload_completed", {
fileId: result.fileId,
purpose: item?.purpose,
})
})
on("upload.error", ({ localId, error }) => {
trackEvent("upload_error", {
code: error.code,
message: error.message,
})
})
},
}
const client = createUploadClient({
api: uploadApi,
strategies,
plugins: [analyticsPlugin],
})import type { UploadPlugin } from "@gentleduck/upload"
const analyticsPlugin: UploadPlugin<MyIntents, MyCursors, Purpose, MyResult> = {
name: "analytics",
setup({ on, getSnapshot }) {
on("upload.completed", ({ localId, result }) => {
const item = getSnapshot().items.get(localId)
trackEvent("upload_completed", {
fileId: result.fileId,
purpose: item?.purpose,
})
})
on("upload.error", ({ localId, error }) => {
trackEvent("upload_error", {
code: error.code,
message: error.message,
})
})
},
}
const client = createUploadClient({
api: uploadApi,
strategies,
plugins: [analyticsPlugin],
})Plugin Guidelines
- Plugins should be read-only — don't mutate state directly.
- Use
dispatchonly for well-defined side effects (e.g. auto-retry). - Plugin
setuperrors are caught and logged in development mode. - Plugin names are for debugging — keep them descriptive.
Hooks
Hooks are a lower-level observation point than plugins. onInternalEvent fires after
each internal event, giving full fidelity into engine behavior:
const client = createUploadClient({
api: uploadApi,
strategies,
hooks: {
onInternalEvent(event, state) {
console.log("[upload]", event.type, event)
},
},
})const client = createUploadClient({
api: uploadApi,
strategies,
hooks: {
onInternalEvent(event, state) {
console.log("[upload]", event.type, event)
},
},
})Custom Fingerprinting
The engine fingerprints files using name, size, type, and lastModified by default. Provide a custom function for stronger identity (e.g. a checksum):
const client = createUploadClient({
api: uploadApi,
strategies,
fingerprint: (file) => ({
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
checksum: computeSHA256(file), // must be synchronous
}),
})const client = createUploadClient({
api: uploadApi,
strategies,
fingerprint: (file) => ({
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
checksum: computeSHA256(file), // must be synchronous
}),
})The fingerprint function must be synchronous to keep addFiles fast.
Next Steps
- Engine — state machine, scheduling, and retry details.
- Contracts — the UploadApi, transport, and strategy interfaces.
- Persistence — configure state persistence.