ClamAV antivirus scanning for Node.js — clean, typed, zero dependencies.
pompelmi is a minimal Node.js wrapper around ClamAV that exposes a single async function — scan() — and returns one of three typed verdict Symbols: Verdict.Clean, Verdict.Malicious, or Verdict.ScanError. Full documentation at pompelmi.app.
It supports two scanning modes:
- Local — spawns
clamscanas a child process and maps its exit code to a verdict. No stdout parsing, no regex. - Remote / Docker — streams the file to a running
clamddaemon over TCP using the ClamAVINSTREAMprotocol.
No cloud. No daemon required for local mode. No native bindings. Zero runtime dependencies.
If you need to scan file uploads for viruses in Node.js, integrate ClamAV with Express or Fastify, or add antivirus scanning to any upload pipeline, pompelmi is the simplest path.
Most integrations require parsing ClamAV's stdout with regex, managing a clamd daemon, or working around unmaintained packages. pompelmi does none of that: one function call, exit-code-mapped verdicts, zero dependencies.
- Single
scan(filePath, [options])function — works locally or against a remote clamd instance scanBuffer(buffer, [options])— scan in-memory Buffers directly, no temp file required in TCP mode- Symbol-based verdicts (
Verdict.Clean/Verdict.Malicious/Verdict.ScanError) — typo-proof comparisons - Full TCP/clamd support via the INSTREAM protocol with configurable host, port, and timeout
- Built-in helpers to install ClamAV and update virus definitions programmatically
- Works with Express, Fastify, and any other Node.js HTTP framework
- Zero runtime dependencies — ships nothing but source code
- Tested with EICAR standard antivirus test files
- CommonJS module; TypeScript type declarations available inline
- Node.js — any LTS release (no native addons, no C++ bindings)
- ClamAV — must be installed on the host or reachable over TCP
pompelmi does not bundle or automatically download ClamAV. Install it once per machine (see Installing ClamAV).
See pompelmi.app for the full getting-started guide.
# npm
npm install pompelmi
# yarn
yarn add pompelmi
# pnpm
pnpm add pompelmiRun ClamAV as a sidecar and point pompelmi at it — no local install needed on the application host.
# docker-compose.yml
services:
clamav:
image: clamav/clamav:stable
ports:
- "3310:3310"const result = await scan('/path/to/upload.zip', {
host: '127.0.0.1',
port: 3310,
});See Docker / remote scanning for details.
const { scan, Verdict } = require('pompelmi');
const result = await scan('/path/to/file.pdf');
if (result === Verdict.Clean) console.log('File is safe.');
if (result === Verdict.Malicious) throw new Error('Malware detected — file rejected.');
if (result === Verdict.ScanError) console.warn('Scan incomplete — treat file as untrusted.');const express = require('express');
const multer = require('multer');
const fs = require('fs');
const { scan, Verdict } = require('pompelmi');
const upload = multer({ dest: './uploads' });
const app = express();
app.post('/upload', upload.single('file'), async (req, res) => {
const filePath = req.file.path;
try {
const result = await scan(filePath);
if (result === Verdict.Malicious) {
fs.unlinkSync(filePath);
return res.status(422).json({ error: 'Malicious file rejected.' });
}
if (result === Verdict.ScanError) {
fs.unlinkSync(filePath);
return res.status(422).json({ error: 'Scan incomplete — file rejected as precaution.' });
}
return res.json({ ok: true, file: req.file.filename });
} catch (err) {
fs.unlink(filePath, () => {});
return res.status(500).json({ error: `Scan failed: ${err.message}` });
}
});
app.listen(3000);const Fastify = require('fastify');
const { pipeline } = require('stream/promises');
const fs = require('fs');
const path = require('path');
const { scan, Verdict } = require('pompelmi');
const app = Fastify({ logger: true });
app.register(require('@fastify/multipart'));
app.post('/upload', async (req, reply) => {
const data = await req.file();
const filePath = path.join('./uploads', `${Date.now()}-${data.filename}`);
await pipeline(data.file, fs.createWriteStream(filePath));
const result = await scan(filePath);
if (result !== Verdict.Clean) {
fs.unlinkSync(filePath);
return reply.code(422).send({ error: result.description });
}
return reply.send({ ok: true });
});const { scan, Verdict } = require('pompelmi');
const path = require('path');
async function safeScan(filePath) {
try {
const result = await scan(path.resolve(filePath));
if (result === Verdict.ScanError) {
// clamscan exited with code 2 — I/O error, encrypted archive, etc.
console.warn('Scan could not complete — rejecting file as precaution.');
return null;
}
return result; // Verdict.Clean or Verdict.Malicious
} catch (err) {
// filePath not a string, file not found, clamscan not in PATH, etc.
console.error('Scan failed:', err.message);
return null;
}
}const { scan } = require('pompelmi');
const files = ['/uploads/a.pdf', '/uploads/b.zip', '/uploads/c.png'];
const results = await Promise.all(files.map((f) => scan(f)));const { scanBuffer, Verdict } = require('pompelmi');
// Useful with multer memoryStorage or any in-memory upload
const result = await scanBuffer(req.file.buffer);
if (result === Verdict.Malicious) throw new Error('Malware detected.');
if (result === Verdict.ScanError) console.warn('Scan incomplete.');Pass host and port to switch from the local clamscan CLI to the clamd TCP daemon. Everything else — the returned verdicts, error types — is identical.
const result = await scan('/path/to/file.zip', {
host: '127.0.0.1',
port: 3310,
timeout: 30_000, // socket idle timeout, ms — default 15 000
});pompelmi uses the ClamAV INSTREAM protocol: the file is streamed in 64 KB chunks, each prefixed with a 4-byte big-endian length header, terminated by four zero bytes. The response line (stream: OK, stream: <name> FOUND, or an error) is mapped to the same verdict Symbols.
pompelmi has no configuration file or environment variables. All options are passed directly to scan().
| Option | Type | Default | Description |
|---|---|---|---|
host |
string |
— | clamd hostname. Enables TCP mode when set. |
port |
number |
3310 |
clamd port. |
timeout |
number |
15000 |
Socket idle timeout in milliseconds (TCP mode only). |
When neither host nor port is provided, pompelmi spawns clamscan --no-summary <filePath> locally.
scan(
filePath: string,
options?: { host?: string; port?: number; timeout?: number }
): Promise<symbol>Returns a Promise that resolves to one of:
| Verdict | ClamAV exit code / response | Meaning |
|---|---|---|
Verdict.Clean |
0 / stream: OK |
No threats found. |
Verdict.Malicious |
1 / <name> FOUND |
A known virus or malware signature was matched. |
Verdict.ScanError |
2 / other response |
Scan failed — I/O error, encrypted archive, permission denied. Treat file as untrusted. |
Rejects with an Error in these cases:
| Condition | Error message |
|---|---|
filePath is not a string |
filePath must be a string |
| File does not exist | File not found: <path> |
clamscan not in PATH |
ENOENT (from the OS) |
| ClamAV returns an unknown exit code | Unexpected exit code: N |
| Process killed by signal | Process killed by signal: <SIG> |
| clamd connection timed out | clamd connection timed out after Nms |
Each Verdict Symbol exposes a .description property for safe serialisation:
Verdict.Clean.description // 'Clean'
Verdict.Malicious.description // 'Malicious'
Verdict.ScanError.description // 'ScanError'scanBuffer(
buffer: Buffer,
options?: { host?: string; port?: number; timeout?: number }
): Promise<symbol>| Parameter | Type | Description |
|---|---|---|
buffer |
Buffer |
The in-memory buffer to scan |
options |
object |
Same options as scan() — host, port, timeout |
Returns the same three Symbol verdicts as scan(): Verdict.Clean, Verdict.Malicious, Verdict.ScanError.
Rejects with the same error types as scan() where applicable, plus:
| Condition | Error message |
|---|---|
buffer is not a Buffer |
buffer must be a Buffer |
buffer is empty |
buffer is empty |
In TCP mode (host or port provided), the buffer is streamed directly to clamd via the INSTREAM protocol — no data is written to disk. In local mode, a temp file is written to os.tmpdir() and deleted automatically in a finally block regardless of outcome.
Installs ClamAV using the platform's native package manager. Resolves immediately if ClamAV is already installed.
ClamAVInstaller(): Promise<string>| Platform | Package manager | Command |
|---|---|---|
| macOS | Homebrew | brew install clamav |
| Linux | apt-get | sudo apt-get install -y clamav clamav-daemon |
| Windows | Chocolatey | choco install clamav -y |
Runs freshclam to download or refresh the virus definition database. Skips if the database file is already present.
updateClamAVDatabase(): Promise<string>| Platform | Database path |
|---|---|
| macOS | /usr/local/share/clamav/main.cvd |
| Linux | /var/lib/clamav/main.cvd |
| Windows | C:\ProgramData\ClamAV\main.cvd |
# macOS
brew install clamav && freshclam
# Linux (Debian / Ubuntu)
sudo apt-get install -y clamav clamav-daemon && sudo freshclam
# Windows (Chocolatey)
choco install clamav -yThe examples/ directory contains standalone runnable scripts. Each can be run directly with node examples/<name>.js.
| File | Description |
|---|---|
basic-scan.js |
Scan a single file and log the verdict |
scan-on-upload-express.js |
Express route: scan before saving |
scan-on-upload-fastify.js |
Fastify route: same pattern |
scan-with-options.js |
Remote clamd with custom host, port, timeout |
handle-scan-error.js |
Handle every verdict including hard rejections |
delete-on-malicious.js |
Auto-delete file if malicious |
quarantine-on-malicious.js |
Move infected file to a quarantine folder |
scan-multiple-files.js |
Concurrent scans with Promise.all |
scan-directory.js |
Recursively scan every file in a directory |
scan-buffer.js |
Scan an in-memory Buffer via a temp-file shim |
rest-api-server.js |
Minimal HTTP server exposing POST /scan |
s3-scan-before-upload.js |
Scan locally, then upload to S3 only if clean |
cli-scan.js |
CLI tool: scan file paths, exit non-zero on threats |
scan-with-timeout.js |
Timeout patterns for local and remote scanning |
scan-pdf.js |
PDF upload with extension validation |
scan-image.js |
Image upload with extension validation |
scan-zip.js |
ZIP archive scan (ClamAV recurses automatically) |
install-clamav.js |
Programmatic ClamAV installation |
update-virus-database.js |
Programmatic virus DB update |
typescript-usage.ts |
TypeScript example with inline type declarations |
# 1. Clone and install dev dependencies
git clone https://github.com/pompelmi/pompelmi.git
cd pompelmi
npm install
# 2. Run the test suite
npm test
# 3. Lint
npm run lintTests
test/unit.test.js— runs with Node's built-in test runner. MocksnativeSpawnand platform dependencies; ClamAV is not required.test/scan.test.js— integration tests that spawn realclamscanagainst EICAR test files. Skipped automatically whenclamscanis not inPATH.
Submitting changes
- Fork the repository.
- Create a feature branch:
git checkout -b feat/your-change. - Make your changes and confirm
npm testpasses. - Open a pull request against
main.
Please read CODE_OF_CONDUCT.md before contributing. To report a security vulnerability, see SECURITY.md.
ISC — © pompelmi contributors
pompelmi.app · npm · GitHub
