Skip to main content

Chapter 7: Validation & Plugins

WIP

Reject 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.

Loading diagram...

Two validation layers exist, and the distinction matters:

  • Built-in rules (size, type, extension, maxFiles) run synchronously in addFiles. Failures emit file.rejected and the file never enters state — listen via events.
  • validateFile and strict MIME run after the file has entered (validating phase). Failures move the item to the error phase with code: 'validation_failed'.

Configure validation

Per-purpose rules

Rules live in config.validation, keyed by purpose, typed as Contracts.ValidationRules. First, grow the purpose union:

src/upload.ts
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' }
src/RejectionToaster.tsx
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:

src/upload.ts
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 }:

src/plugins/analytics.ts
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:

MethodPurpose
on / offSubscribe/unsubscribe to events
dispatchSend commands
getSnapshotRead current state
</Step>

Register plugins and hooks

src/upload.ts
import { analyticsPlugin } from './plugins/analytics'

createUploadStore({
  api,
  strategies,
  plugins: [analyticsPlugin],
  hooks: {
    onInternalEvent: (event, state) => {
      if (event.type === 'validation.failed') console.debug('[upload]', event)
    },
  },
})
PluginsHooks
Subscribe to eventsononInternalEvent
Dispatch commandsYesNo
Read stategetSnapshotreceives state
Countarraysingle object
Use forextend behaviorobserve / debug

A practical plugin — auto-retry on rate limits using the server's suggested delay:

src/plugins/rate-limit-retry.ts
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

  1. Built-in batch (addFiles): maxFiles, then per-file size/type/extension. Failures → file.rejected, never enter state.
  2. Checksum + dedupe: if the fingerprint has a checksum and findByChecksum is implemented, a match completes the item via dedupe.ok (completedBy: 'dedupe').
  3. validateFile: your custom check.
  4. Strict MIME: when strictMimeMatch: true, magic bytes are sniffed and mismatches rejected.
  5. 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

PatternMatchesNot
image/jpegimage/jpegimage/png
image/*image/jpeg, image/pngvideo/mp4

image/* is a prefix match (type.startsWith('image/')), not a glob.

Checkpoint


Chapter 7 FAQ


Next: Chapter 8: Production Patterns