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.
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.jsnpm install hono @hono/node-server pompelmiBun
bun add hono pompelmi
Install ClamAV on the host:
Linux (Debian / Ubuntu)sudo apt-get install -y clamav && sudo freshclammacOS
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
- Running ClamAV in Docker alongside Hono? See Running pompelmi with ClamAV in Docker Compose.
- Uploading to object storage after scanning? See Scanning files before uploading to AWS S3 or Cloudflare R2.
- Want the complete upload security picture? Read Node.js file upload security checklist.