# File Upload Patterns Streaming file handling with Bun.file and Bun.write. ## Basic Upload ```typescript import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; const app = new Hono(); app.post('/upload', async (c) => { const body = await c.req.parseBody(); const file = body.file as File; if (!file) { throw new HTTPException(400, { message: 'File is required' }); } const filename = `${crypto.randomUUID()}-${file.name}`; const filepath = `./uploads/${filename}`; await Bun.write(filepath, file); return c.json({ filename, size: file.size, type: file.type }, 201); }); ``` ## With Validation ```typescript const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; const MAX_SIZE = 10 * 1024 * 1024; // 10MB app.post('/upload/image', async (c) => { const body = await c.req.parseBody(); const file = body.file as File; if (!file) { throw new HTTPException(400, { message: 'File is required' }); } // Validate type if (!ALLOWED_TYPES.includes(file.type)) { throw new HTTPException(400, { message: `Invalid file type. Allowed: ${ALLOWED_TYPES.join(', ')}` }); } // Validate size if (file.size > MAX_SIZE) { throw new HTTPException(400, { message: `File too large. Max size: ${MAX_SIZE / 1024 / 1024}MB` }); } // Generate safe filename const ext = file.name.split('.').pop()?.toLowerCase() || 'bin'; const filename = `${crypto.randomUUID()}.${ext}`; const filepath = `./uploads/${filename}`; await Bun.write(filepath, file); return c.json({ filename, size: file.size, type: file.type }, 201); }); ``` ## Multiple Files ```typescript app.post('/upload/multiple', async (c) => { const body = await c.req.parseBody({ all: true }); const files = body.files as File[]; if (!files || files.length === 0) { throw new HTTPException(400, { message: 'At least one file is required' }); } const results = []; for (const file of files) { if (file.size > MAX_SIZE) { throw new HTTPException(400, { message: `File ${file.name} exceeds max size` }); } const ext = file.name.split('.').pop()?.toLowerCase() || 'bin'; const filename = `${crypto.randomUUID()}.${ext}`; const filepath = `./uploads/${filename}`; await Bun.write(filepath, file); results.push({ original: file.name, filename, size: file.size, type: file.type }); } return c.json({ files: results }, 201); }); ``` ## Streaming Large Files ```typescript app.post('/upload/large', async (c) => { const body = await c.req.parseBody(); const file = body.file as File; if (!file) { throw new HTTPException(400, { message: 'File is required' }); } const filename = `${crypto.randomUUID()}.bin`; const filepath = `./uploads/${filename}`; // Stream directly to disk — efficient for large files await Bun.write(filepath, file.stream()); return c.json({ filename, size: file.size }, 201); }); ``` ## Download Files ```typescript app.get('/files/:filename', async (c) => { const filename = c.req.param('filename'); // Prevent directory traversal if (filename.includes('..') || filename.includes('/')) { throw new HTTPException(400, { message: 'Invalid filename' }); } const filepath = `./uploads/${filename}`; const file = Bun.file(filepath); if (!(await file.exists())) { throw new HTTPException(404, { message: 'File not found' }); } return c.body(file.stream(), { headers: { 'Content-Type': file.type, 'Content-Length': file.size.toString(), 'Content-Disposition': `attachment; filename="${filename}"` } }); }); ``` ## Inline Display (Images) ```typescript app.get('/images/:filename', async (c) => { const filename = c.req.param('filename'); if (filename.includes('..') || filename.includes('/')) { throw new HTTPException(400, { message: 'Invalid filename' }); } const filepath = `./uploads/${filename}`; const file = Bun.file(filepath); if (!(await file.exists())) { throw new HTTPException(404, { message: 'File not found' }); } // Verify it's an image if (!file.type.startsWith('image/')) { throw new HTTPException(400, { message: 'Not an image' }); } return c.body(file.stream(), { headers: { 'Content-Type': file.type, 'Content-Length': file.size.toString(), 'Cache-Control': 'public, max-age=31536000' // 1 year cache } }); }); ``` ## With Database Metadata ```typescript import { Database } from 'bun:sqlite'; type FileRecord = { id: string; filename: string; originalName: string; mimeType: string; size: number; uploadedAt: string; userId: string; }; class FileRepository { constructor(private db: Database) { this.db.run(` CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, filename TEXT UNIQUE NOT NULL, original_name TEXT NOT NULL, mime_type TEXT NOT NULL, size INTEGER NOT NULL, uploaded_at TEXT DEFAULT CURRENT_TIMESTAMP, user_id TEXT NOT NULL ) `); } create(data: Omit): FileRecord { const id = crypto.randomUUID(); return this.db.prepare(` INSERT INTO files (id, filename, original_name, mime_type, size, user_id) VALUES (?, ?, ?, ?, ?, ?) RETURNING * `).get(id, data.filename, data.originalName, data.mimeType, data.size, data.userId) as FileRecord; } findById(id: string): FileRecord | null { return this.db.prepare('SELECT * FROM files WHERE id = ?').get(id) as FileRecord | null; } findByUser(userId: string): FileRecord[] { return this.db.prepare('SELECT * FROM files WHERE user_id = ? ORDER BY uploaded_at DESC').all(userId) as FileRecord[]; } delete(id: string): boolean { const result = this.db.prepare('DELETE FROM files WHERE id = ? RETURNING filename').get(id) as { filename: string } | null; return result !== null; } } // API with metadata app.post('/files', async (c) => { const userId = c.get('userId'); // From auth middleware const body = await c.req.parseBody(); const file = body.file as File; if (!file) { throw new HTTPException(400, { message: 'File is required' }); } const ext = file.name.split('.').pop()?.toLowerCase() || 'bin'; const filename = `${crypto.randomUUID()}.${ext}`; const filepath = `./uploads/${filename}`; await Bun.write(filepath, file); const files = c.get('files') as FileRepository; const record = files.create({ filename, originalName: file.name, mimeType: file.type, size: file.size, userId }); return c.json({ file: record }, 201); }); app.delete('/files/:id', async (c) => { const files = c.get('files') as FileRepository; const record = files.findById(c.req.param('id')); if (!record) { throw new HTTPException(404, { message: 'File not found' }); } // Delete from disk const filepath = `./uploads/${record.filename}`; const file = Bun.file(filepath); if (await file.exists()) { await Bun.write(filepath, ''); // Clear file // Or use node:fs for actual deletion } // Delete from database files.delete(record.id); return c.json({ deleted: true }); }); ``` ## Image Processing ```typescript import sharp from 'sharp'; // npm install sharp const THUMBNAIL_SIZE = 200; app.post('/upload/image-with-thumbnail', async (c) => { const body = await c.req.parseBody(); const file = body.file as File; if (!file) { throw new HTTPException(400, { message: 'File is required' }); } if (!file.type.startsWith('image/')) { throw new HTTPException(400, { message: 'Must be an image' }); } const id = crypto.randomUUID(); const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg'; // Save original const originalPath = `./uploads/${id}.${ext}`; const buffer = await file.arrayBuffer(); await Bun.write(originalPath, buffer); // Create thumbnail const thumbnailPath = `./uploads/${id}-thumb.${ext}`; await sharp(Buffer.from(buffer)) .resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: 'cover' }) .toFile(thumbnailPath); return c.json({ id, original: `${id}.${ext}`, thumbnail: `${id}-thumb.${ext}`, size: file.size, type: file.type }, 201); }); ``` ## Presigned URLs (S3-style) ```typescript import { sign, verify } from 'hono/jwt'; const SECRET = Bun.env.JWT_SECRET!; const EXPIRY = 3600; // 1 hour // Generate presigned URL app.post('/files/:id/presign', async (c) => { const fileId = c.req.param('id'); const files = c.get('files') as FileRepository; const record = files.findById(fileId); if (!record) { throw new HTTPException(404, { message: 'File not found' }); } const token = await sign({ fileId, exp: Math.floor(Date.now() / 1000) + EXPIRY }, SECRET); const url = `${c.req.url.split('/files')[0]}/download?token=${token}`; return c.json({ url, expiresIn: EXPIRY }); }); // Download with presigned URL app.get('/download', async (c) => { const token = c.req.query('token'); if (!token) { throw new HTTPException(401, { message: 'Token required' }); } try { const payload = await verify(token, SECRET); const fileId = payload.fileId as string; const files = c.get('files') as FileRepository; const record = files.findById(fileId); if (!record) { throw new HTTPException(404, { message: 'File not found' }); } const filepath = `./uploads/${record.filename}`; const file = Bun.file(filepath); return c.body(file.stream(), { headers: { 'Content-Type': record.mimeType, 'Content-Length': record.size.toString(), 'Content-Disposition': `attachment; filename="${record.originalName}"` } }); } catch { throw new HTTPException(401, { message: 'Invalid or expired token' }); } }); ``` ## Cleanup Old Files ```typescript import { readdir, unlink, stat } from 'node:fs/promises'; import { join } from 'node:path'; async function cleanupOldFiles(directory: string, maxAgeDays: number) { const files = await readdir(directory); const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000); for (const filename of files) { const filepath = join(directory, filename); const stats = await stat(filepath); if (stats.mtimeMs < cutoff) { await unlink(filepath); console.log(`Deleted old file: ${filename}`); } } } // Run cleanup every hour setInterval(() => { cleanupOldFiles('./uploads', 30); // Delete files older than 30 days }, 60 * 60 * 1000); ```