Virus scanning in Hono.js with pompelmi

Hono is a lightweight, ultra-fast web framework designed to run on multiple runtimes: Node.js, Bun, Deno, Cloudflare Workers, and more. Its API is built around the Web standard Request / Response objects and FormData, making file upload handling consistent across runtimes.

This guide integrates pompelmi into Hono upload routes. The same code works on both Node.js and Bun — the only difference is the server adapter you import.

New to pompelmi? Start with Getting started with antivirus scanning in Node.js to install ClamAV, then return here for the Hono integration.

Runtime note

pompelmi uses child_process.spawn (CLI mode) or Node's net module (TCP mode). Both are available on Node.js and Bun. They are not available on Cloudflare Workers or Deno Deploy. If you deploy Hono to those edge runtimes, run pompelmi in a separate Node.js / Bun microservice and call it over HTTP.

Install

Node.js
npm install hono @hono/node-server pompelmi
Bun
bun add hono pompelmi

Install ClamAV on the host:

Linux (Debian / Ubuntu)
sudo apt-get install -y clamav && sudo freshclam
macOS
brew install clamav && freshclam

Upload route

Hono exposes the uploaded file as a Web API File object via c.req.formData(). Write the bytes to a temp path, scan, then clean up in a finally block:

// src/index.ts
import { Hono } from 'hono';
import { scan, Verdict } from 'pompelmi';
import { writeFile, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto';

const app = new Hono();

app.post('/upload', async (c) => {
  let tmpPath: string | null = null;

  try {
    const body = await c.req.formData();
    const file = body.get('file') as File | null;

    if (!file || file.size === 0) {
      return c.json({ error: 'No file provided.' }, 400);
    }

    const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
    if (file.size > MAX_BYTES) {
      return c.json({ error: 'File too large.' }, 413);
    }

    // Web API File → Buffer → temp file
    const buffer = Buffer.from(await file.arrayBuffer());
    const ext    = file.name.split('.').pop()?.toLowerCase() ?? 'bin';
    tmpPath      = join(tmpdir(), randomBytes(16).toString('hex') + '.' + ext);
    await writeFile(tmpPath, buffer);

    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      return c.json({ error: 'Malware detected. Upload rejected.' }, 400);
    }
    if (verdict === Verdict.ScanError) {
      return c.json({ error: 'Scan could not complete. Upload rejected.' }, 422);
    }

    // File is clean — persist to storage here
    return c.json({ status: 'ok', name: file.name });

  } catch (err) {
    const msg = err instanceof Error ? err.message : 'Unknown error';
    return c.json({ error: msg }, 500);

  } finally {
    if (tmpPath && existsSync(tmpPath)) {
      await unlink(tmpPath).catch(() => {});
    }
  }
});

export default app;

Reusable scan middleware

If you have multiple upload routes, extract the scan logic into a Hono middleware. The middleware writes the file to disk, scans it, attaches the cleaned path to the context, and calls next():

// src/middleware/antivirus.ts
import { createMiddleware } from 'hono/factory';
import { scan, Verdict } from 'pompelmi';
import { writeFile, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto';

declare module 'hono' {
  interface ContextVariableMap {
    scannedFile: { path: string; name: string; size: number };
  }
}

export const antivirusMiddleware = createMiddleware(async (c, next) => {
  let tmpPath: string | null = null;

  try {
    const body = await c.req.formData();
    const file = body.get('file') as File | null;

    if (!file || file.size === 0) {
      return c.json({ error: 'No file provided.' }, 400);
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    const ext    = file.name.split('.').pop()?.toLowerCase() ?? 'bin';
    tmpPath      = join(tmpdir(), randomBytes(16).toString('hex') + '.' + ext);
    await writeFile(tmpPath, buffer);

    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      return c.json({ error: 'Malware detected.' }, 400);
    }
    if (verdict === Verdict.ScanError) {
      return c.json({ error: 'Scan incomplete. Rejected.' }, 422);
    }

    c.set('scannedFile', { path: tmpPath, name: file.name, size: file.size });
    tmpPath = null; // ownership transferred — route handler cleans up

    await next();

  } finally {
    if (tmpPath && existsSync(tmpPath)) {
      await unlink(tmpPath).catch(() => {});
    }
  }
});

Use it on any route that needs scanning:

import { antivirusMiddleware } from './middleware/antivirus';

app.post('/documents', antivirusMiddleware, async (c) => {
  const { path, name } = c.get('scannedFile');
  // Upload `path` to storage, then unlink it
  return c.json({ status: 'ok', name });
});

Running on Bun

On Bun, export the Hono app directly — Bun's built-in HTTP server handles it:

// src/index.ts (Bun)
import { Hono } from 'hono';
// ... routes defined above ...

export default {
  port: 3000,
  fetch: app.fetch,
};
Start the server
bun run src/index.ts

On Node.js, use @hono/node-server:

// src/index.ts (Node.js)
import { serve } from '@hono/node-server';
serve({ fetch: app.fetch, port: 3000 },
  () => console.log('Listening on http://localhost:3000'));

Next steps