Chapter 7: Validation & Plugins
WIPReject bad files before they upload, and extend the engine with plugins and hooks.
Goal
Stop wasting bandwidth on files that shouldn't upload, and extend the engine without forking it.
You'll configure per-purpose validation rules, add a custom validateFile, and write
plugins and hooks.
Two validation layers exist, and the distinction matters:
- Built-in rules (size, type, extension,
maxFiles) run synchronously inaddFiles. Failures emitfile.rejectedand the file never enters state — listen via events. validateFileand strict MIME run after the file has entered (validatingphase). Failures move the item to theerrorphase withcode: 'validation_failed'.
Configure validation
Per-purpose rules
Rules live in config.validation, keyed by purpose, typed as Contracts.ValidationRules.
First, grow the purpose union:
type PhotoPurpose = 'photo' | 'avatar' | 'document'
export const store = createUploadStore<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>({
api,
strategies,
config: {
validation: {
photo: {
maxFiles: 20,
maxSizeBytes: 10 * 1024 * 1024,
allowedTypes: ['image/*'],
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'heic'], // no leading dot
},
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'],
},
},
},
})
Contracts.ValidationRules:
type ValidationRules = {
maxFiles?: number
maxSizeBytes?: number
minSizeBytes?: number
allowedTypes?: string[] // MIME; 'image/*' wildcard prefix supported
allowedExtensions?: string[] // extensions without a dot, case-insensitive
}
When both allowedTypes and allowedExtensions are set, a file passes if it matches
either.
Show built-in rejections
Built-in failures don't enter state, so listen for file.rejected. The reason is a
Contracts.Validation.Rejection:
type Rejection =
| { 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 }
| { code: 'mime_mismatch'; claimed: string; sniffed: string }
| { code: 'filename_rejected'; reason: 'reserved' | 'too-long' | 'empty' | 'path-sep' }
import { useEffect } from 'react'
import { useUploaderActions } from '@gentleduck/upload/react'
import type { Contracts } from '@gentleduck/upload/core'
function describe(reason: Contracts.Validation.Rejection): string {
switch (reason.code) {
case 'empty_file': return 'File is empty.'
case 'file_too_large': return `Too large (${reason.size} B). Max ${reason.maxBytes} B.`
case 'type_not_allowed': return `Type not allowed. Accepted: ${reason.allowed.join(', ')}.`
case 'too_many_files': return `Too many files. Max ${reason.max}.`
case 'mime_mismatch': return `Content looks like ${reason.sniffed}, not ${reason.claimed}.`
case 'filename_rejected': return `Filename rejected (${reason.reason}).`
}
}
export function RejectionToaster() {
const { on } = useUploaderActions<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
useEffect(() => on('file.rejected', ({ file, reason }) =>
console.warn(file.name, describe(reason))
), [on])
return null
}
Custom validation with validateFile
validateFile runs after built-in rules, once the file is in state. Return a
Contracts.Validation.Rejection to reject (moves the item to error), or null to accept:
createUploadStore({
api,
strategies,
config: { /* … */ },
validateFile: (file, purpose) => {
// Block suspicious executable double-extensions.
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
if (['exe', 'bat', 'sh', 'cmd', 'msi'].includes(ext)) {
return { code: 'type_not_allowed', allowed: [], got: file.name }
}
return null
},
})
Items rejected here sit in error with error.code === 'validation_failed' and
retryable: false. Read them from state:
const { items } = useUploader<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
const invalid = items.filter((i) => i.phase === 'error' && i.error.code === 'validation_failed')
Write a plugin
A plugin is an Engine.Plugin — a name plus setup receiving { on, off, dispatch, getSnapshot }:
import type { Engine } from '@gentleduck/upload/core'
export const analyticsPlugin: Engine.Plugin<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult> = {
name: 'analytics',
setup({ on }) {
on('upload.completed', ({ result, completedBy }) =>
track('upload_completed', { fileId: result.fileId, completedBy }))
on('upload.error', ({ error, retryable }) =>
track('upload_error', { code: error.code, retryable }))
},
}
setup gets:
| Method | Purpose |
|---|---|
on / off | Subscribe/unsubscribe to events |
dispatch | Send commands |
getSnapshot | Read current state |
| </Step> |
Register plugins and hooks
import { analyticsPlugin } from './plugins/analytics'
createUploadStore({
api,
strategies,
plugins: [analyticsPlugin],
hooks: {
onInternalEvent: (event, state) => {
if (event.type === 'validation.failed') console.debug('[upload]', event)
},
},
})
| Plugins | Hooks | |
|---|---|---|
| Subscribe to events | on | onInternalEvent |
| Dispatch commands | Yes | No |
| Read state | getSnapshot | receives state |
| Count | array | single object |
| Use for | extend behavior | observe / debug |
A practical plugin — auto-retry on rate limits using the server's suggested delay:
import type { Engine } from '@gentleduck/upload/core'
import { UploadRateLimitError } from '@gentleduck/upload/core'
export const rateLimitRetry: Engine.Plugin<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult> = {
name: 'rate-limit-retry',
setup({ on, dispatch }) {
on('upload.error', ({ localId, error, retryable }) => {
if (error.code === 'rate_limit' && retryable) {
const delay = error instanceof UploadRateLimitError ? error.retryAfterMs ?? 5000 : 5000
setTimeout(() => dispatch({ type: 'retry', localId }), delay)
}
})
},
}
How the validation phase works
- Built-in batch (
addFiles):maxFiles, then per-file size/type/extension. Failures →file.rejected, never enter state. - Checksum + dedupe: if the fingerprint has a checksum and
findByChecksumis implemented, a match completes the item viadedupe.ok(completedBy: 'dedupe'). validateFile: your custom check.- Strict MIME: when
strictMimeMatch: true, magic bytes are sniffed and mismatches rejected. - Steps 3–4 failures →
error(validation_failed). Otherwise →creating_intent.
maxFiles counts existing non-canceled items for the same purpose. Use remove to free slots.
MIME matching
| Pattern | Matches | Not |
|---|---|---|
image/jpeg | image/jpeg | image/png |
image/* | image/jpeg, image/png | video/mp4 |
image/* is a prefix match (type.startsWith('image/')), not a glob.