playbook/outfitter-agents/plugins/outfitter/skills/bun-dev/examples/file-uploads.md

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);