Last active
July 13, 2025 00:53
-
-
Save ChrisGV04/8350b53958f207ef2d33a9ea96ecd882 to your computer and use it in GitHub Desktop.
Small example on how to do file uploads on Nuxt 3 & Nitro. I believe it requires Node v20+. Heavily inspired by NuxtHub
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script setup lang="ts"> | |
async function uploadFiles(e: Event) { | |
const input = e.target as HTMLInputElement; | |
const files = input.files; | |
if (!files) return; | |
const fd = new FormData(); | |
for (const file of files) { | |
fd.append('files', file, file.name); | |
} | |
try { | |
// Send files as FormData | |
await $fetch('/api/files/bulk', { method: 'POST', body: fd }); | |
} catch (error) { | |
console.log('Error uploading files:', error); | |
} | |
} | |
</script> | |
<template> | |
<input multiple type="file" @change="uploadFiles"> | |
</template> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// File path: server/api/files/bulk.post.ts | |
import { handleFileUpload, receiveFiles } from '~/server/utils/blob'; | |
/** | |
* Event handler to receive multiple files | |
*/ | |
export default defineEventHandler(async (event) => { | |
const files = await receiveFiles(event, { | |
multiple: 20, // Max 20 files at a time for now | |
ensure: { | |
maxSize: '50MB', // Max 50 MB each file | |
types: ['audio', 'csv', 'image', 'video', 'pdf', 'text'], | |
}, | |
}); | |
for (const file of files) { | |
await handleFileUpload(file, true); | |
} | |
return sendNoContent(event); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// File path: server/api/files/index.post.ts | |
import { receiveFiles, handleFileUpload } from '~/server/utils/blob'; | |
/** | |
* Event handler to receive a single file | |
*/ | |
export default defineEventHandler(async (event) => { | |
const [file] = await receiveFiles(event, { | |
formKey: 'file', | |
multiple: false, | |
ensure: { | |
maxSize: '128MB', | |
types: ['audio', 'csv', 'image', 'video', 'pdf', 'text'], | |
}, | |
}); | |
await handleFileUpload(file); | |
return sendNoContent(event); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// File path: server/types/blob.ts | |
/////////////////////////////////////////////////////////// | |
// Functionality based on Nuxt Hub's Blob implementation // | |
/////////////////////////////////////////////////////////// | |
// Credits from shared utils of https://github.com/pingdotgg/uploadthing | |
export type FileSizeUnit = 'B' | 'KB' | 'MB' | 'GB'; | |
export type BlobSize = `${number}${FileSizeUnit}`; | |
export type BlobType = | |
| 'image' | |
| 'video' | |
| 'audio' | |
| 'pdf' | |
| 'csv' | |
| 'text' | |
| 'blob' | |
| (string & Record<never, never>); | |
export interface BlobUploadOptions { | |
/** | |
* The key to get the file/files from the request form. | |
* @default 'files' | |
*/ | |
formKey?: string; | |
/** | |
* Whether to allow multiple files to be uploaded. | |
* @default true | |
*/ | |
multiple?: boolean | number; | |
/** | |
* Options used for the ensure() method. | |
*/ | |
ensure?: BlobEnsureOptions; | |
} | |
export interface BlobEnsureOptions { | |
/** | |
* The maximum size of the blob (e.g. '1MB') | |
*/ | |
maxSize?: BlobSize; | |
/** | |
* The allowed types of the blob (e.g. ['image/png', 'application/json', 'video']) | |
*/ | |
types?: BlobType[]; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// File path: server/utils/blob.ts | |
import type { H3Event } from 'h3'; | |
import type { BlobUploadOptions, BlobEnsureOptions, BlobSize, FileSizeUnit } from '~/server/types/blob'; | |
import { defu } from 'defu'; | |
import mime from 'mime'; | |
import crypto from 'node:crypto'; | |
import fs from 'node:fs'; | |
import path from 'node:path'; | |
import { pipeline } from 'node:stream'; | |
import util from 'node:util'; | |
import { parse } from 'pathe'; | |
// Credits from shared utils of https://github.com/pingdotgg/uploadthing | |
const FILESIZE_UNITS = ['B', 'KB', 'MB', 'GB']; | |
/** | |
* Helper function that converts any valid BlobSize into numeric bytes value | |
* | |
* @example "1MB", "1500B", "1.2GB" | |
* | |
* @throws If the input is not a valid BlobSize | |
*/ | |
export function fileSizeToBytes(input: BlobSize) { | |
const regex = new RegExp(`^(\\d+)(\\.\\d+)?\\s*(${FILESIZE_UNITS.join('|')})$`, 'i'); | |
const match = input.match(regex); | |
if (!match) { | |
throw createError({ | |
statusCode: 500, | |
message: `Invalid file size format: ${input}`, | |
}); | |
} | |
const sizeValue = Number.parseFloat(match[1]); | |
const sizeUnit = match[3].toUpperCase() as FileSizeUnit; | |
if (!FILESIZE_UNITS.includes(sizeUnit)) { | |
throw createError({ | |
statusCode: 500, | |
message: `Invalid file size unit: ${sizeUnit}`, | |
}); | |
} | |
const bytes = sizeValue * Math.pow(1024, FILESIZE_UNITS.indexOf(sizeUnit)); | |
return Math.floor(bytes); | |
} | |
/** | |
* Ensure the blob is valid and meets the specified requirements. | |
* | |
* @param blob The blob to check | |
* @param options The options to check against | |
* @param options.maxSize The maximum size of the blob (e.g. '1MB') | |
* @param options.types The allowed types of the blob (e.g. ['image/png', 'application/json', 'video']) | |
* | |
* @throws If the blob does not meet the requirements | |
*/ | |
export function ensureBlob(blob: Blob, options: BlobEnsureOptions = {}) { | |
if (!(blob instanceof Blob)) { | |
throw createError({ | |
statusCode: 400, | |
message: 'Received invalid file', | |
}); | |
} | |
if (options.maxSize) { | |
const maxFileSizeBytes = fileSizeToBytes(options.maxSize); | |
if (blob.size > maxFileSizeBytes) { | |
throw createError({ | |
statusCode: 400, | |
message: `File too heavy. Max size is: ${options.maxSize}`, | |
}); | |
} | |
} | |
const [blobType, blobSubtype] = blob.type.split('/'); | |
if ( | |
options.types?.length && | |
!options.types?.includes(blob.type) && | |
!options.types?.includes(blobType) && | |
!options.types?.includes(blobSubtype) | |
) { | |
throw createError({ | |
statusCode: 400, | |
message: `Invalid file type. Only allowed: ${options.types.join(', ')}`, | |
}); | |
} | |
} | |
/** | |
* Utility to receive a file or files from body's FormData without storing it. | |
* | |
* @throws | |
* If the files are invalid or don't meet the ensure conditions. | |
*/ | |
export async function receiveFiles(event: H3Event, options: BlobUploadOptions = {}) { | |
options = defu(options, { formKey: 'files', multiple: true } satisfies BlobUploadOptions); | |
const form = await readFormData(event); | |
const files = form.getAll(options.formKey!) as File[]; | |
if (!files?.length) throw createBadRequestError('No recibimos ningún archivo'); | |
if (!options.multiple && files.length > 1) throw createBadRequestError('No se permiten múltiples archivos'); | |
if (typeof options.multiple === 'number' && files.length > options.multiple) | |
throw createBadRequestError('Número de archivos excedido', `Máximo permitido: ${options.multiple}`); | |
if (options.ensure?.maxSize || options.ensure?.types?.length) { | |
for (const file of files) { | |
ensureBlob(file, options.ensure); | |
} | |
} | |
return files; | |
} | |
/** Simple utility to get the MIME type of a file */ | |
export function getContentType(pathOrExtension?: string) { | |
return (pathOrExtension && mime.getType(pathOrExtension)) || 'application/octet-stream'; | |
} | |
/** Inspired by Fastify's upload guide. Used to pipe the file stream into fs */ | |
const pump = util.promisify(pipeline); | |
/** Generates a safe file name and stores the file on the disk */ | |
async function handleFileUpload(file: File) { | |
const { ext } = parse(file.name); | |
/** Unique file name with current UNIX date, 16 random chracters and the original file extension */ | |
const safeFileName = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`; | |
/** The path to the directory where you want to save the file in the local file system */ | |
const filePath = path.resolve('public/uploads', safeFileName); | |
try { | |
/** Stream the file into the file system to save it */ | |
await pump(file.stream() as any, fs.createWriteStream(filePath)); | |
} catch (error) { | |
console.error('Error uploading file:', error); | |
/** Return error response to the client */ | |
throw createError({ statusCode: 500, message: 'Error uploading file' }); | |
} | |
/////////////////////////////////////////////////////////////////////////// | |
// Do anything else you want here. The file is now saved at the filePath // | |
/////////////////////////////////////////////////////////////////////////// | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment