chapter 7: validation & plugins
WIPValidate files before upload and extend behavior with plugins.
Goal
PhotoDuck should reject invalid files before wasting bandwidth. A 200 MB video should not
even start uploading when the limit is 10 MB. A .exe file has no place in a photo gallery.
You will configure validation rules per purpose and build plugins that extend the
upload engine without forking it.
Configure Validation Rules
Define per-purpose validation rules
Validation rules are configured in the store's config.validation object, keyed by
purpose. Each purpose can have its own limits:
import { createUploadStore } from '@gentleduck/upload'
type Purpose = 'photo' | 'avatar' | 'document'
const store = createUploadStore<MyIntentMap, MyCursorMap, Purpose, MyUploadResult>({
api: photoDuckApi,
strategies: photoDuckStrategies,
config: {
validation: {
photo: {
maxFiles: 20,
maxSizeBytes: 10 * 1024 * 1024, // 10 MB
allowedTypes: ['image/*'], // any image MIME type
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'heic'],
},
avatar: {
maxFiles: 1,
maxSizeBytes: 2 * 1024 * 1024, // 2 MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
document: {
maxFiles: 10,
maxSizeBytes: 50 * 1024 * 1024, // 50 MB
allowedTypes: ['application/pdf'],
allowedExtensions: ['pdf'],
},
},
},
})import { createUploadStore } from '@gentleduck/upload'
type Purpose = 'photo' | 'avatar' | 'document'
const store = createUploadStore<MyIntentMap, MyCursorMap, Purpose, MyUploadResult>({
api: photoDuckApi,
strategies: photoDuckStrategies,
config: {
validation: {
photo: {
maxFiles: 20,
maxSizeBytes: 10 * 1024 * 1024, // 10 MB
allowedTypes: ['image/*'], // any image MIME type
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'heic'],
},
avatar: {
maxFiles: 1,
maxSizeBytes: 2 * 1024 * 1024, // 2 MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
document: {
maxFiles: 10,
maxSizeBytes: 50 * 1024 * 1024, // 50 MB
allowedTypes: ['application/pdf'],
allowedExtensions: ['pdf'],
},
},
},
})The full UploadValidationRules type:
type UploadValidationRules = {
maxFiles?: number // max items for this purpose
maxSizeBytes?: number // max file size in bytes
minSizeBytes?: number // min file size (rejects empty files)
allowedTypes?: string[] // MIME types (supports wildcards like 'image/*')
allowedExtensions?: string[] // file extensions (without dot)
}type UploadValidationRules = {
maxFiles?: number // max items for this purpose
maxSizeBytes?: number // max file size in bytes
minSizeBytes?: number // min file size (rejects empty files)
allowedTypes?: string[] // MIME types (supports wildcards like 'image/*')
allowedExtensions?: string[] // file extensions (without dot)
}When both allowedTypes and allowedExtensions are specified, a file passes if it
matches either rule (OR logic). When only one is specified, it must match.
Show validation errors in the UI
When a file fails validation, the item transitions to the error phase with
error.code === 'validation_failed' and retryable: false. The rejection reason is
embedded in the error:
import { useUploader } from '@gentleduck/upload/react'
import type { UploadItem, RejectReason } from '@gentleduck/upload'
function formatRejection(reason: RejectReason): string {
switch (reason.code) {
case 'empty_file':
return 'File is empty.'
case 'file_too_large':
return `File is too large (${formatBytes(reason.size)}). Maximum is ${formatBytes(reason.maxBytes)}.`
case 'type_not_allowed':
return `File type not allowed. Accepted: ${reason.allowed.join(', ')}.`
case 'too_many_files':
return `Too many files. Maximum is ${reason.max}.`
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function ValidationErrors() {
const { store } = useUploader()
const snapshot = store.getSnapshot()
const items = Array.from(snapshot.items.values())
const errors = items.filter(
(item) => item.phase === 'error' && item.error.code === 'validation_failed'
)
if (errors.length === 0) return null
return (
<div className="bg-red-50 border border-red-200 rounded p-4">
<h3 className="text-red-700">{errors.length} file(s) rejected</h3>
<ul>
{errors.map((item) => (
<li key={item.localId} className="flex items-center justify-between py-1">
<span>{item.fingerprint.name}</span>
<span className="text-sm text-red-600">
{item.error.code === 'validation_failed' &&
formatRejection(item.error.reason)}
</span>
<button
className="text-xs"
onClick={() => store.dispatch({ type: 'remove', localId: item.localId })}
>
Dismiss
</button>
</li>
))}
</ul>
</div>
)
}import { useUploader } from '@gentleduck/upload/react'
import type { UploadItem, RejectReason } from '@gentleduck/upload'
function formatRejection(reason: RejectReason): string {
switch (reason.code) {
case 'empty_file':
return 'File is empty.'
case 'file_too_large':
return `File is too large (${formatBytes(reason.size)}). Maximum is ${formatBytes(reason.maxBytes)}.`
case 'type_not_allowed':
return `File type not allowed. Accepted: ${reason.allowed.join(', ')}.`
case 'too_many_files':
return `Too many files. Maximum is ${reason.max}.`
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function ValidationErrors() {
const { store } = useUploader()
const snapshot = store.getSnapshot()
const items = Array.from(snapshot.items.values())
const errors = items.filter(
(item) => item.phase === 'error' && item.error.code === 'validation_failed'
)
if (errors.length === 0) return null
return (
<div className="bg-red-50 border border-red-200 rounded p-4">
<h3 className="text-red-700">{errors.length} file(s) rejected</h3>
<ul>
{errors.map((item) => (
<li key={item.localId} className="flex items-center justify-between py-1">
<span>{item.fingerprint.name}</span>
<span className="text-sm text-red-600">
{item.error.code === 'validation_failed' &&
formatRejection(item.error.reason)}
</span>
<button
className="text-xs"
onClick={() => store.dispatch({ type: 'remove', localId: item.localId })}
>
Dismiss
</button>
</li>
))}
</ul>
</div>
)
}You can also listen for rejections via the event emitter:
store.on('file.rejected', ({ file, reason }) => {
console.warn(`Rejected ${file.name}:`, reason)
})
store.on('validation.failed', ({ localId, reason }) => {
console.warn(`Validation failed for ${localId}:`, reason)
})store.on('file.rejected', ({ file, reason }) => {
console.warn(`Rejected ${file.name}:`, reason)
})
store.on('validation.failed', ({ localId, reason }) => {
console.warn(`Validation failed for ${localId}:`, reason)
})Add custom validation with validateFile
For validation logic beyond what the built-in rules support, use the validateFile
callback on the store options. It runs after the built-in rules:
const store = createUploadStore({
api: photoDuckApi,
strategies: photoDuckStrategies,
config: {
validation: {
photo: {
maxSizeBytes: 10 * 1024 * 1024,
allowedTypes: ['image/*'],
},
},
},
// Custom validation: reject duplicate filenames
validateFile: (file, purpose) => {
const snapshot = store.getSnapshot()
const existing = Array.from(snapshot.items.values())
const duplicate = existing.some(
(item) => item.fingerprint.name === file.name && item.purpose === purpose
)
if (duplicate) {
return { code: 'type_not_allowed', allowed: [], got: `duplicate: ${file.name}` }
}
return null // null means valid
},
})const store = createUploadStore({
api: photoDuckApi,
strategies: photoDuckStrategies,
config: {
validation: {
photo: {
maxSizeBytes: 10 * 1024 * 1024,
allowedTypes: ['image/*'],
},
},
},
// Custom validation: reject duplicate filenames
validateFile: (file, purpose) => {
const snapshot = store.getSnapshot()
const existing = Array.from(snapshot.items.values())
const duplicate = existing.some(
(item) => item.fingerprint.name === file.name && item.purpose === purpose
)
if (duplicate) {
return { code: 'type_not_allowed', allowed: [], got: `duplicate: ${file.name}` }
}
return null // null means valid
},
})Return a RejectReason to reject the file, or null to accept it. The RejectReason
type is a union of built-in codes:
type RejectReason =
| { code: 'empty_file' }
| { code: 'file_too_large'; maxBytes: number; size: number }
| { code: 'type_not_allowed'; allowed: string[]; got: string }
| { code: 'too_many_files'; max: number }type RejectReason =
| { code: 'empty_file' }
| { code: 'file_too_large'; maxBytes: number; size: number }
| { code: 'type_not_allowed'; allowed: string[]; got: string }
| { code: 'too_many_files'; max: number }Create a custom plugin
Plugins let you extend the upload engine's behavior without modifying its internals.
A plugin has a name and a setup function that receives the store context:
import type { UploadPlugin } from '@gentleduck/upload'
/**
* Plugin that logs image dimensions when uploads complete.
*/
export function createImageMetadataPlugin<M, C, P extends string, R>(): UploadPlugin<M, C, P, R> {
return {
name: 'image-metadata',
setup({ on, dispatch, getSnapshot }) {
// Listen for completed uploads
on('upload.completed', ({ localId, result }) => {
console.log(`Upload ${localId} completed:`, result)
})
// Listen for errors and track metrics
on('upload.error', ({ localId, error, retryable }) => {
console.error(`Upload ${localId} failed:`, error.code, error.message)
// Send to your analytics service
trackMetric('upload_error', {
code: error.code,
retryable,
})
})
},
}
}import type { UploadPlugin } from '@gentleduck/upload'
/**
* Plugin that logs image dimensions when uploads complete.
*/
export function createImageMetadataPlugin<M, C, P extends string, R>(): UploadPlugin<M, C, P, R> {
return {
name: 'image-metadata',
setup({ on, dispatch, getSnapshot }) {
// Listen for completed uploads
on('upload.completed', ({ localId, result }) => {
console.log(`Upload ${localId} completed:`, result)
})
// Listen for errors and track metrics
on('upload.error', ({ localId, error, retryable }) => {
console.error(`Upload ${localId} failed:`, error.code, error.message)
// Send to your analytics service
trackMetric('upload_error', {
code: error.code,
retryable,
})
})
},
}
}The setup function receives three methods from the store:
| Method | Description |
|---|---|
on(event, callback) | Subscribe to store events |
dispatch(command) | Dispatch commands to the store |
getSnapshot() | Read the current state |
Plugins can observe events and dispatch commands, which is what makes orchestration patterns possible.
Use lifecycle hooks and chain plugins
Register plugins in the store options. They run in order during setup:
import { createUploadStore } from '@gentleduck/upload'
import { createImageMetadataPlugin } from '../plugins/image-metadata'
import { createAnalyticsPlugin } from '../plugins/analytics'
import { createAutoStartPlugin } from '../plugins/auto-start'
const store = createUploadStore({
api: photoDuckApi,
strategies: photoDuckStrategies,
plugins: [
createImageMetadataPlugin(),
createAnalyticsPlugin({ endpoint: '/api/metrics' }),
createAutoStartPlugin(),
],
// Hooks are a lighter alternative for simple observation
hooks: {
onInternalEvent: (event, state) => {
// Called after every internal event is processed
// Useful for devtools, logging, debugging
if (event.type === 'upload.failed') {
console.debug('[upload-engine]', event.type, event.localId, event.error)
}
},
},
})import { createUploadStore } from '@gentleduck/upload'
import { createImageMetadataPlugin } from '../plugins/image-metadata'
import { createAnalyticsPlugin } from '../plugins/analytics'
import { createAutoStartPlugin } from '../plugins/auto-start'
const store = createUploadStore({
api: photoDuckApi,
strategies: photoDuckStrategies,
plugins: [
createImageMetadataPlugin(),
createAnalyticsPlugin({ endpoint: '/api/metrics' }),
createAutoStartPlugin(),
],
// Hooks are a lighter alternative for simple observation
hooks: {
onInternalEvent: (event, state) => {
// Called after every internal event is processed
// Useful for devtools, logging, debugging
if (event.type === 'upload.failed') {
console.debug('[upload-engine]', event.type, event.localId, event.error)
}
},
},
})The difference between plugins and hooks:
| Feature | Plugins | Hooks |
|---|---|---|
| Subscribe to events | Yes (on) | Yes (onInternalEvent) |
| Dispatch commands | Yes (dispatch) | No |
| Read state | Yes (getSnapshot) | Yes (receives state) |
| Multiple | Array of plugins | Single hook object |
| Use case | Extend behavior | Observe / debug |
Here is a practical plugin that auto-retries on rate-limit errors with the server's suggested delay:
import type { UploadPlugin } from '@gentleduck/upload'
export function createRateLimitRetryPlugin<M, C, P extends string, R>(): UploadPlugin<M, C, P, R> {
return {
name: 'rate-limit-retry',
setup({ on, dispatch }) {
on('upload.error', ({ localId, error, retryable }) => {
if (error.code === 'rate_limit' && retryable) {
const delay = 'retryAfterMs' in error
? (error.retryAfterMs as number)
: 5000
setTimeout(() => {
dispatch({ type: 'retry', localId })
}, delay)
}
})
},
}
}import type { UploadPlugin } from '@gentleduck/upload'
export function createRateLimitRetryPlugin<M, C, P extends string, R>(): UploadPlugin<M, C, P, R> {
return {
name: 'rate-limit-retry',
setup({ on, dispatch }) {
on('upload.error', ({ localId, error, retryable }) => {
if (error.code === 'rate_limit' && retryable) {
const delay = 'retryAfterMs' in error
? (error.retryAfterMs as number)
: 5000
setTimeout(() => {
dispatch({ type: 'retry', localId })
}, delay)
}
})
},
}
}How the Validation Phase Works
When you dispatch addFiles, the engine does not start uploading immediately. Each file
goes through a validation pipeline:
- Files added: Each file gets a
localIdand entersvalidatingphase - Fingerprint computed:
name,size,type,lastModifiedare extracted (synchronous) - Deduplication check: If your API implements
findByChecksum()and the file has a checksum, the engine checks for an existing upload. If found, the item jumps straight tocompletedwithcompletedBy: 'dedupe' - Built-in validation:
maxSizeBytes,minSizeBytes,allowedTypes,allowedExtensions,maxFilesare checked against the purpose's rules - Custom validation: Your
validateFilecallback runs if provided - Result: Valid files move to
creating_intent. Invalid files move toerrorwithretryable: false
The maxFiles check counts existing items for the same purpose. If you have 18 photos and
the limit is 20, adding 5 files will accept 2 and reject 3 with { code: 'too_many_files', max: 20 }.
MIME Type Matching
The allowedTypes array supports both exact matches and wildcard prefixes:
| Pattern | Matches | Does Not Match |
|---|---|---|
'image/jpeg' | image/jpeg | image/png, image/webp |
'image/*' | image/jpeg, image/png, image/webp | video/mp4, application/pdf |
'application/pdf' | application/pdf | application/json |
The wildcard image/* strips the /* and checks if the file's MIME type starts with
image/. This is a prefix match, not a glob.
Checkpoint
Full validation and plugin setup for PhotoDuck:
import { createUploadStore } from '@gentleduck/upload'
import { IndexedDBAdapter } from '@gentleduck/upload/persistence/indexeddb'
type Purpose = 'photo' | 'avatar' | 'document'
// Analytics plugin
const analyticsPlugin = {
name: 'analytics',
setup({ on }) {
on('upload.completed', ({ localId, result, completedBy }) => {
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({
event: 'upload_completed',
fileId: result.fileId,
completedBy,
}),
})
})
on('upload.error', ({ localId, error }) => {
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({
event: 'upload_error',
code: error.code,
message: error.message,
}),
})
})
},
}
// Auto-cleanup plugin: remove completed items after 10 seconds
const autoCleanupPlugin = {
name: 'auto-cleanup',
setup({ on, dispatch }) {
on('upload.completed', ({ localId }) => {
setTimeout(() => {
dispatch({ type: 'remove', localId })
}, 10_000)
})
},
}
export const uploadStore = createUploadStore({
api: photoDuckApi,
strategies: photoDuckStrategies,
config: {
maxConcurrentUploads: 3,
maxAttempts: 5,
validation: {
photo: {
maxFiles: 50,
maxSizeBytes: 10 * 1024 * 1024,
allowedTypes: ['image/*'],
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'heic', 'gif'],
},
avatar: {
maxFiles: 1,
maxSizeBytes: 2 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
document: {
maxFiles: 10,
maxSizeBytes: 50 * 1024 * 1024,
allowedTypes: ['application/pdf'],
allowedExtensions: ['pdf'],
},
},
retryPolicy: ({ attempt, error }) => {
if (error.code === 'auth' || error.code === 'validation_failed') {
return { retryable: false }
}
return { retryable: true, delayMs: Math.min(1000 * 2 ** (attempt - 1), 30_000) }
},
},
plugins: [analyticsPlugin, autoCleanupPlugin],
persistence: {
key: 'photoduck-uploads',
version: 1,
adapter: IndexedDBAdapter,
},
validateFile: (file, purpose) => {
// Reject files with suspicious double extensions
const parts = file.name.split('.')
if (parts.length > 2) {
const lastExt = parts[parts.length - 1]?.toLowerCase()
if (['exe', 'bat', 'sh', 'cmd', 'msi'].includes(lastExt ?? '')) {
return { code: 'type_not_allowed', allowed: [], got: file.name }
}
}
return null
},
})import { createUploadStore } from '@gentleduck/upload'
import { IndexedDBAdapter } from '@gentleduck/upload/persistence/indexeddb'
type Purpose = 'photo' | 'avatar' | 'document'
// Analytics plugin
const analyticsPlugin = {
name: 'analytics',
setup({ on }) {
on('upload.completed', ({ localId, result, completedBy }) => {
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({
event: 'upload_completed',
fileId: result.fileId,
completedBy,
}),
})
})
on('upload.error', ({ localId, error }) => {
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({
event: 'upload_error',
code: error.code,
message: error.message,
}),
})
})
},
}
// Auto-cleanup plugin: remove completed items after 10 seconds
const autoCleanupPlugin = {
name: 'auto-cleanup',
setup({ on, dispatch }) {
on('upload.completed', ({ localId }) => {
setTimeout(() => {
dispatch({ type: 'remove', localId })
}, 10_000)
})
},
}
export const uploadStore = createUploadStore({
api: photoDuckApi,
strategies: photoDuckStrategies,
config: {
maxConcurrentUploads: 3,
maxAttempts: 5,
validation: {
photo: {
maxFiles: 50,
maxSizeBytes: 10 * 1024 * 1024,
allowedTypes: ['image/*'],
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'heic', 'gif'],
},
avatar: {
maxFiles: 1,
maxSizeBytes: 2 * 1024 * 1024,
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
document: {
maxFiles: 10,
maxSizeBytes: 50 * 1024 * 1024,
allowedTypes: ['application/pdf'],
allowedExtensions: ['pdf'],
},
},
retryPolicy: ({ attempt, error }) => {
if (error.code === 'auth' || error.code === 'validation_failed') {
return { retryable: false }
}
return { retryable: true, delayMs: Math.min(1000 * 2 ** (attempt - 1), 30_000) }
},
},
plugins: [analyticsPlugin, autoCleanupPlugin],
persistence: {
key: 'photoduck-uploads',
version: 1,
adapter: IndexedDBAdapter,
},
validateFile: (file, purpose) => {
// Reject files with suspicious double extensions
const parts = file.name.split('.')
if (parts.length > 2) {
const lastExt = parts[parts.length - 1]?.toLowerCase()
if (['exe', 'bat', 'sh', 'cmd', 'msi'].includes(lastExt ?? '')) {
return { code: 'type_not_allowed', allowed: [], got: file.name }
}
}
return null
},
})import { useUploader } from '@gentleduck/upload/react'
import type { RejectReason } from '@gentleduck/upload'
function PhotoUploader() {
const { store } = useUploader()
const snapshot = store.getSnapshot()
const items = Array.from(snapshot.items.values())
const rejected = items.filter(
(i) => i.phase === 'error' && i.error.code === 'validation_failed'
)
return (
<div>
<input
type="file"
multiple
accept="image/*"
onChange={(e) => {
const files = Array.from(e.target.files ?? [])
if (files.length > 0) {
store.dispatch({ type: 'addFiles', files, purpose: 'photo' })
}
}}
/>
{rejected.length > 0 && (
<div className="mt-2 p-3 bg-red-50 rounded">
{rejected.map((item) => (
<div key={item.localId} className="flex justify-between text-sm">
<span>{item.fingerprint.name}</span>
<span className="text-red-600">
{item.error.code === 'validation_failed' && item.error.reason.code}
</span>
<button onClick={() => store.dispatch({ type: 'remove', localId: item.localId })}>
Dismiss
</button>
</div>
))}
</div>
)}
{items
.filter((i) => i.phase !== 'error')
.map((item) => (
<div key={item.localId} className="py-1">
{item.fingerprint.name} -- {item.phase}
{'progress' in item && item.progress && (
<span> ({Math.round(item.progress.pct)}%)</span>
)}
</div>
))}
</div>
)
}import { useUploader } from '@gentleduck/upload/react'
import type { RejectReason } from '@gentleduck/upload'
function PhotoUploader() {
const { store } = useUploader()
const snapshot = store.getSnapshot()
const items = Array.from(snapshot.items.values())
const rejected = items.filter(
(i) => i.phase === 'error' && i.error.code === 'validation_failed'
)
return (
<div>
<input
type="file"
multiple
accept="image/*"
onChange={(e) => {
const files = Array.from(e.target.files ?? [])
if (files.length > 0) {
store.dispatch({ type: 'addFiles', files, purpose: 'photo' })
}
}}
/>
{rejected.length > 0 && (
<div className="mt-2 p-3 bg-red-50 rounded">
{rejected.map((item) => (
<div key={item.localId} className="flex justify-between text-sm">
<span>{item.fingerprint.name}</span>
<span className="text-red-600">
{item.error.code === 'validation_failed' && item.error.reason.code}
</span>
<button onClick={() => store.dispatch({ type: 'remove', localId: item.localId })}>
Dismiss
</button>
</div>
))}
</div>
)}
{items
.filter((i) => i.phase !== 'error')
.map((item) => (
<div key={item.localId} className="py-1">
{item.fingerprint.name} -- {item.phase}
{'progress' in item && item.progress && (
<span> ({Math.round(item.progress.pct)}%)</span>
)}
</div>
))}
</div>
)
}