10 KiB
10 KiB
File Upload Patterns
Streaming file handling with Bun.file and Bun.write.
Basic Upload
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
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
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
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
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)
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
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, 'id' | 'uploadedAt'>): 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
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)
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
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);