Plugin Authoring
Plugin Authoring Guide
This guide covers everything you need to know to create custom plugins for Nuxt Upload Kit.
Architecture Overview
Nuxt Upload Kit uses a plugin-based architecture with three plugin types:
| Type | Purpose | Example | Define With |
|---|---|---|---|
| Validator | Gate files before adding | Max file size, allowed types | defineProcessingPlugin |
| Processor | Transform files | Thumbnails, compression | defineProcessingPlugin |
| Storage | Handle persistence | S3, Azure, Firebase | defineStorageAdapter |
Plugin Lifecycle
┌─────────────────────────────────────────────────────────────────────┐
│ ADDING FILES │
│ │
│ addFile() ──▶ [validate] ──▶ [preprocess] ──▶ file:added │
│ │ │
│ ▼ (fail) │
│ file:error │
└─────────────────────────────────────────────────────────────────────┘
│
│ upload() called
▼
┌─────────────────────────────────────────────────────────────────────┐
│ UPLOADING │
│ │
│ [process] ──▶ storage.upload ──▶ [complete] ──▶ upload:complete │
│ │ │ │
│ ▼ (fail) ▼ (fail) │
│ file:error file:error │
└─────────────────────────────────────────────────────────────────────┘
Legend: [hooks] are plugin extension points
When Each Hook Runs
| Hook | When | Use For |
|---|---|---|
validate | Immediately on addFile() | Rejecting invalid files |
preprocess | After validation passes | Generating thumbnails, previews |
process | When upload() is called | Compression, transformations |
complete | After successful upload | Post-upload cleanup |
Basic Structure
Use defineProcessingPlugin to create custom validators and file processors.
import { defineProcessingPlugin } from "nuxt-upload-kit"
const MyPlugin = defineProcessingPlugin<PluginOptions>((options) => ({
id: "my-plugin",
hooks: {
// Available hooks: validate, preprocess, process, complete
},
}))
Hook Reference
| Hook | When | Purpose | Return |
|---|---|---|---|
validate | Before adding | Check if file is valid | file or throw error |
preprocess | After validation | Immediate UI prep (thumbnails) | Modified file |
process | Before upload | Transform file (compression) | Modified file |
complete | After upload | Post-upload processing | Modified file |
Validator Example
Create a custom validator that checks filename patterns:
import { defineProcessingPlugin, type FileError } from "nuxt-upload-kit"
interface FilenameValidatorOptions {
pattern: RegExp
message?: string
}
export const ValidatorFilename = defineProcessingPlugin<FilenameValidatorOptions>((options) => ({
id: "validator-filename",
hooks: {
validate: async (file, context) => {
if (!options.pattern.test(file.name)) {
throw {
message: options.message || `Filename must match pattern: ${options.pattern}`,
} satisfies FileError
}
return file
},
},
}))
// Usage
const uploader = useUploadKit({
plugins: [
ValidatorFilename({
pattern: /^[a-z0-9-]+\.[a-z]+$/i,
message: "Filenames can only contain letters, numbers, and hyphens",
}),
],
})
Processor Example
Create a processor that adds watermarks to images:
import { defineProcessingPlugin, type UploadFile } from "nuxt-upload-kit"
interface WatermarkOptions {
text: string
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"
opacity?: number
}
type WatermarkEvents = {
applied: { file: UploadFile; text: string }
skipped: { file: UploadFile; reason: string }
}
export const PluginWatermark = defineProcessingPlugin<WatermarkOptions, WatermarkEvents>((options) => ({
id: "watermark",
hooks: {
process: async (file, context) => {
// Skip non-images
if (!file.mimeType.startsWith("image/")) {
context.emit("skipped", { file, reason: "Not an image" })
return file
}
// Skip remote files
if (file.source !== "local") {
context.emit("skipped", { file, reason: "Remote file" })
return file
}
// Apply watermark
const watermarkedBlob = await applyWatermark(file.data, options)
context.emit("applied", { file, text: options.text })
return {
...file,
data: watermarkedBlob,
size: watermarkedBlob.size,
}
},
},
}))
async function applyWatermark(blob: Blob, options: WatermarkOptions): Promise<Blob> {
// Implementation using Canvas API
const img = await createImageBitmap(blob)
const canvas = document.createElement("canvas")
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext("2d")!
ctx.drawImage(img, 0, 0)
// Add watermark text
ctx.font = "24px Arial"
ctx.fillStyle = `rgba(255, 255, 255, ${options.opacity || 0.5})`
ctx.fillText(options.text, 20, img.height - 20)
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), "image/jpeg", 0.9)
})
}
Plugin Context
Every hook receives a context object with access to:
| Property | Type | Description |
|---|---|---|
files | UploadFile[] | All files in the uploader |
options | UploadOptions | Uploader configuration |
storage | StoragePlugin | undefined | Storage adapter (if configured) |
emit | Function | Emit custom events (see below) |
Accessing Storage
Plugins can upload derivatives (thumbnails, variants, etc.) using context.storage:
export const PluginThumbnailUploader = defineProcessingPlugin<{ upload: boolean }>((options) => ({
id: "thumbnail-uploader",
hooks: {
process: async (file, context) => {
// Check if storage is available
if (!context.storage || !options.upload) {
return file
}
// Upload thumbnail using storage adapter's standalone upload method
if (file.preview?.startsWith("data:")) {
const thumbnailBlob = dataUrlToBlob(file.preview)
const thumbnailKey = `${file.id}_thumb.jpg`
try {
const result = await context.storage.upload(thumbnailBlob, thumbnailKey, {
contentType: "image/jpeg",
})
// Store thumbnail metadata on file
file.thumbnail = {
url: result.url,
storageKey: result.storageKey,
}
} catch (err) {
console.warn(`Thumbnail upload failed:`, err)
// Non-fatal - continue without thumbnail
}
}
return file
},
},
}))
When to use context.storage:
- ✅ Uploading generated derivatives (thumbnails, resized images, watermarked versions)
- ✅ Creating multiple size variants of an image
- ✅ Uploading processed/transformed versions alongside originals
- ❌ Don't use for the main file upload (handled by the storage plugin's
uploadhook)
Important notes:
context.storageisundefinedif no storage adapter is configured- Always check
context.storagebefore using it - Use the standalone
upload()method, nothooks.upload() - The storage adapter handles retries, authentication, and error handling internally
Emitting Events
Plugins can emit custom events that users can listen to:
defineProcessingPlugin((options) => ({
id: "my-plugin",
hooks: {
process: async (file, context) => {
context.emit("processing", { file })
// Do work...
context.emit("complete", { file, result: "success" })
return file
},
},
}))
// Users listen with plugin ID prefix
uploader.on("my-plugin:processing", ({ file }) => {
console.log("Processing:", file.name)
})
uploader.on("my-plugin:complete", ({ file, result }) => {
console.log("Done:", file.name, result)
})
TypeScript: Typed Events
Define event types for full type safety:
type MyPluginEvents = {
start: { file: UploadFile }
progress: { file: UploadFile; percent: number }
complete: { file: UploadFile; savedBytes: number }
error: { file: UploadFile; error: Error }
}
export const MyPlugin = defineProcessingPlugin<MyOptions, MyPluginEvents>((options) => ({
id: "my-plugin",
hooks: {
process: async (file, context) => {
// context.emit is typed!
context.emit("start", { file })
context.emit("progress", { file, percent: 50 })
context.emit("complete", { file, savedBytes: 1000 })
return file
},
},
}))
Complete Example
import { defineProcessingPlugin, type UploadFile, type FileError } from "nuxt-upload-kit"
interface ImageResizerOptions {
maxDimension: number
quality?: number
}
type ImageResizerEvents = {
resize: { file: UploadFile; from: { w: number; h: number }; to: { w: number; h: number } }
skip: { file: UploadFile; reason: string }
}
/**
* Resizes images to fit within a maximum dimension while preserving aspect ratio.
*
* @example
* ```ts
* const uploader = useUploadKit({
* plugins: [
* PluginImageResizer({ maxDimension: 800, quality: 0.9 })
* ]
* })
*
* uploader.on('image-resizer:resize', ({ file, from, to }) => {
* console.log(`Resized ${file.name} from ${from.w}x${from.h} to ${to.w}x${to.h}`)
* })
* ```
*/
export const PluginImageResizer = defineProcessingPlugin<ImageResizerOptions, ImageResizerEvents>((options) => {
const { maxDimension, quality = 0.85 } = options
return {
id: "image-resizer",
hooks: {
process: async (file, context) => {
// Skip non-images
if (!file.mimeType.startsWith("image/")) {
return file
}
// Skip remote files
if (file.source !== "local") {
context.emit("skip", { file, reason: "Remote file" })
return file
}
try {
const img = await createImageBitmap(file.data)
// Skip if already small enough
if (img.width <= maxDimension && img.height <= maxDimension) {
context.emit("skip", { file, reason: "Already within size limits" })
return file
}
// Calculate new dimensions
const ratio = Math.min(maxDimension / img.width, maxDimension / img.height)
const newWidth = Math.round(img.width * ratio)
const newHeight = Math.round(img.height * ratio)
// Resize
const canvas = document.createElement("canvas")
canvas.width = newWidth
canvas.height = newHeight
const ctx = canvas.getContext("2d")!
ctx.drawImage(img, 0, 0, newWidth, newHeight)
const resizedBlob = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => resolve(blob!), file.mimeType, quality)
})
context.emit("resize", {
file,
from: { w: img.width, h: img.height },
to: { w: newWidth, h: newHeight },
})
return {
...file,
data: resizedBlob,
size: resizedBlob.size,
meta: {
...file.meta,
resized: true,
originalDimensions: { width: img.width, height: img.height },
},
}
} catch (error) {
console.warn(`[ImageResizer] Failed for ${file.name}:`, error)
return file // Return original on failure
}
},
},
}
})
Best Practices
1. Always Check File Source
Remote files (source: "storage") have data: null. Always check before accessing file data:
process: async (file, context) => {
if (file.source !== "local") {
context.emit("skip", { file, reason: "Remote file" })
return file
}
// Safe to access file.data
const blob = file.data
}
2. Return Original on Failure
Never throw from process hooks unless you want to fail the upload. Return the original file to continue:
process: async (file, context) => {
try {
// Transform file...
return transformedFile
} catch (error) {
console.warn(`[MyPlugin] Failed:`, error)
return file // Continue with original
}
}
3. Use Namespaced Plugin IDs
Prefix your plugin IDs to avoid collisions:
// Good
id: "mycompany-watermark"
id: "acme-validator-dimensions"
// Avoid
id: "watermark" // Too generic
4. Emit Events for Observability
Emit events so users can track plugin activity:
process: async (file, context) => {
context.emit("start", { file })
// Do work...
context.emit("complete", { file, savings: originalSize - newSize })
return file
}
5. Document Your Plugin
Include JSDoc comments with examples:
/**
* Validates that images meet minimum dimension requirements.
*
* @example
* ```ts
* useUploadKit({
* plugins: [
* ValidatorMinDimensions({ minWidth: 800, minHeight: 600 })
* ]
* })
* ```
*/
export const ValidatorMinDimensions = defineProcessingPlugin<Options>((options) => ({
// ...
}))
6. Handle All Image Types Appropriately
Some formats need special handling:
process: async (file, context) => {
// Skip non-processable formats
if (file.mimeType === "image/gif" || file.mimeType === "image/svg+xml") {
context.emit("skip", { file, reason: "Format not supported" })
return file
}
// Process other images...
}

